Compare commits
No commits in common. "main" and "claude/thirsty-goldberg-51bb9b" have entirely different histories.
main
...
claude/thi
597 changed files with 7842 additions and 260107 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -26,11 +26,8 @@ references/*
|
||||||
|
|
||||||
# Claude Code session state
|
# Claude Code session state
|
||||||
.claude/
|
.claude/
|
||||||
# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source)
|
|
||||||
/.superpowers/
|
|
||||||
launch.log
|
launch.log
|
||||||
launch-*.log
|
launch-*.log
|
||||||
proveout*.log
|
|
||||||
launch.utf8.log
|
launch.utf8.log
|
||||||
n4-verify*.log
|
n4-verify*.log
|
||||||
|
|
||||||
|
|
@ -90,12 +87,3 @@ C[-
|
||||||
|
|
||||||
# Junction to Claude Code per-project memory (Obsidian vault visibility)
|
# Junction to Claude Code per-project memory (Obsidian vault visibility)
|
||||||
claude-memory
|
claude-memory
|
||||||
studio-shots/
|
|
||||||
|
|
||||||
# MP1b acdream-bake output — user-machine artifact, never committed
|
|
||||||
# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5).
|
|
||||||
*.pak
|
|
||||||
|
|
||||||
# session-local physics capture artifacts (worktree root)
|
|
||||||
/resolve-*.jsonl
|
|
||||||
/launch-*.log
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/AcDream.App/AcDream.App.csproj" />
|
<Project Path="src/AcDream.App/AcDream.App.csproj" />
|
||||||
<Project Path="src/AcDream.Bake/AcDream.Bake.csproj" />
|
|
||||||
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
|
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
|
||||||
<Project Path="src/AcDream.Content/AcDream.Content.csproj" />
|
|
||||||
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
||||||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||||
|
|
@ -16,8 +14,6 @@
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" />
|
<Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj" />
|
|
||||||
<Project Path="tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj" />
|
|
||||||
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
|
|
|
||||||
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -108,17 +108,18 @@ movement queries.
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
**Currently working toward: M1.5 — Indoor world feels right** (building/cellar demo
|
**Currently working toward: M1.5 — Indoor world feels right.** The
|
||||||
DONE + gated; REMAINING = the critical path: **#137 dungeon collision**,
|
building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13
|
||||||
**#138 teleport-OUT**, **A7 dungeon lighting** #79/#93). One **user-report-driven
|
to include **dungeon support (full Phase G.3)** — dungeons don't work at
|
||||||
parity track** still interleaves at the issue level (NOT a milestone; see the M1.5
|
all: terrain-less dungeon landblocks aren't supported by the streaming/
|
||||||
note in the milestones doc): **D.2b retail UI** (next: container-switching —
|
load/render/physics pipeline (`LandblockLoader.Load` null with no
|
||||||
`claude-memory/project_d2b_retail_ui.md`). The **R5 movement-manager arc is DONE**
|
`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration
|
||||||
(2026-07-05, V5 facade shipped; close-out banner in
|
→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2
|
||||||
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`; carried: #167, R6/TS-42).
|
(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec.
|
||||||
**Track MP** (modern-pipeline perf side track, dedicated sessions only — roadmap
|
Recent closes (2026-06-12/13): #119/#128, #112, #113, #124,
|
||||||
"Track MP") is at MP0. M2 (CombatMath) deferred. Keep this paragraph ≤6 lines + pointers — detail in the docs
|
#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra
|
||||||
below, NOT here.
|
threshold fix). Keep this paragraph ≤5 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
|
||||||
|
|
@ -950,12 +951,6 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
|
||||||
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
|
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
|
||||||
decisions at frame boundaries. Used to converge the U.4c flap fix
|
decisions at frame boundaries. Used to converge the U.4c flap fix
|
||||||
(root indoor visibility at player's cell, not eye).
|
(root indoor visibility at player's cell, not eye).
|
||||||
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
|
|
||||||
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
|
|
||||||
per-armed-tick steer lines (signed gap dist, applied delta, heading
|
|
||||||
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
|
|
||||||
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
|
|
||||||
#171 residuals (the deep-overlap sign pin AP-82).
|
|
||||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
|
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
|
||||||
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
|
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
|
||||||
JSON Lines record with full inputs, PhysicsBody snapshot before AND
|
JSON Lines record with full inputs, PhysicsBody snapshot before AND
|
||||||
|
|
|
||||||
1934
docs/ISSUES.md
1934
docs/ISSUES.md
File diff suppressed because it is too large
Load diff
|
|
@ -37,13 +37,14 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Intentional architecture (IA) — 16 rows
|
## 1. Intentional architecture (IA) — 14 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
|
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
|
||||||
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
|
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
|
||||||
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
|
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
|
||||||
|
| IA-4 | `MultiplyFramerate` omits retail's negative-factor StartFrame↔EndFrame swap (direction encoded in Framerate sign instead) | `src/AcDream.Core/Physics/AnimationSequencer.cs:129` | Our callers (ForwardSpeed updates) only pass positive factors; Advance loop handles negative framerates against StartFrame as lower bound | A future negative-factor caller (reverse playback) scales without swapping bounds — wrong frame range traversal instead of clean reversal | `FUN_005267E0`; ACE Sequence.cs L277-287 |
|
||||||
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
|
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
|
||||||
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
|
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
|
||||||
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
|
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
|
||||||
|
|
@ -51,25 +52,25 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
||||||
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
||||||
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
|
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
|
||||||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
||||||
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
||||||
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
||||||
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
|
|
||||||
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
|
||||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Adaptation (AD) — 33 rows
|
## 2. Adaptation (AD) — 27 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
|
||||||
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail fade cover, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
|
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) |
|
||||||
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
|
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
|
||||||
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
||||||
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
||||||
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
|
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
|
||||||
|
| AD-7 | Full collision exemption on ETHEREAL alone; retail requires ETHEREAL_PS **and** IGNORE_COLLISIONS_PS (ETHEREAL-alone takes the unported `obstruction_ethereal` path) | `src/AcDream.Core/Physics/CollisionExemption.cs:78` | ACE's `Door.Open()` broadcasts ETHEREAL only (0x0001000C); without the shortcut, opened doors stay solid on ACE | ETHEREAL-only targets generate NO contact where retail records contact-but-allows-passage; against a retail-semantics server the bit means something different than we implement | pc:276782 (combined gate), :276795 (obstruction_ethereal) |
|
||||||
|
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
|
||||||
|
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
|
||||||
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
|
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
|
||||||
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
|
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
|
||||||
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
||||||
|
|
@ -85,113 +86,62 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
|
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
|
||||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||||
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||||
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` |
|
||||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius |
|
||||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
|
||||||
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
|
||||||
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
|
|
||||||
| AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) |
|
|
||||||
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
|
|
||||||
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
|
|
||||||
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
|
|
||||||
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit)
|
## 3. Documented approximation (AP) — 34 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
|
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
|
||||||
|
| AP-2 | Visual-AABB fallback collision shape for Setups with no retail physics data; retail emits NO shapes (phantom). **#101** fixed the GfxObj-only class; the Setup-without-shapes fallback remains | `src/AcDream.Core/Physics/PhysicsDataCache.cs:96` | Lets the player collide with decorative meshes shipping no CylSphere/part-BSP instead of walking through furniture-like props | Retail-phantom entities block movement (the **#100/#101** family), and the synthetic box gives non-retail push-out normals when it collides | `CPartArray::InitParts` (cited at PhysicsDataCache.cs:386-389) |
|
||||||
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
||||||
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
||||||
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
||||||
|
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
|
||||||
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
||||||
|
| AP-8 | Remote MoveTo driver is a minimum viable subset: no target re-tracking, no sticky/StickTo, no fail-distance detector, no sphere-cylinder distance variant | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:44` | All server-side concerns the local body needn't model; ACE re-emits MoveTo ~1 Hz with refreshed origins, substituting for re-tracking | If the re-emit cadence assumption breaks (or sticky-follow packets appear), chase/flee motion visibly diverges — orbiting, overshoot, giving up where retail tracks | `MoveToManager::HandleMoveToPosition` 0x00529d80 |
|
||||||
|
| AP-9 | Fixed π/2 rad/s in-motion turn rate; per-creature TurnSpeed unwired | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` | Matches ACE's monster TurnSpeed default; field hook documented for the future port | Creatures with non-default turn speeds rotate at the wrong rate — facing-correction mismatch vs retail observers | run_turn_factor 0x007c8914; `apply_run_to_command` 0x00527be0 |
|
||||||
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
||||||
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
|
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
|
||||||
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
|
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
|
||||||
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
|
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
|
||||||
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
|
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
|
||||||
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
|
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
|
||||||
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
|
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
|
||||||
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
|
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
|
||||||
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
|
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
|
||||||
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
||||||
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
||||||
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing |
|
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing |
|
||||||
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
||||||
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + speculative turn-to-target | `src/AcDream.App/Rendering/GameWindow.cs:11120` | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required (B.6) | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for an auto-walk that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius |
|
||||||
| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:176` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
|
| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:170` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
|
||||||
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
||||||
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
||||||
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
|
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
|
||||||
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
|
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
|
||||||
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
|
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
|
||||||
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
|
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
|
||||||
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
|
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
|
||||||
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
||||||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||||
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
||||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
|
|
||||||
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
|
||||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
|
||||||
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
|
|
||||||
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
|
|
||||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
|
||||||
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
|
|
||||||
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
|
|
||||||
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
|
|
||||||
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
|
|
||||||
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
|
|
||||||
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
|
|
||||||
| AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 |
|
|
||||||
| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation |
|
|
||||||
| AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc |
|
|
||||||
| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x0600127E` + `ItemType.Container` (composited over the Container type-underlay), rather than resolving it at runtime the way retail does. Sprite VISUALLY CONFIRMED (2026-06-22 live gate). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch draws a CONSTANT backpack with `m_itemType = TYPE_CONTAINER` (the original AP-51 "equipped-pack weenie icon" premise was wrong). The exact runtime resolution is unconfirmed: a research dat-dump misreported `GetDIDByEnum(0x10000004,7)` as the green tile `0x060011F4` (which rendered green-no-pack at the gate); the real backpack `0x0600127E` was identified by dat export + user confirmation. We pin the visually-verified literal (cf. AP-55). Retire when the runtime resolve is reproduced + confirmed. | If the pinned sprite diverges from what retail resolves at runtime, the main-pack icon goes stale. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); sprite `0x0600127E` dat-exported + visually confirmed |
|
|
||||||
| AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` |
|
|
||||||
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
|
|
||||||
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
|
||||||
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
|
||||||
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
|
|
||||||
| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` |
|
|
||||||
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
|
|
||||||
| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt |
|
|
||||||
| AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` |
|
|
||||||
| AP-62 | **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred; the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer, so the mapping was not directly confirmed from the named-retail source. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`SlotMap`) | Dropping ammo onto that slot wields to the wrong location if the mapping is wrong. Gate-verify via dat dump + cdb. | Ammo wields to the wrong equip location — functional gap if wrong. | `GetLocationInfoFromElementID` decomp 173676; `acclient.h:3193` INVENTORY_LOC |
|
|
||||||
| AP-63 | **Dual-wield-into-shield-slot special not implemented** — retail `OnItemListDragOver` (decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream's `wieldMask = ValidLocations & slotMask` rejects it. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 |
|
|
||||||
| AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify |
|
|
||||||
| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` |
|
|
||||||
| AP-66 | **Empty equip slots show a generic frame (Slice 1) instead of the retail doll-backed transparent look** — retail's slot-view leaves empty armor slots transparent with the live 3-D doll body behind them; Slice 1 has no doll yet, so empty slots render a visible frame (`0x06004D20`, the inventory grid's empty square) so every position is seen + usable. The "figure" in the paperdoll is the doll, NOT a per-slot silhouette. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`emptySlotSprite`); `src/AcDream.App/Rendering/GameWindow.cs` (PaperdollController.Bind) | Retired in Slice 2 when the doll `UiViewport` (`0x100001D5`) renders behind the slots and the empty slots flip to transparent (`EmptySprite=0`). | Empty equip slots look like plain framed squares instead of revealing the character body — cosmetic, pending Slice 2's doll viewport. | `gmPaperDollUI` init `SetVisible(0)` per slot; user retail screenshots (slot-view) |
|
|
||||||
| AP-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) |
|
|
||||||
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
|
|
||||||
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
|
|
||||||
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
|
|
||||||
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
|
||||||
| AP-72 | **Per-element dat-font resolver (Fix C) wired into the STUDIO path only** — `LayoutImporter.Import` + `DatWidgetFactory` support a `Func<uint,UiDatFont?>` resolver that gives each text element its own dat font; `StudioWindow` supplies it. `GameWindow`'s four `Import`/`Build` call sites pass `null`, so the live game still uses a single global font. **Retire with #157.** | `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` — pattern to mirror into GameWindow) + `src/AcDream.App/Studio/StudioWindow.cs` (resolver wired) + `src/AcDream.App/Rendering/GameWindow.cs` (~4 import sites pass null) | Studio is the proving-ground path; GameWindow threading is Issue #157 (small, safe, orthogonal to M5 gameplay). The live panels still render with the global dat font (same as before Fix C), so behavior is unchanged in the shipped game. | Text elements whose dat `FontDid` differs from the global font render with the wrong size/face in the live game — cosmetic; the studio shows the correct dat font. | `DatWidgetFactory.BuildText`; `LayoutImporter.Import(fontResolver)`; commit `a0d3395` (Fix C) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
|
## 4. Temporary stopgap (TS) — 30 rows
|
||||||
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
|
|
||||||
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
|
|
||||||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
|
||||||
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
|
||||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
|
||||||
| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 1–9). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
|
|
||||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
|
||||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
|
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
||||||
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
|
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
|
||||||
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
|
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
|
||||||
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
||||||
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
|
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2) | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry |
|
||||||
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
|
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
|
||||||
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
|
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
|
||||||
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
|
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
|
||||||
|
|
@ -207,28 +157,16 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
|
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
|
||||||
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
|
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
|
||||||
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
|
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
|
||||||
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
|
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
|
||||||
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1177` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
|
| TS-22 | `adjust_motion` not ported — backward (×−0.65) / strafe (×−1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 |
|
||||||
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
|
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
|
||||||
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
|
| TS-24 | RawMotionState command list always empty (bits 11-31 = 0) — discrete motion events (emotes, one-shots) never packed outbound | `src/AcDream.Core.Net/Messages/MoveToState.cs:34` | Discrete client-initiated motions aren't implemented yet; documented builder scope | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | RawMotionState pack (holtburger types.rs) |
|
||||||
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 |
|
| TS-25 | `FlagCurrentStyle` (stance, bit 0x2) never written to outbound MoveToState | `src/AcDream.Core.Net/Messages/MoveToState.cs:130` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | RawMotionFlags CurrentStyle 0x2 (holtburger) |
|
||||||
|
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) |
|
||||||
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
|
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
|
||||||
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
|
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
|
||||||
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
||||||
| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
|
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx |
|
||||||
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
|
||||||
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
|
|
||||||
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) |
|
|
||||||
| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` |
|
|
||||||
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
|
|
||||||
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
|
|
||||||
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
|
|
||||||
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
|
|
||||||
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
|
||||||
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
|
|
||||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
|
|
||||||
| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
|
|
||||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -243,8 +181,8 @@ equivalence argument (promote to AD/AP) or a fix.
|
||||||
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
|
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
|
||||||
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
|
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
|
||||||
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
||||||
|
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
|
||||||
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
||||||
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -258,23 +196,25 @@ WITH that phase, not before.
|
||||||
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
|
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
|
||||||
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
|
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
|
||||||
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
|
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
|
||||||
4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
|
4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
|
||||||
5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
|
5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
|
||||||
6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
|
6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
|
||||||
7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
|
7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
|
||||||
8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
|
8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
|
||||||
9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
|
9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
|
||||||
10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
|
10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
|
||||||
11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
|
11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
|
||||||
12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
|
12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
|
||||||
|
13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
|
||||||
|
14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
|
||||||
|
|
||||||
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
|
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
|
||||||
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
|
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
|
||||||
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
|
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
|
||||||
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
|
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
|
||||||
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
|
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
|
||||||
Membership Stage 2 must land TS-18 (BuildingCellId).
|
Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30;
|
||||||
The audio phase lands TS-9/TS-29; the animation-hook layer lands
|
the audio phase lands TS-9/TS-29; the animation-hook layer lands
|
||||||
TS-10/TS-11/TS-12/TS-13/TS-14.
|
TS-10/TS-11/TS-12/TS-13/TS-14.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -30,68 +30,6 @@ our tree (see CLAUDE.md for the full breakdown):
|
||||||
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
|
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
|
||||||
internal `_dats.*` call sites — above the 20-site inline-swap threshold).
|
internal `_dats.*` call sites — above the 20-site inline-swap threshold).
|
||||||
|
|
||||||
**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.**
|
|
||||||
The GL-free portion of the former `ObjectMeshManager` — dat read → polygon
|
|
||||||
walk → vertex/index build → inline BCn/palette texture decode →
|
|
||||||
`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/`
|
|
||||||
assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact
|
|
||||||
same extraction code offline without an OpenGL context. This was a
|
|
||||||
mechanical, verbatim move (namespace + visibility only) per
|
|
||||||
`docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md` — no
|
|
||||||
behavior change, no divergence-register row.
|
|
||||||
|
|
||||||
- `src/AcDream.Content/MeshExtractor.cs` — the `Prepare*` family
|
|
||||||
(`PrepareMeshData`, `PrepareSetupMeshData`, `PrepareGfxObjMeshData`,
|
|
||||||
`PrepareEnvCellMeshData`, `PrepareCellStructMeshData`,
|
|
||||||
`PrepareCellStructEdgeLineData`) + private helpers (`CollectParts`,
|
|
||||||
`CollectEmittersFromScript`, `ComputeBounds`, `BuildPolygonIndices`,
|
|
||||||
`BuildCellStructPolygonIndices`) and the decoded-texture cache /
|
|
||||||
`ThreadLocal<BcDecoder>` that back them.
|
|
||||||
- `src/AcDream.Content/ObjectMeshData.cs` — the CPU-side boundary records:
|
|
||||||
`VertexPositionNormalTexture`, `StagedEmitter`, `ObjectMeshData`,
|
|
||||||
`MeshBatchData`, `TextureBatchData`.
|
|
||||||
- `src/AcDream.Content/TextureKey.cs` — the atlas dedup key, lifted out of
|
|
||||||
the GL-owning `TextureAtlasManager` (which stays in App and now
|
|
||||||
references the lifted struct).
|
|
||||||
- `src/AcDream.Content/UploadFormats.cs` — Content-owned
|
|
||||||
`UploadPixelFormat`/`UploadPixelType` enums carried by
|
|
||||||
`MeshBatchData`/`TextureBatchData` instead of
|
|
||||||
`Silk.NET.OpenGL.PixelFormat`/`PixelType` (Content must stay
|
|
||||||
Silk.NET-free — the bake tool must not ship GL binaries). Underlying
|
|
||||||
values are the GL ABI constants, numerically identical to the Silk.NET
|
|
||||||
members; App casts at its single upload boundary (the `AddTexture` call
|
|
||||||
in `UploadGfxObjMeshData`) via a lifted nullable enum conversion —
|
|
||||||
value- and null-preserving.
|
|
||||||
- `src/AcDream.Content/IDatReaderWriter.cs`, `EdgeLineBuilder.cs` — GL-free
|
|
||||||
dependencies of the extractor, moved (namespace-only) alongside it.
|
|
||||||
- **Side-stage sink seam:** `CollectEmittersFromScript` pre-loads particle
|
|
||||||
GfxObj meshes mid-extraction and, pre-MP1a, enqueued them directly onto
|
|
||||||
`ObjectMeshManager._stagedMeshData`. The extractor now takes an
|
|
||||||
`Action<ObjectMeshData>? sideStagedSink` constructor parameter; App wires
|
|
||||||
it to `_stagedMeshData.Enqueue`, preserving the original
|
|
||||||
immediate-enqueue semantics exactly — including on a mid-`Prepare*`
|
|
||||||
throw (preloads staged before a malformed-dat texture-decode exception
|
|
||||||
survive, as they always did). The MP1b bake tool passes its own
|
|
||||||
collector.
|
|
||||||
- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (now a
|
|
||||||
thin wrapper owning the staged-queue/worker-pool/Dispose-quiesce lifecycle
|
|
||||||
and all GL upload — constructs one `MeshExtractor` and delegates every
|
|
||||||
former `Prepare*` call site to it), `ObjectRenderData`/`ObjectRenderBatch`
|
|
||||||
(hold a GL `TextureAtlasManager` field), `TextureAtlasManager`,
|
|
||||||
`DatCollectionAdapter` (concrete `DatCollection`-backed implementation of
|
|
||||||
the now-Content-namespaced `IDatReaderWriter`), `GeometryUtils` (used only
|
|
||||||
by App-side raycasting, not by extraction), `AcSurfaceMetadata`/
|
|
||||||
`AcSurfaceMetadataTable` (not on the extraction path), `Building.cs`
|
|
||||||
(explicitly out of scope).
|
|
||||||
- `AcDream.Core` is untouched; `AcDream.Content` references `AcDream.Core`
|
|
||||||
(for `TextureHelpers`, `Sphere`/`BoundingBox` via `Chorizite.Core.Lib`);
|
|
||||||
`AcDream.App` references `AcDream.Content`. `AcDream.Core` does NOT
|
|
||||||
reference `AcDream.Content` (one-way dependency, per Code Structure Rule 2).
|
|
||||||
- Reason: MP1 (`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`
|
|
||||||
§6.1) — the bake tool needs the identical mesh/texture extraction code
|
|
||||||
running with no GL context, so baked pak output and live-client output stay
|
|
||||||
byte-identical.
|
|
||||||
|
|
||||||
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
||||||
algorithm, **check this inventory first**. If we already extracted it (🟢
|
algorithm, **check this inventory first**. If we already extracted it (🟢
|
||||||
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
|
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
|
||||||
|
|
|
||||||
|
|
@ -293,32 +293,14 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries
|
||||||
|
|
||||||
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
|
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
|
||||||
|
|
||||||
**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam
|
|
||||||
flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light
|
|
||||||
visibility culling" layer — a camera-nearest `MaxGlobalLights=128`
|
|
||||||
snapshot cap evicts in-range lights of visible cells (Hub has 366
|
|
||||||
fixtures), so per-cell 8-light sets churn as the camera moves.
|
|
||||||
Uncapping was live-tested and reverted because the full pool exposes the
|
|
||||||
per-cell-reach + fixture-curve defects below (through-floor light,
|
|
||||||
1/d-vs-1/d³). Analysis PRE-PAID — see
|
|
||||||
`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order:
|
|
||||||
per-cell `insert_light` registration → static fixture curve → stripe
|
|
||||||
hunt → uncap) + register AP-85.
|
|
||||||
|
|
||||||
**Hypothesis layers (less mapped than physics):**
|
**Hypothesis layers (less mapped than physics):**
|
||||||
- Per-cell environment-light tag association — indoor cells should
|
- Per-cell environment-light tag association — indoor cells should
|
||||||
inherit only their own env lights, not outdoor day-cycle.
|
inherit only their own env lights, not outdoor day-cycle.
|
||||||
- Light visibility culling — what lights actually contribute to each
|
- Light visibility culling — what lights actually contribute to each
|
||||||
cell's render. **CONFIRMED bug here (#176/#177): no per-cell light
|
cell's render.
|
||||||
registration — lights are a flat world-space sphere-overlap pool that
|
|
||||||
reaches through solid floors; retail's `insert_light` 0x0054d1b0
|
|
||||||
scopes each light to its cell.**
|
|
||||||
- Per-entity light direction transform — held-item-spotlight bug
|
- Per-entity light direction transform — held-item-spotlight bug
|
||||||
(#L-spotlight) is per-entity attribution gone wrong.
|
(#L-spotlight) is per-entity attribution gone wrong.
|
||||||
- Static-stab atmospheric inheritance (#81).
|
- Static-stab atmospheric inheritance (#81).
|
||||||
- **Fixture falloff curve (#176/#177): stationary server-spawned
|
|
||||||
fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the
|
|
||||||
static 1/d³ bake (`calc_point_light` 0x0059c8b0).**
|
|
||||||
|
|
||||||
**Investigation methodology:** less existing infrastructure than
|
**Investigation methodology:** less existing infrastructure than
|
||||||
physics. Requires:
|
physics. Requires:
|
||||||
|
|
@ -442,22 +424,10 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
**Sub-pieces:**
|
**Sub-pieces:**
|
||||||
- **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
|
- **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
|
||||||
- **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green.
|
- **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green.
|
||||||
- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).**
|
- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`.
|
||||||
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`.
|
|
||||||
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-38–40 / TS-30–31; updated IA-15.
|
|
||||||
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — widget generalization).** Shipped 2026-06-16 (`b7f7e2b`→`89626cd`). The hand-named chat widgets became GENERIC, Type-registered widgets built by `DatWidgetFactory` (`1→UiButton`, `6→UiMenu`, `7→UiMeter`, `11→UiScrollbar`, `12→UiText`); `UiField` (editable) ships controller-placed. `ChatWindowController` + `VitalsController` collapsed to thin `gm*UI::PostInit`-style find-by-id binders — this is the reusable toolkit + assembly pattern the future inventory/vendor/spell-bar windows build on. New `UiElement.ConsumesDatChildren` leaf-widget rule: behavioral widgets reproduce their dat sub-elements procedurally, so the importer must not build their children (an invisible Menu label child was swallowing the button click → dropdown wouldn't open). **Type 3 deliberately NOT registered** → `UiField` (acdream's Type-3 elements are inert sprite-bearing chrome/containers → stay `UiDatElement`; a subagent's Type-3→`UiField` registration was reverted — it blanked the vitals bevel + masked the regression by weakening a test). The editable input resolves to Type 12 → controller-placed `UiField` (Variant B). Vitals numbers rewired to a centered `UiText` (Task 8) — `UiText.Centered` reuses the meter's former centering formula, pixel-identical. Both visual gates (chat + vitals) **user-confirmed**; 404 tests green; new `chat_21000006.json` golden fixture. Amended AP-37, narrowed AP-41, added AP-42. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-widget-generalization*.md`.
|
|
||||||
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)**
|
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)**
|
||||||
- **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish).
|
- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)**
|
||||||
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
|
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
|
||||||
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
|
|
||||||
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
|
|
||||||
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan.
|
|
||||||
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
|
|
||||||
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
|
|
||||||
- **✓ 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 — 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.
|
|
||||||
- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.)
|
|
||||||
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
|
|
||||||
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene.
|
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene.
|
||||||
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)**
|
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)**
|
||||||
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
|
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
|
||||||
|
|
@ -630,33 +600,6 @@ change must keep both phase plans in sync.
|
||||||
- **L.1b — Command router + motion-state cleanup.** Extract tested
|
- **L.1b — Command router + motion-state cleanup.** Extract tested
|
||||||
`SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
|
`SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
|
||||||
and split death `Sanctuary` action from persistent `Dead` substate.
|
and split death `Sanctuary` action from persistent `Dead` substate.
|
||||||
*(Partial, shipped 2026-06-30:* dual `IMotionCommandCatalog` seam —
|
|
||||||
`AceModernCommandCatalog` runtime default + full-extraction
|
|
||||||
`Retail2013CommandCatalog` conformance; deleted the blind 0x016E–0x0197
|
|
||||||
override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug
|
|
||||||
as ISSUES #159 — **fixed 2026-07-04 `2de5a011`**, the constants now derive
|
|
||||||
directly from `DatReaderWriter.Enums.MotionCommand` by name.
|
|
||||||
Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)*
|
|
||||||
*(D6.2a, shipped 2026-07-01:* ported `CMotionInterp::adjust_motion` /
|
|
||||||
`apply_run_to_command` / `apply_raw_movement` into `MotionInterpreter` and
|
|
||||||
unified the local velocity + keyboard turn + jump onto one input-built
|
|
||||||
`RawMotionState` → `get_state_velocity` — backward/strafe-left no longer zero,
|
|
||||||
strafe is now retail-exact (`~1.56×runRate`, clamped ≤3.75), turn omega from
|
|
||||||
interpreted `turn_speed`, hand-mirrored formulas deleted. User smoke sign-off
|
|
||||||
2026-07-01 (strafe-left moves + symmetric, backward outpaces strafe, jump
|
|
||||||
lateral, turn feel unchanged; no crash/rejection). Retired register
|
|
||||||
TS-22 + UN-5; added TS-34 (IsCreature guard no-op). `WalkAnimSpeed` unified to
|
|
||||||
the retail-exact 3.11999989f. Pseudocode:
|
|
||||||
`docs/research/2026-07-01-d6-motion-interp-pseudocode.md`.)*
|
|
||||||
*(D6.2b, shipped 2026-07-01:* the wire now sends the retail-faithful RAW
|
|
||||||
`forward_speed=1.0` (omitted by default-difference packing) instead of runRate.
|
|
||||||
Echo-test settled the open question: ACE RECOMPUTES the broadcast run speed from
|
|
||||||
run skill and auto-upgrades WalkForward+HoldKey.Run → RunForward — sending 1.0
|
|
||||||
still broadcasts RunForward @ runRate, and a retail observer saw +Acdream run at
|
|
||||||
full pace. The stale `MovementData.cs`-citing "ACE relays" comment was
|
|
||||||
corrected. Wire + velocity are now both raw-1.0; threading them onto one shared
|
|
||||||
RawMotionState (removing section-6's duplicate build) is a no-op-behavior
|
|
||||||
cleanup follow-up.)
|
|
||||||
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
|
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
|
||||||
style transitions, attack swings by stance/power/height, hit reactions,
|
style transitions, attack swings by stance/power/height, hit reactions,
|
||||||
evades/blocks/parries, and death handoff.
|
evades/blocks/parries, and death handoff.
|
||||||
|
|
@ -717,88 +660,6 @@ diagnostic scaffolding, not yet the final collision system.
|
||||||
tests to real-world fixtures and verify local acdream view plus retail
|
tests to real-world fixtures and verify local acdream view plus retail
|
||||||
observer view. ACE accepting a position is a compatibility check, not proof
|
observer view. ACE accepting a position is a compatibility check, not proof
|
||||||
of fine-grained retail collision parity.
|
of fine-grained retail collision parity.
|
||||||
- **Phase R — retail motion & animation ground-up reconstruction (2026-07-02,
|
|
||||||
user mandate: total overhaul, verbatim, all entity classes, no frozen
|
|
||||||
code).** Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`
|
|
||||||
— stages R1 (CSequence) → R8 (cutover audit). SUPERSEDES the L.2g S3–S6
|
|
||||||
slices below; L.2g S1/S2/S5 (shipped 2026-07-02: staleness gate, inbound
|
|
||||||
funnel + 183-case live-retail conformance harness, pace-guesser deletion)
|
|
||||||
are absorbed as components. NOTE: this Phase R is unrelated to the retired
|
|
||||||
"R1→R8 refactor sketch" from early 2026 — same letter, new plan.
|
|
||||||
**R1 SHIPPED 2026-07-02** (verbatim CSequence core + adapter rehost,
|
|
||||||
a987cad1 tail). **R2 SHIPPED 2026-07-02** (Q0–Q6; Fix B/fast-path/
|
|
||||||
stop-anim-fallback/G17/RemoteMotionSink deleted, AP-73 retired).
|
|
||||||
**R3 SHIPPED 2026-07-03** (W0–W7: pending_motions + MotionDone consumer,
|
|
||||||
jump family, ground transitions with K-fix18 DELETED, the ONE
|
|
||||||
DoInterpretedMotion + zero-tick flush, LOCAL PLAYER unification —
|
|
||||||
UpdatePlayerAnimation + the synthesis layer deleted; trail in the plan
|
|
||||||
doc). **R4 SHIPPED 2026-07-03** (V0–V6: the verbatim MoveToManager, all
|
|
||||||
33 members + conformance harness; mt 6/7/8/9 wire completion; remote AND
|
|
||||||
local-player cutovers — RemoteMoveToDriver, PlanMoveToStart, and the
|
|
||||||
whole B.6 auto-walk DELETED; the P1 autonomous-echo gate ported verbatim;
|
|
||||||
TS-36 bound — input/jump/teleport cancel movetos through the retail
|
|
||||||
interrupt chain; retired AD-8/AD-9/AP-8/AP-9/AD-26/TS-36; trail in the
|
|
||||||
plan doc). R2+R3+R4 visual pass PASSED 2026-07-03 (#161–#163 closed;
|
|
||||||
#164–#166 filed). **R5-V1 SHIPPED 2026-07-03 `3d89446d`** — PositionManager
|
|
||||||
facade + StickyManager + ConstraintManager + the full TargetManager voyeur
|
|
||||||
system ported to Core, fully tested, UNWIRED (purely additive; renamed the
|
|
||||||
misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`);
|
|
||||||
ConstraintManager unarmed (issue #167, TS-35 stays). Decomp/ACE/plan:
|
|
||||||
`docs/research/2026-07-03-r5-managers/`. **R5-V2 SHIPPED 2026-07-03 `fffe90b3`**
|
|
||||||
— wired the voyeur system per-entity via `EntityPhysicsHost`, retired AP-79;
|
|
||||||
verified live (remote creatures chase). Same session fixed two PRE-EXISTING
|
|
||||||
streaming bugs surfaced while visual-gating V2 (the user's "world doesn't load"
|
|
||||||
report): **#168** pending-bucket trap (`315af02f`) + **#169** cold-spawn
|
|
||||||
streaming hole (`9b06a9b8`) — both verified live. Filed **#170** (remote
|
|
||||||
creature chase+attack animation divergence vs retail — glide/over-frequency/
|
|
||||||
uniform attacks; decomposes into #159 + pending_motions loop + dead-reckon
|
|
||||||
glide). **#170 CLOSED + gated 2026-07-04** (`427332ac` re-dispatch flood +
|
|
||||||
`1051fc83` armed-moveto-always-ticks). **R5-V3 SHIPPED + gated 2026-07-04**
|
|
||||||
(#171 sticky melee, three slices `5bd2b8bc`/`7a823176`/`69966950` — TS-39
|
|
||||||
retired, TS-43/TS-44/AP-82 filed). **R5-V4 behavioral items SHIPPED + gated
|
|
||||||
2026-07-04 `f423884b`** (head style-on-change all mt types, #164 autonomy
|
|
||||||
bit, mt-0 wire flags consumed). **R5-V5 SHIPPED 2026-07-05 — the R5 arc is
|
|
||||||
DONE**: the `MovementManager` facade (retail acclient.h `/* 3463 */`,
|
|
||||||
methods 0x00524000-0x00524790) now owns each entity's interp+moveto pair —
|
|
||||||
ONE per entity (`RemoteMotion.Movement`, `PlayerMovementController.Movement`,
|
|
||||||
chase-harness mirror); the three wiring sites construct through
|
|
||||||
`MoveToFactory` + `MakeMoveToManager` (0x00524000) and the relay shapes
|
|
||||||
(`UseTime` 0x005242f0, `HitGround` 0x00524300, `HandleExitWorld` 0x00524350,
|
|
||||||
`CancelMoveTo` 0x005241b0, `HandleUpdateTarget` 0x00524790,
|
|
||||||
`PerformMovement` 0x005240d0) replace the loose-pair call sites. Structural,
|
|
||||||
zero behavior change; 15 facade conformance tests; suite 4052 green with the
|
|
||||||
183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out:
|
|
||||||
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons
|
|
||||||
carried: #167 (ConstraintManager arming), TS-42 per-tick order (R6).
|
|
||||||
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
|
|
||||||
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
|
|
||||||
remote entities (players, NPCs, monsters — one funnel, user-approved):
|
|
||||||
`move_to_interpreted_state` (0x005289c0) + `apply_interpreted_movement`
|
|
||||||
(0x00528600) + `StopInterpretedMotion` stop path + `MotionTableManager`
|
|
||||||
bookkeeping (`remove_redundant_links` / `CheckForCompletedMotions` /
|
|
||||||
`MotionDone`), delete the non-retail UP-pace→cycle inference layer, restore
|
|
||||||
the walk↔run link pose. Slice order S0–S6 + full deviation map (DEV-1..10,
|
|
||||||
adversarially verified): `docs/research/2026-07-02-inbound-motion-deviation-map.md`.
|
|
||||||
Acceptance: walk↔run toggle on an observed remote reacts at wire latency with
|
|
||||||
no compounding animation/position desync; no stop-slide; user visual
|
|
||||||
side-by-side vs retail observer.
|
|
||||||
|
|
||||||
**Shipped (2026-06-30) — L.2b outbound wire parity (decomp-verbatim, tests-first):**
|
|
||||||
- **D1** — `MoveToState` now packs `RawMotionState` by retail default-difference
|
|
||||||
(`RawMotionState::Pack` 0x0051ed10): a field bit is set only when the value
|
|
||||||
differs from its retail default, so over-sent `forwardSpeed=1.0` / `holdKey=None`
|
|
||||||
are gone. New `AcDream.Core.Physics.RawMotionState` data type + `RawMotionStatePacker`.
|
|
||||||
- **D3** — MoveToState trailing byte = `(standingLongjump?2:0)|(contact?1:0)`
|
|
||||||
(`MoveToStatePack::Pack` 0x005168f0); explicit `contact`/`standingLongjump` params.
|
|
||||||
- **D4** — `JumpAction` rewritten to retail `JumpPack` (0x00516d10): extent · velocity ·
|
|
||||||
full Position · 4 timestamps; spurious objectGuid/spellId removed.
|
|
||||||
- **D5** — audited + confirmed divergent (MTS over-stamping vs retail `SendMovementEvent`);
|
|
||||||
left unchanged, recorded as register **TS-33**, deferred to a dedicated cadence-port slice.
|
|
||||||
- Golden-byte tests (Core.Net.Tests) for all packers; full suite green.
|
|
||||||
- ACE smoke + user visual sign-off 2026-07-01: login → run/turn/RunLock all
|
|
||||||
accepted (server echoes player motion, no rejection), NPCs animate incl.
|
|
||||||
inbound MoveTo type-7 + TurnToHeading type-9 (no crash), graceful exit.
|
|
||||||
- Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.
|
|
||||||
|
|
||||||
**Acceptance:**
|
**Acceptance:**
|
||||||
- A developer can trace the active movement path: input/motion -> body
|
- A developer can trace the active movement path: input/motion -> body
|
||||||
|
|
@ -1198,50 +1059,6 @@ port in any phase — no separate listing here.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Track MP — Modern Pipeline (performance side track; user-commissioned 2026-07-05)
|
|
||||||
|
|
||||||
> **⏸ PARKED 2026-07-05 (user decision) — WINS BANKED, REMAINDER DEFERRED TO THE FUTURE.**
|
|
||||||
> The track's actual trigger — the FPS/ms counter *fluctuating wildly* (read by the
|
|
||||||
> user as inefficiency) — is **RESOLVED**: MP0 (frame profiler), MP1a (Content
|
|
||||||
> extraction), and **MP-Alloc safe batch** shipped and merged to local main
|
|
||||||
> (`093cdb6d`, suite 4188 green, user-confirmed steady FPS). **ECS (MP3) + the
|
|
||||||
> rest are DEFERRED**: ECS is the *throughput* lever (raise avg FPS ~230→300), a
|
|
||||||
> DIFFERENT goal from steadiness — revisit ONLY if the user wants a higher raw FPS
|
|
||||||
> number and accepts the rewrite risk. Rust rejected. Parked (resume from the table
|
|
||||||
> below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever),
|
|
||||||
> MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
|
|
||||||
umbrella design — read it first). **Goal:** smoothness first (no frame over
|
|
||||||
~16 ms during any traversal), then 300+ FPS sustained in dense towns.
|
|
||||||
Perf-only per `feedback_render_perf_not_faithfulness_gated` — pixels and
|
|
||||||
game-feel stay identical; the simulation's retail-mirroring OO structure is
|
|
||||||
untouched. NOT part of M1.5 — runs in dedicated side-track sessions; M1.5
|
|
||||||
critical path wins every conflict.
|
|
||||||
|
|
||||||
**Sequencing note (2026-07-05, evidence-driven):** the steady-state allocation
|
|
||||||
triage (MP-Alloc) was **pulled forward ahead of the pak cutover (MP1c)** after a
|
|
||||||
54-site allocation audit proved the town GC churn is ~90–95% OUTSIDE the MP3 draw
|
|
||||||
surface (so MP3 won't fix it) and independent of the pak — it's the felt-most-often
|
|
||||||
smoothness tax and mostly easy faithfulness-neutral buffer-reuse work. See
|
|
||||||
`project_mp_track_findings.md`. Load-time smoothness (the pak, MP1c) is the rarer
|
|
||||||
hitch and follows.
|
|
||||||
|
|
||||||
| Phase | What | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| MP0 | Honest frame profiler (`[frame-prof]`) + baseline capture | ✅ SHIPPED 2026-07-05 — gate PASSED (`docs/research/2026-07-05-mp0-baseline.md`); headline: town spikes = GC churn (1.5–3 MB/frame), teleport hitch 211 ms |
|
|
||||||
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
|
|
||||||
| MP1b | Pak format + `acdream-bake` CLI + mmap zero-copy `PakReader` + equivalence test | 🔵 code + review-fixes done; **full-bake gate found 865 GB → EnvCell dedup slice REQUIRED before shipped** (spec §6.6) |
|
|
||||||
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
|
|
||||||
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) |
|
|
||||||
| MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc |
|
|
||||||
| MP2 | Retail distance-degrade port, hide-only cut (retires a divergence row) | ⚪ |
|
|
||||||
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
|
|
||||||
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
|
|
||||||
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting work tracked in parallel
|
## Cross-cutting work tracked in parallel
|
||||||
|
|
||||||
- **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land.
|
- **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land.
|
||||||
|
|
|
||||||
|
|
@ -225,40 +225,6 @@ synthesis workarounds were removed by A6.P4. Remaining feel-level debt is
|
||||||
tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor
|
tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor
|
||||||
lighting fidelity not yet done — folded forward).
|
lighting fidelity not yet done — folded forward).
|
||||||
|
|
||||||
**Interleaved parity tracks (reconciled 2026-07-04 — NOT milestones).** Two
|
|
||||||
work streams have run alongside M1.5, both driven by the user's live
|
|
||||||
side-by-side-vs-retail sessions, both issue-level per the operating rules
|
|
||||||
(observed defects → ISSUES → fix → user gate), and both stay subordinate
|
|
||||||
to M1.5 rather than becoming milestones of their own: (1) **D.2b retail
|
|
||||||
UI** — inventory window / paperdoll / item interaction shipped and gated;
|
|
||||||
next container-switching (`claude-memory/project_d2b_retail_ui.md`);
|
|
||||||
(2) **the R5 movement-manager arc — DONE 2026-07-05** — the retail
|
|
||||||
MovementManager family port (TargetManager voyeur system V2,
|
|
||||||
StickyManager/PositionManager V3, V4 behavioral items, V5 MovementManager
|
|
||||||
facade — every entity now holds ONE `MovementManager` owning its
|
|
||||||
interp+moveto pair) that closed the #170 chase-slide and #171 pack-melee
|
|
||||||
gates (`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` has the
|
|
||||||
arc close-out). Rationale:
|
|
||||||
rule 1's "one active milestone" governs where NEW scope goes — these
|
|
||||||
tracks fix observed retail-parity defects in already-shipped systems,
|
|
||||||
which the operating rules have always allowed; declaring them milestones
|
|
||||||
would manufacture scope where a tactical ledger (ISSUES + handoffs)
|
|
||||||
already carries them.
|
|
||||||
|
|
||||||
**Modern Pipeline side track (Track MP — user-commissioned 2026-07-05; NOT
|
|
||||||
a milestone, NOT part of M1.5).** A performance modernization arc (baked
|
|
||||||
asset pipeline → retail distance-degrade → ECS render world with delta
|
|
||||||
submission → zero-alloc frame loop) targeting smoothness first, then 300+
|
|
||||||
FPS in dense towns. Spec:
|
|
||||||
`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`; phase table
|
|
||||||
in the roadmap ("Track MP"). **Freeze exception (explicit, user-authorized
|
|
||||||
2026-07-05):** this track may reopen the otherwise-frozen streaming and
|
|
||||||
WB-rendering subsystems — but only under its own gated phases (pixel- and
|
|
||||||
feel-identical conformance gates, legacy path deleted at each gate), never
|
|
||||||
as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in
|
|
||||||
dedicated side-track sessions; the M1.5 critical path wins every conflict,
|
|
||||||
and MP4 is hard-queued behind #137.
|
|
||||||
|
|
||||||
**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons
|
**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons
|
||||||
don't work: the streaming/load/render/physics pipeline was built entirely
|
don't work: the streaming/load/render/physics pipeline was built entirely
|
||||||
around outdoor landblocks (terrain + scattered buildings) and has no path
|
around outdoor landblocks (terrain + scattered buildings) and has no path
|
||||||
|
|
@ -488,7 +454,7 @@ Open the inventory panel — retail-skinned with the right font, icons, and
|
||||||
local lighting).
|
local lighting).
|
||||||
- **C.3** — Palette range tuning (skin/hair/eye colors match retail).
|
- **C.3** — Palette range tuning (skin/hair/eye colors match retail).
|
||||||
- **C.4** — Double-sided translucent polys.
|
- **C.4** — Double-sided translucent polys.
|
||||||
- **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Next: D.5.3 selected-object bar + spell shortcuts; #158 Character window polish deferred.
|
- **D.2b** — Custom retail-look UI backend.
|
||||||
- **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
|
- **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
|
||||||
cursor manager.
|
cursor manager.
|
||||||
- **L.1f** — NPC/monster + item-use animation coverage.
|
- **L.1f** — NPC/monster + item-use animation coverage.
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
# Phase R — retail motion & animation stack, ground-up reconstruction
|
|
||||||
|
|
||||||
Date: 2026-07-02. **Mandate (user, verbatim intent):** a complete new
|
|
||||||
movement + animation system, verbatim retail equivalent — all movement,
|
|
||||||
inbound and outbound, all animation, for players, NPCs, and monsters. No
|
|
||||||
frozen code, no bandaids, no guessing, no approximation. Total overhaul.
|
|
||||||
|
|
||||||
**Execution shape:** a staged verbatim RECONSTRUCTION, not a big-bang
|
|
||||||
cutover — each stage builds a retail class 1:1 with a conformance harness,
|
|
||||||
cuts consumers over, and DELETES the legacy path in the same stage. "New
|
|
||||||
system" is the destination; the client keeps running between stages. The
|
|
||||||
cdb-golden technique is proven (183/183 live-retail dispatch conformance in
|
|
||||||
S2a) and is the acceptance mechanism for every stage: the user's eyes are a
|
|
||||||
final sanity pass only.
|
|
||||||
|
|
||||||
Supersedes the L.2g S3–S6 slice plan (S1/S2/S5 landed and are absorbed as
|
|
||||||
components). This doc is the plan of record; the deviation map
|
|
||||||
(`docs/research/2026-07-02-inbound-motion-deviation-map.md`), the funnel
|
|
||||||
pseudocode (`…-s2-inbound-funnel-pseudocode.md`), and the 2026-06-04
|
|
||||||
sequencer deep-dive are its research base.
|
|
||||||
|
|
||||||
## The retail module map to reconstruct (per physics object, ALL classes)
|
|
||||||
|
|
||||||
```
|
|
||||||
CPhysicsObj (local player, remote player, NPC, monster — ONE pipeline)
|
|
||||||
├─ MovementManager unpack_movement 0x00524440 (10-way dispatch),
|
|
||||||
│ │ PerformMovement, MotionDone relay
|
|
||||||
│ ├─ CMotionInterp raw_state + interpreted_state + pending_motions,
|
|
||||||
│ │ DoMotion / DoInterpretedMotion / StopInterpretedMotion,
|
|
||||||
│ │ apply_raw/interpreted/current_movement, my_run_rate,
|
|
||||||
│ │ HitGround / LeaveGround / ReportExhaustion, jump family
|
|
||||||
│ └─ MoveToManager movement types 6/7/8/9 (MoveToObject/Position,
|
|
||||||
│ TurnToObject/Heading), node stepping, arrival radii,
|
|
||||||
│ fail distance, CanCharge walk/run selection
|
|
||||||
├─ CPartArray → MotionTableManager pending_animations, add_to_queue 0x0051bfe0,
|
|
||||||
│ │ remove_redundant_links 0x0051bf20,
|
|
||||||
│ │ CheckForCompletedMotions 0x0051be00 → AnimationDone
|
|
||||||
│ └─ CMotionTable::GetObjectSequence 0x00522860
|
|
||||||
│ │ same-substate fast path (change_cycle_speed +
|
|
||||||
│ │ subtract/combine_motion), link path (get_link +
|
|
||||||
│ │ style-default double-hop), re_modify, is_allowed
|
|
||||||
│ └─ CSequence anim-node DLList, append_animation 0x00525510,
|
|
||||||
│ remove_cyclic_anims, clear_physics, velocity/omega
|
|
||||||
│ accumulators, update/update_internal + apply_physics
|
|
||||||
│ (root motion), placement frames, hook dispatch
|
|
||||||
├─ PositionManager InterpolationManager [PORTED ✓ L.3] + StickyManager
|
|
||||||
│ + ConstraintManager
|
|
||||||
└─ per-tick UpdateObjectInternal 0x005156b0 order:
|
|
||||||
CPartArray.Update (CSequence root motion) → PositionManager.adjust_offset
|
|
||||||
(chase REPLACES) → Frame.combine → UpdatePhysicsInternal → FULL transition
|
|
||||||
sweep (ALL entities — retires the remote no-sweep fork) → MovementManager.UseTime
|
|
||||||
/ CPartArray.HandleMovement / PositionManager.UseTime → process_hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
## Already-verbatim components (absorbed, not rewritten)
|
|
||||||
|
|
||||||
- `MotionSequenceGate` (S1) — is_newer 3-stamp gate, live-validated.
|
|
||||||
- Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` /
|
|
||||||
`DispatchInterpretedMotion` / verbatim `contact_allows_move` (S2a) +
|
|
||||||
183-case live-trace conformance suite.
|
|
||||||
- `InterpolationManager` (L.3) — chase constants re-verified 2026-07-02.
|
|
||||||
- Outbound: `RawMotionState::Pack` default-difference, MoveToStatePack
|
|
||||||
trailer, JumpPack (L.2b/D6.2b, golden-byte tests); dual command catalogs
|
|
||||||
(L.1b); `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/
|
|
||||||
`get_state_velocity` (D6).
|
|
||||||
|
|
||||||
Everything else is reconstruction scope — especially: `AnimationSequencer`
|
|
||||||
internals (becomes the CSequence port's host or is replaced by it), the
|
|
||||||
three per-tick drive paths in `GameWindow.TickAnimations`, `RemoteMotionSink`
|
|
||||||
(temporary S2b seam — dissolves into GetObjectSequence), `RemoteMoveToDriver`,
|
|
||||||
`ServerControlledLocomotion`, the motion half of `PlayerMovementController`,
|
|
||||||
the 300 ms stop-detection window, NPC UP hard-snaps.
|
|
||||||
|
|
||||||
## Stage plan (each: pseudocode → harness → port → cutover → DELETE legacy → register sweep)
|
|
||||||
|
|
||||||
- **R1 — CSequence verbatim. SHIPPED 2026-07-02** (commits 1371c2a1 P0/P1, 778744bf P2, 5138b8fb P3, 658b91d8 P4, 9147344a P5, +P6): the verbatim core (AnimSequenceNode/CSequence/FrameOps, 56 conformance tests) + the AnimationSequencer adapter rehost deleting the legacy epsilon/stale-head/safety-cap/per-node-flag mechanisms; root motion flows through Advance(dt, Frame) = retail update(Frame*). Registers AD-33/AD-34. Node list, framerate/rate math, velocity+
|
|
||||||
omega accumulators (set/combine/subtract), update/update_internal root
|
|
||||||
motion, apply_physics, placement frames, hook dispatch. Goldens: dat
|
|
||||||
MotionData fixtures + a cdb trace of append_animation/remove_cyclic_anims
|
|
||||||
args (script pattern: tools/cdb/l2g-observer.cdb). Cutover: becomes the
|
|
||||||
sequencer core behind the existing AnimationSequencer API, then the API
|
|
||||||
narrows to retail's.
|
|
||||||
- **R2 — GetObjectSequence + MotionTableManager. SHIPPED 2026-07-02 (pending
|
|
||||||
the stage visual pass).** Fast path,
|
|
||||||
link path (restores the walk↔run link pose — old S4), re_modify (modifier
|
|
||||||
blend — retires AP-73), pending_animations + remove_redundant_links +
|
|
||||||
CheckForCompletedMotions → AnimationDone→MotionDone chain (old S3).
|
|
||||||
RemoteMotionSink's single-cycle pick DELETED — GetObjectSequence decides.
|
|
||||||
Progress: Q0 pins (dc54a3e4) + Q1 MotionState (2345da30), Q2 CMotionTable all-4-branch
|
|
||||||
GetObjectSequence + statics (98f58db9, 44 tests), Q3 MotionTableManager+
|
|
||||||
pending_animations (aa65990a, 47 tests), Q4 adapter cutover (3b9d9bb6 —
|
|
||||||
SetCycle/PlayAction → PerformMovement; Fix B / fast-path / stop-anim
|
|
||||||
fallback / G17 gate DELETED; 11-scenario trace conformance a6235a36).
|
|
||||||
Q5 RemoteMotionSink DELETED (d82f07d4 — funnel → MotionTableDispatchSink →
|
|
||||||
PerformMovement; AP-73 retired; spawn/despawn run initialize_state/
|
|
||||||
HandleExitWorld; live smoke green: MOTIONDONE chain firing in-world).
|
|
||||||
Q6 sweep done with Q5. OUTSTANDING: the ONE user visual pass (walk↔run
|
|
||||||
stride continuity, turn-while-running legs, emote overlay, stop settle).
|
|
||||||
R3 prep done in parallel: research base 8eff3978 + W0 pins cd0289be (all
|
|
||||||
10 ambiguities resolved, adversarially verified).
|
|
||||||
- **R3 — CMotionInterp completion. SHIPPED 2026-07-03 (pending the stage
|
|
||||||
visual pass).** pending_motions/MotionDone, DoMotion, jump family
|
|
||||||
(jump_charge_is_allowed/motion_allows_jump verbatim — the misattribution
|
|
||||||
found in S2a), HitGround/LeaveGround/ReportExhaustion, enter_default_state.
|
|
||||||
LOCAL PLAYER unified (edge-driven CommandInterpreter altitude; the
|
|
||||||
synthesis layer + UpdatePlayerAnimation deleted).
|
|
||||||
Trail: W-1 research 8eff3978, W0 pins cd0289be (A1-A10 adversarially
|
|
||||||
verified), W1 state completion 86649591, W2 pending_motions 37167991,
|
|
||||||
W3 jump family af476444, W4 ground transitions + K-fix18 DELETED
|
|
||||||
e214acdf, W5 one-DoInterpretedMotion + zero-tick flush df7b096d
|
|
||||||
(discovery: the dispatch RESULT gates queue+state writes — sink returns
|
|
||||||
bool), W6 local-player unification fb7beb70 (map fc5a2cda; discoveries:
|
|
||||||
ChargeJump never wired, CurrentHoldKey shadow-field staleness, the
|
|
||||||
autonomous-flag clobber). Registers through the arc: retired AP-73/
|
|
||||||
AP-74/AP-78/TS-34/IA-4 + the Fix-B-class inventions; added AD-36
|
|
||||||
(narrowed), AP-75/76/77, TS-35/36/37/38. EXPECTED-DIFFS for the visual
|
|
||||||
pass: #45 sidestep factor + ANIM_SPEED_SCALE retired (local now matches
|
|
||||||
remotes), auto-walk-at-run walk-pace legs (R4), ApplyServerRunRate echo
|
|
||||||
live through fast re-speed. R3+R2 share ONE visual pass.
|
|
||||||
- **R4 — MoveToManager verbatim. SHIPPED 2026-07-03 (pending the stage
|
|
||||||
visual pass).** Types 6/7/8/9 (TurnToObject/TurnToHeading — the dropped
|
|
||||||
D9/DEV-5 commands), node stepping, arrival, fail distance, CanCharge.
|
|
||||||
RemoteMoveToDriver + ServerControlledLocomotion.PlanMoveToStart + B.6
|
|
||||||
auto-walk all DELETED.
|
|
||||||
Trail: research base 988304e1, V0 pins 386b1ce5 (P1-P7 resolved; P1
|
|
||||||
autonomous-echo gate + P3 heading_diff mirror adversarially sealed, P3
|
|
||||||
down to instruction bytes), V1 command-selection family e0d2492c
|
|
||||||
(GetCommand + CanCharge fast-path + MoveToMath), V2 the verbatim manager
|
|
||||||
addc8e97 (all 33 members, 101 tests, seam-injected harness), V3 wire
|
|
||||||
completion a144e873 (mt 8/9 parse + full params exposure + sticky
|
|
||||||
trailer), V4 remote cutover 7016b26c (per-remote manager, P4
|
|
||||||
TargetTracker adapter, retail unpack dispatch; retired AD-8/AD-9/AP-8/
|
|
||||||
AP-9; smoke log verified clean), V5 local-player cutover b3decdfa (P1
|
|
||||||
gate ported verbatim — ACE's autonomous echo dropped before unpack;
|
|
||||||
B.6 deleted; TS-36 bound — input/jump/teleport cancel through the retail
|
|
||||||
interrupt chain; run-rate re-anchored to PD skills + mt-6/7 my_run_rate,
|
|
||||||
echo tap deleted with NO AD row; MoveToComplete client seam widened to
|
|
||||||
natural completion for AD-27; adversarial-review fixes: remote HitGround
|
|
||||||
relay, mt-8 wire_heading degrade, remote curTime clock), V6 register/
|
|
||||||
docs sweep (AD-34 widened with the MoveToNode rename, NEW TS-39 StickTo/
|
|
||||||
Unstick no-op seams → R5, TS-33 extended with the orientation-diff gap,
|
|
||||||
TS-21/AD-25/AP-24/AP-30 re-anchored). EXPECTED-DIFFS for the visual
|
|
||||||
pass: melee-range stop distance (retail cylinder distance — the AD-8
|
|
||||||
max() class is gone), auto-walk legs now walk/run per CanCharge with
|
|
||||||
real turn cycles during corrections, walk-pace close-in demote.
|
|
||||||
OUTSTANDING: the stage visual pass (folds into the pending R2+R3 pass).
|
|
||||||
- **R5 — MovementManager + MovementSystem facade.** One per-object pipeline
|
|
||||||
for every entity class; StickyManager (stick_to_object — the motionFlags
|
|
||||||
0x100 bit) + ConstraintManager ports; GameWindow's OnLiveMotionUpdated
|
|
||||||
shrinks to parse→MovementSystem.HandleMovementEvent.
|
|
||||||
- **R6 — per-tick UpdateObjectInternal order.** Retail tick order for ALL
|
|
||||||
entities incl. the transition sweep for remotes (retires the L.3-M2
|
|
||||||
no-sweep fork + the path A/B split + the 300 ms stop window + NPC UP
|
|
||||||
hard-snap special cases). GameWindow.TickAnimations sheds its motion
|
|
||||||
logic entirely (Code Structure Rule 1).
|
|
||||||
- **R7 — outbound autonomy cadence.** ShouldSendPositionEvent (0x006b45e0)
|
|
||||||
+ the MTS/AP stamp split — retires TS-33. Outbound becomes 100% verbatim.
|
|
||||||
- **R8 — cutover audit.** grep-sweep for legacy motion code, register
|
|
||||||
reconciliation (every AD/AP/TS motion row retired or re-justified), full
|
|
||||||
live protocol (walk/run/toggle/turn/circle/stop/jump/MoveTo/TurnTo,
|
|
||||||
player+NPC+monster), ONE final user visual pass.
|
|
||||||
|
|
||||||
## Standing rules for every stage
|
|
||||||
|
|
||||||
1. Decomp is the oracle; ACE the interpretation aid; cdb/TTD the runtime
|
|
||||||
arbiter when they disagree (the S0/S2 pattern).
|
|
||||||
2. No stage ships without its conformance fixtures. Golden sources: dat
|
|
||||||
tables, cdb traces (both observer + actor side), captured wire logs.
|
|
||||||
3. Every commit: build + full suite green; register rows added/retired in
|
|
||||||
the same commit; roadmap stage table updated on stage completion.
|
|
||||||
4. New code lives in `src/AcDream.Core/Physics/Motion/` (pure logic; GL-free)
|
|
||||||
with App seams only for rendering handoff (Structure Rule 2).
|
|
||||||
5. Delete, don't gate: when a stage cuts over, the legacy path is REMOVED in
|
|
||||||
that stage (mandate: no frozen code, no bandaids).
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
# Chat-window re-drive — session handoff (2026-06-15)
|
|
||||||
|
|
||||||
**Status:** brainstorm STARTED (context gathered, design questions open) — not yet
|
|
||||||
designed or implemented. Resume with `superpowers:brainstorming`.
|
|
||||||
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12` — **continue UI work HERE** (the user's
|
|
||||||
call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch
|
|
||||||
off `main`, it's unrelated and easy to merge). This branch is already current with
|
|
||||||
`main` (merged `5ac9d8c`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Where we are (what shipped this session)
|
|
||||||
|
|
||||||
**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.**
|
|
||||||
The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no
|
|
||||||
per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the
|
|
||||||
SSOT crib) FIRST — it has the full architecture + every correction. Key commits:
|
|
||||||
|
|
||||||
- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the
|
|
||||||
hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired.
|
|
||||||
- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail
|
|
||||||
`UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch).
|
|
||||||
- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in
|
|
||||||
**submission (painter) order** (was per-texture batched → later bars overpainted
|
|
||||||
their own numbers).
|
|
||||||
- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest.
|
|
||||||
|
|
||||||
**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):**
|
|
||||||
- `ElementReader` — `ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId
|
|
||||||
inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct).
|
|
||||||
- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget).
|
|
||||||
- `DatWidgetFactory` — `Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else
|
|
||||||
generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a
|
|
||||||
dedicated widget; the widget CONSUMES the element's children (leaf — importer
|
|
||||||
does not recurse them).** This is the pattern the chat re-drive extends.
|
|
||||||
- `LayoutImporter` — `Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded
|
|
||||||
`Resolve`. `ImportedLayout.FindElement(id)` for binding by id.
|
|
||||||
- `VitalsController` — binds live data to widgets by element id (mirrors retail
|
|
||||||
`gmVitalsUI::PostInit`). The chat controller will mirror this.
|
|
||||||
- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc
|
|
||||||
API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST
|
|
||||||
reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc +
|
|
||||||
`ToAnchors` — `RightEdge==1`=stretch, `LeftEdge==2`=track-right.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next task: re-drive the chat window through the importer (Plan 2 chat piece)
|
|
||||||
|
|
||||||
Today the chat window is **hand-authored**, not data-driven. The goal mirrors the
|
|
||||||
vitals re-drive: read the chat window's dat `LayoutDesc`, build it via
|
|
||||||
`LayoutImporter`, and bind the live chat through a `ChatController`.
|
|
||||||
|
|
||||||
### Current chat window (what to reproduce / replace)
|
|
||||||
- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)`
|
|
||||||
block (~line 1836, "Retail chat window").
|
|
||||||
- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`,
|
|
||||||
`MinWidth 180 / MinHeight 80`, draggable + resizable.
|
|
||||||
- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript,
|
|
||||||
**bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**,
|
|
||||||
whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug
|
|
||||||
bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame.
|
|
||||||
- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind`
|
|
||||||
colour via `RetailChatColor(...)` (local static in GameWindow).
|
|
||||||
|
|
||||||
### Chat pipeline (already shipped, Phase I — reuse, don't rebuild)
|
|
||||||
`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input →
|
|
||||||
ChatInputParser → LiveCommandBus → WorldSession`. See
|
|
||||||
`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the
|
|
||||||
pipeline stays.
|
|
||||||
|
|
||||||
### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI)
|
|
||||||
`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI`
|
|
||||||
(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has
|
|
||||||
`gmMainChatUI::Register` etc.). Chat-layout notes:
|
|
||||||
`docs/research/retail-ui/05-panels.md:120` (chat window layout) +
|
|
||||||
`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`).
|
|
||||||
|
|
||||||
### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id
|
|
||||||
The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it:
|
|
||||||
- `dump-vitals-layout <datdir> [0xId]` enumerates LayoutDescs (it already lists all
|
|
||||||
layouts containing given element ids). Use it to scan for the chat window, or grep
|
|
||||||
the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`.
|
|
||||||
- Then dump it and enumerate its element Types (expect a scroll/list region +
|
|
||||||
scrollbar, maybe a text-input/edit element + channel tabs) — this drives the
|
|
||||||
factory/widget work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open design questions (resume the brainstorm here)
|
|
||||||
|
|
||||||
1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse
|
|
||||||
`UiChatView` for the transcript, parity with today), OR expand to the FULL retail
|
|
||||||
chat (input box for typing, channel tabs)? Recommendation to discuss: do the
|
|
||||||
frame re-drive + transcript first (parity), defer input/tabs to a follow-up —
|
|
||||||
but confirm with the user.
|
|
||||||
2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the
|
|
||||||
vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC
|
|
||||||
`Text`/edit. Two options:
|
|
||||||
- **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript
|
|
||||||
region's Type → the existing `UiChatView` (which already scrolls/selects/copies);
|
|
||||||
a `ChatController` binds the tail by element id. Minimal new code; fastest parity.
|
|
||||||
- **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the
|
|
||||||
decomp so the dat's scrollbar element drives scrolling. More faithful, more work;
|
|
||||||
better as a later step.
|
|
||||||
3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font
|
|
||||||
to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug
|
|
||||||
font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based,
|
|
||||||
so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection
|
|
||||||
hit-test rework). Likely a follow-up, not the first cut.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Watchouts / lessons (don't regress these)
|
|
||||||
- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert
|
|
||||||
to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug).
|
|
||||||
- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize).
|
|
||||||
- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format
|
|
||||||
doc §4's first reading was inverted; trust the corrected `ToAnchors`.
|
|
||||||
- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat
|
|
||||||
children; the importer doesn't recurse into them. Apply the same to the chat
|
|
||||||
transcript widget.
|
|
||||||
- **Don't fabricate dat reader internals** — `Chorizite.DatReaderWriter` is a NuGet
|
|
||||||
package (not in `references/`); verify member names via the dump tool / reflection.
|
|
||||||
|
|
||||||
## Process for the next session
|
|
||||||
1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and
|
|
||||||
`docs/research/2026-06-15-layoutdesc-format.md`.
|
|
||||||
2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach
|
|
||||||
(the 3 questions above), present a design, write the spec.
|
|
||||||
3. Then `superpowers:writing-plans` → `superpowers:subagent-driven-development`
|
|
||||||
(same flow that shipped the vitals importer cleanly).
|
|
||||||
4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on
|
|
||||||
`127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 /
|
|
||||||
testpassword2` or `notan / MittSnus81!` (character `+Je`).
|
|
||||||
|
|
@ -1,491 +0,0 @@
|
||||||
# LayoutDesc Format Enumeration Reference
|
|
||||||
|
|
||||||
**Date:** 2026-06-15
|
|
||||||
**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`)
|
|
||||||
**Sources:**
|
|
||||||
- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F`
|
|
||||||
- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB)
|
|
||||||
- DatReaderWriter 2.1.7 reflection probe (deleted after use)
|
|
||||||
|
|
||||||
This doc is the ground-truth API table for Tasks 2–6. Where it corrects a plan assumption, the correction is called out in **§ Corrections to plan assumptions** at the end.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. `ElementDesc` — exact API
|
|
||||||
|
|
||||||
All members are **public fields** (not properties), except `ElementId`, `Type`, `BaseElement`, `BaseLayoutId`, `DefaultState`, `ReadOrder` which are also fields. There are no `ElementDesc` properties used by the importer.
|
|
||||||
|
|
||||||
| Member | Kind | Type | Notes |
|
|
||||||
|--------|------|------|-------|
|
|
||||||
| `ElementId` | **field** | `uint` | unique element id (e.g. `0x100000E6`) |
|
|
||||||
| `Type` | **field** | `uint` | element class id — **not an enum in DRW**; raw uint |
|
|
||||||
| `BaseElement` | **field** | `uint` | base element id in base layout (0 = no base) |
|
|
||||||
| `BaseLayoutId` | **field** | `uint` | layout id where base element lives (0 = no base) |
|
|
||||||
| `DefaultState` | **field** | `UIStateId` (enum) | the element's initial active state |
|
|
||||||
| `ReadOrder` | **field** | `uint` | draw order within parent |
|
|
||||||
| `X` | **field** | `uint` | left position within parent, in pixels |
|
|
||||||
| `Y` | **field** | `uint` | top position within parent, in pixels |
|
|
||||||
| `Width` | **field** | `uint` | pixel width |
|
|
||||||
| `Height` | **field** | `uint` | pixel height |
|
|
||||||
| `ZLevel` | **field** | `uint` | z-order (0 in all vitals elements) |
|
|
||||||
| `LeftEdge` | **field** | `uint` | left anchor flag (see §4) |
|
|
||||||
| `TopEdge` | **field** | `uint` | top anchor flag (see §4) |
|
|
||||||
| `RightEdge` | **field** | `uint` | right anchor flag (see §4) |
|
|
||||||
| `BottomEdge` | **field** | `uint` | bottom anchor flag (see §4) |
|
|
||||||
| `StateDesc` | **field** | `StateDesc?` | the element's "DirectState" (no name); null if absent |
|
|
||||||
| `States` | **field** | `Dictionary<UIStateId, StateDesc>` | named states (e.g. `HideDetail`, `ShowDetail`) |
|
|
||||||
| `Children` | **field** | `Dictionary<uint, ElementDesc>` | child elements keyed by their `ElementId` |
|
|
||||||
|
|
||||||
**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`.
|
|
||||||
|
|
||||||
The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `StateDesc` — exact API
|
|
||||||
|
|
||||||
| Member | Kind | Type | Notes |
|
|
||||||
|--------|------|------|-------|
|
|
||||||
| `StateId` | **field** | `uint` | redundant with the dict key |
|
|
||||||
| `PassToChildren` | **field** | `bool` | |
|
|
||||||
| `IncorporationFlags` | **field** | `IncorporationFlags` | |
|
|
||||||
| `Properties` | **field** | `Dictionary<uint, BaseProperty>` | keyed by property-id (uint); see §3 |
|
|
||||||
| `Media` | **field** | `List<MediaDesc>` | polymorphic list of media items |
|
|
||||||
|
|
||||||
### States dictionary key type
|
|
||||||
|
|
||||||
`ElementDesc.States` is `Dictionary<UIStateId, StateDesc>`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Key: UIStateId.HideDetail = 268435462 (0x10000006)
|
|
||||||
// Key: UIStateId.ShowDetail = 268435463 (0x10000007)
|
|
||||||
```
|
|
||||||
|
|
||||||
See §6 for the full `UIStateId` enum.
|
|
||||||
|
|
||||||
**Iterating in code:**
|
|
||||||
```csharp
|
|
||||||
foreach (var s in d.States)
|
|
||||||
ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored
|
|
||||||
|
|
||||||
`StateDesc.Properties` is `Dictionary<uint, BaseProperty>`. The `BaseProperty` base class has:
|
|
||||||
- `BasePropertyType PropertyType` (enum)
|
|
||||||
- `uint MasterPropertyId`
|
|
||||||
- `bool ShouldPackMasterPropertyId`
|
|
||||||
|
|
||||||
Concrete subclasses (`DatReaderWriter.Types.*`):
|
|
||||||
|
|
||||||
| Subclass | Field | Type | Notes |
|
|
||||||
|----------|-------|------|-------|
|
|
||||||
| `BoolBaseProperty` | `Value` | `bool` | |
|
|
||||||
| `IntegerBaseProperty` | `Value` | `int` | |
|
|
||||||
| `FloatBaseProperty` | `Value` | `float` | |
|
|
||||||
| `EnumBaseProperty` | `Value` | `uint` | |
|
|
||||||
| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID |
|
|
||||||
| `ArrayBaseProperty` | `Value` | `List<BaseProperty>` | array of sub-properties |
|
|
||||||
| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` |
|
|
||||||
| `StringInfoBaseProperty` | `Value` | `StringInfo` | |
|
|
||||||
| `VectorBaseProperty` | `Value` | `Vector3` | |
|
|
||||||
| `Bitfield32BaseProperty` | `Value` | `uint` | |
|
|
||||||
| `Bitfield64BaseProperty` | `Value` | `ulong` | |
|
|
||||||
| `InstanceIdBaseProperty` | `Value` | `uint` | |
|
|
||||||
| `StructBaseProperty` | `Value` | `Dictionary<uint, BaseProperty>` | |
|
|
||||||
|
|
||||||
### Property key meanings (confirmed from decomp + dat inspection)
|
|
||||||
|
|
||||||
| Key | Type found in dat | Meaning | Decomp ref |
|
|
||||||
|-----|-------------------|---------|-----------|
|
|
||||||
| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` |
|
|
||||||
| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` |
|
|
||||||
| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` |
|
|
||||||
| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` |
|
|
||||||
| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) |
|
|
||||||
| `0x16` | `BoolBaseProperty` | Some text flag | |
|
|
||||||
| `0x21` | `BoolBaseProperty` | One-line mode | |
|
|
||||||
| `0x23` | `IntegerBaseProperty` | Left margin | |
|
|
||||||
| `0x24` | `IntegerBaseProperty` | Top margin | |
|
|
||||||
| `0x25` | `IntegerBaseProperty` | Right margin | |
|
|
||||||
| `0x26` | `IntegerBaseProperty` | Bottom margin | |
|
|
||||||
| `0x27` | `BoolBaseProperty` | Some text option | |
|
|
||||||
| `0x20` | `BoolBaseProperty` | Some text option | |
|
|
||||||
| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` |
|
|
||||||
| `0xCB` | `BoolBaseProperty` | Some text option | |
|
|
||||||
|
|
||||||
**Critical point for font DID extraction:**
|
|
||||||
Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID:
|
|
||||||
```csharp
|
|
||||||
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
|
|
||||||
if (arr.Value[0] is DataIdBaseProperty did)
|
|
||||||
fontDid = did.Value; // e.g. 0x40000000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Confirmed for element `0x10000376` (the vitals text prototype):**
|
|
||||||
- Property `0x1A` → `DataIdBaseProperty.Value = 0x40000000` (font DID)
|
|
||||||
- Property `0x1B` → `ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white)
|
|
||||||
|
|
||||||
**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`)
|
|
||||||
|
|
||||||
These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are:
|
|
||||||
|
|
||||||
| Value | Meaning | Where observed |
|
|
||||||
|-------|---------|---------------|
|
|
||||||
| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) |
|
|
||||||
| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces |
|
|
||||||
| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces |
|
|
||||||
| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` |
|
|
||||||
| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements |
|
|
||||||
|
|
||||||
### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`)
|
|
||||||
|
|
||||||
The **far-axis fields** (RightEdge, BottomEdge) drive stretch:
|
|
||||||
- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta)
|
|
||||||
- **RightEdge==2** ⇒ designRight is fixed (no stretch)
|
|
||||||
- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**)
|
|
||||||
- **LeftEdge==1** ⇒ pin left at designX (near-pin)
|
|
||||||
- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near)
|
|
||||||
- **value==3** ⇒ centered / floating (no anchor on that axis)
|
|
||||||
- **value==0** ⇒ no anchor (prototype-only)
|
|
||||||
|
|
||||||
This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 108459–108668.
|
|
||||||
|
|
||||||
**Correct `ToAnchors` logic (as implemented in `ElementReader.cs`):**
|
|
||||||
```csharp
|
|
||||||
// Per UIElement::UpdateForParentSizeChange @0x00462640
|
|
||||||
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
|
||||||
{
|
|
||||||
var a = AnchorEdges.None;
|
|
||||||
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
|
||||||
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
|
|
||||||
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
|
||||||
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
|
|
||||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verified against all 19 vitals pieces** (format doc §11). At-rest render (no resize) is pixel-identical — anchors only fire on resize. Value `3` contributes no anchor on its axis and falls through to the Left|Top default only when all four values are 3 or 0.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. `MediaDesc` kinds
|
|
||||||
|
|
||||||
`StateDesc.Media` is `List<MediaDesc>`. The concrete types found across the vitals layouts:
|
|
||||||
|
|
||||||
| Subclass | Fields | Used in vitals? | Notes |
|
|
||||||
|----------|--------|----------------|-------|
|
|
||||||
| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type |
|
|
||||||
| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element |
|
|
||||||
| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List<BaseProperty> Frames`, `MediaType Type` | not in vitals | Animated sprite |
|
|
||||||
| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay |
|
|
||||||
| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition |
|
|
||||||
| `MediaDescSound` | `uint File`, ... | not in vitals | |
|
|
||||||
| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition |
|
|
||||||
| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | |
|
|
||||||
| `MediaDescMessage` | `uint Id`, ... | not in vitals | |
|
|
||||||
| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | |
|
|
||||||
| `MediaDescMovie` | `PStringBase<char> FileName`, ... | not in vitals | |
|
|
||||||
|
|
||||||
Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. `DrawModeType` enum (confirmed from reflection)
|
|
||||||
|
|
||||||
`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`):
|
|
||||||
|
|
||||||
| Name | Value | Behavior | Used in vitals? |
|
|
||||||
|------|-------|----------|----------------|
|
|
||||||
| `Undefined` | 0 | (not used) | no |
|
|
||||||
| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome |
|
|
||||||
| `Overlay` | 2 | Blended overlay (not observed in vitals) | no |
|
|
||||||
| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites |
|
|
||||||
|
|
||||||
**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. `UIStateId` enum (key type for `ElementDesc.States`)
|
|
||||||
|
|
||||||
`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window:
|
|
||||||
|
|
||||||
| Name | Value |
|
|
||||||
|------|-------|
|
|
||||||
| `Undef` | 0 |
|
|
||||||
| `Normal` | 1 |
|
|
||||||
| `HideDetail` | 268435462 (= `0x10000006`) |
|
|
||||||
| `ShowDetail` | 268435463 (= `0x10000007`) |
|
|
||||||
| `IsCharacter` | 268435542 (= `0x10000056`) |
|
|
||||||
| `IsAccount` | 268435543 (= `0x10000057`) |
|
|
||||||
|
|
||||||
The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Type → meaning → render method → widget bucket
|
|
||||||
|
|
||||||
From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail:
|
|
||||||
|
|
||||||
| Type (uint) | Class registered | Render method | Widget bucket | Vitals? |
|
|
||||||
|-------------|-----------------|---------------|---------------|---------|
|
|
||||||
| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) |
|
|
||||||
| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no |
|
|
||||||
| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (drag region) | YES — top/bottom drag bars |
|
|
||||||
| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` | YES — container/group elements, chrome corners/edges |
|
|
||||||
| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no |
|
|
||||||
| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no |
|
|
||||||
| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no |
|
|
||||||
| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral** → `UiMeter` | **YES — the three vitals bars** |
|
|
||||||
| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no |
|
|
||||||
| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (grip) | YES — resize grips (corners + edges) |
|
|
||||||
| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no |
|
|
||||||
| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base |
|
|
||||||
| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no |
|
|
||||||
| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no |
|
|
||||||
| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no |
|
|
||||||
| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no |
|
|
||||||
| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements |
|
|
||||||
| 0x13–0x19 | `ConfirmationDialog*` / `MessageDialog*` / etc. | dialog widgets | behavioral → dialog | no |
|
|
||||||
| 0x1000xxxx | `gmVitalsUI`, `gmAttributeUI`, etc. | game-specific custom classes | **custom widget** (registered with high ids) | YES — the stacked vitals window root `0x100005F9` has `Type=268435533=0x10000009`; the floaty row root has Type=`268435465=0x10000009`… actually see below |
|
|
||||||
|
|
||||||
### Root element types in the vitals layouts
|
|
||||||
|
|
||||||
- `0x2100006C` root element `0x100005F9`: `Type = 268435533 = 0x10000009` → `gmVitalsUI::Register` registers type `0x10000009`
|
|
||||||
- `0x21000014` root element `0x100000E5`: `Type = 268435465 = 0x10000009` — wait, `268435465 = 0x10000009` ✓
|
|
||||||
|
|
||||||
Actually: `268435533 = 0x1000000D` (not 9). Let me recompute:
|
|
||||||
- `268435533 decimal`: `268435456 + 77 = 0x10000000 + 0x4D = 0x1000004D` — that's `gmVitalsUI`-ish but a different id.
|
|
||||||
- `268435465`: `268435456 + 9 = 0x10000009` — confirmed `gmVitalsUI` type.
|
|
||||||
|
|
||||||
The correct decomp cross-check: `UIElement::RegisterElementClass(0x10000009, gmVitalsUI::Create)` @`0x4bfe1a`. The stacked vitals window root `0x100005F9` has `Type=268435533`. `268435533 = 0x1000004D` which would be a different registered type. The floaty row root `0x100000E5` has `Type=268435465 = 0x10000009` = confirmed `gmVitalsUI`.
|
|
||||||
|
|
||||||
The key observation: **the root element's Type selects the `gmVitalsUI` C++ class**, which is the window-level controller. In our importer, we don't need to match this: the `LayoutImporter` walks children, and the `VitalsController` binds the meter elements by id directly — the root type is irrelevant to Plan 1.
|
|
||||||
|
|
||||||
**Plan 1 relevant types (vitals window only):**
|
|
||||||
|
|
||||||
| Type | Role | Bucket |
|
|
||||||
|------|------|--------|
|
|
||||||
| 0 | text overlay label (BaseElement → Type 12 for font, but the element itself renders as text) | behavioral → dat-font label |
|
|
||||||
| 2 | drag bar (top/bottom) | generic |
|
|
||||||
| 3 | container / chrome edge / corner (no children hierarchy in vitals) | generic |
|
|
||||||
| 7 | meter | behavioral → `UiMeter` |
|
|
||||||
| 9 | resize grip (corners + edges) | generic |
|
|
||||||
| 12 | style prototype — zero-size, never directly rendered | skip |
|
|
||||||
| 0x10000009 | `gmVitalsUI` root — the window itself | behavioral → window root (use as container) |
|
|
||||||
| 0x1000004D | the stacked-window root | same |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. `LayoutDesc` fields
|
|
||||||
|
|
||||||
| Member | Kind | Type | Notes |
|
|
||||||
|--------|------|------|-------|
|
|
||||||
| `Id` | property | `uint` | dat object id |
|
|
||||||
| `HeaderFlags` | property | `DBObjHeaderFlags` | |
|
|
||||||
| `DBObjType` | property | `DBObjType` | always `LayoutDesc` |
|
|
||||||
| `DataCategory` | property | `uint` | |
|
|
||||||
| `Width` | **field** | `uint` | screen-space width context (800 in all observed layouts) |
|
|
||||||
| `Height` | **field** | `uint` | screen-space height context (600 in all observed layouts) |
|
|
||||||
| `Elements` | **field** | `HashTable<uint, ElementDesc>` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Inheritance chain for vitals number-text elements
|
|
||||||
|
|
||||||
All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share:
|
|
||||||
- `Type = 0` (text element, no render registration — renders via inherited machinery)
|
|
||||||
- `BaseElement = 268436342 = 0x10000376`
|
|
||||||
- `BaseLayoutId = 553648191 = 0x2100003F`
|
|
||||||
|
|
||||||
The base element `0x10000376` in `0x2100003F`:
|
|
||||||
- `Type = 12` (style prototype — zero-size, never rendered directly)
|
|
||||||
- `StateDesc.Properties`:
|
|
||||||
- `0x1A` → `ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`**
|
|
||||||
- `0x1B` → `ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white
|
|
||||||
- `0x14` → `EnumBaseProperty{Value=1}` — horizontal justification = 1
|
|
||||||
- `0x15` → `EnumBaseProperty{Value=1}` — vertical justification = 1
|
|
||||||
- `0x23`, `0x25` → `IntegerBaseProperty{Value=0}` — margins
|
|
||||||
|
|
||||||
The inheritance chain for the text element in the importer is:
|
|
||||||
```
|
|
||||||
derived (Type=0, no StateDesc media, no font prop itself)
|
|
||||||
inherits from base 0x10000376 in layout 0x2100003F (Type=12)
|
|
||||||
→ font DID = 0x40000000 (from property 0x1A)
|
|
||||||
→ font color = white ARGB(255,255,255,255) (from property 0x1B)
|
|
||||||
```
|
|
||||||
|
|
||||||
The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`.
|
|
||||||
|
|
||||||
**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Vitals window `0x2100006C` — confirmed element map
|
|
||||||
|
|
||||||
Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2)
|
|
||||||
|
|
||||||
### Chrome (all Type=3, `DrawMode=Normal`)
|
|
||||||
|
|
||||||
| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite |
|
|
||||||
|----|---|---|---|---|----------|---------|-----------|------------|--------|
|
|
||||||
| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) |
|
|
||||||
| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) |
|
|
||||||
| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) |
|
|
||||||
| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) |
|
|
||||||
| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) |
|
|
||||||
| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) |
|
|
||||||
| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) |
|
|
||||||
| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) |
|
|
||||||
|
|
||||||
### Drag bars (Type=2)
|
|
||||||
|
|
||||||
| Id | X | Y | W | H | Notes |
|
|
||||||
|----|---|---|---|---|-------|
|
|
||||||
| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` |
|
|
||||||
| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor |
|
|
||||||
|
|
||||||
### Resize grips (Type=9 — corners + edges)
|
|
||||||
|
|
||||||
| Id | X | Y | W | H | Corner/Edge |
|
|
||||||
|----|---|---|---|---|-------------|
|
|
||||||
| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip |
|
|
||||||
| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip |
|
|
||||||
| `0x1000063E` | 0 | 5 | 5 | 48 | left grip |
|
|
||||||
| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip |
|
|
||||||
| `0x10000641` | 155 | 53 | 5 | 5 | BR grip |
|
|
||||||
| `0x10000642` | 155 | 5 | 5 | 48 | right grip |
|
|
||||||
|
|
||||||
Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list.
|
|
||||||
|
|
||||||
### Meter elements (Type=7 — `UiMeter`)
|
|
||||||
|
|
||||||
| Id | X | Y | W | H | Purpose |
|
|
||||||
|----|---|---|---|---|---------|
|
|
||||||
| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter |
|
|
||||||
| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter |
|
|
||||||
| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter |
|
|
||||||
|
|
||||||
Each meter has:
|
|
||||||
- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites)
|
|
||||||
- `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern
|
|
||||||
- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states)
|
|
||||||
- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000`
|
|
||||||
|
|
||||||
### Sprite ids confirmed from dump
|
|
||||||
|
|
||||||
**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer):
|
|
||||||
- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480`
|
|
||||||
- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483`
|
|
||||||
- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front)
|
|
||||||
|
|
||||||
**Stamina bar:**
|
|
||||||
- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486`
|
|
||||||
- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489`
|
|
||||||
- ShowDetail: `0x06007492` / `0x06007493`
|
|
||||||
|
|
||||||
**Mana bar:**
|
|
||||||
- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C`
|
|
||||||
- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F`
|
|
||||||
- ShowDetail: `0x06007494` / `0x06007495`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Inheritance resolution rules
|
|
||||||
|
|
||||||
1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`.
|
|
||||||
2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it.
|
|
||||||
3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`.
|
|
||||||
4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## § Corrections to plan assumptions
|
|
||||||
|
|
||||||
### 1. Edge-flag semantics are INVERTED from the earlier §4 reading
|
|
||||||
|
|
||||||
**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch` → `right==2||right==4` for Right anchor.
|
|
||||||
**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`:
|
|
||||||
|
|
||||||
| Edge value | LeftEdge meaning | RightEdge meaning |
|
|
||||||
|-----------|-----------------|------------------|
|
|
||||||
| 0 | no anchor | no anchor |
|
|
||||||
| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** |
|
|
||||||
| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) |
|
|
||||||
| 3 | centered / floating (no anchor) | centered / floating (no anchor) |
|
|
||||||
| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** |
|
|
||||||
|
|
||||||
The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4.
|
|
||||||
|
|
||||||
**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):**
|
|
||||||
```csharp
|
|
||||||
// Per UIElement::UpdateForParentSizeChange @0x00462640
|
|
||||||
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
|
||||||
{
|
|
||||||
var a = AnchorEdges.None;
|
|
||||||
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
|
||||||
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
|
|
||||||
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
|
||||||
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
|
|
||||||
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top;
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site.
|
|
||||||
|
|
||||||
### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int`
|
|
||||||
|
|
||||||
The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct.
|
|
||||||
|
|
||||||
### 3. `ElementDesc.Type` is `uint`, not an enum
|
|
||||||
|
|
||||||
The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`.
|
|
||||||
|
|
||||||
### 4. `DrawModeType` has no `Stretch` value
|
|
||||||
|
|
||||||
The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch.
|
|
||||||
|
|
||||||
### 5. `d.States` key is `UIStateId`, not `string`
|
|
||||||
|
|
||||||
The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc.
|
|
||||||
|
|
||||||
### 6. Font DID is in `ArrayBaseProperty`, not a direct property
|
|
||||||
|
|
||||||
The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is:
|
|
||||||
```csharp
|
|
||||||
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
|
|
||||||
if (arr.Value[0] is DataIdBaseProperty did)
|
|
||||||
info.FontDid = did.Value;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Fill (`0x69`) is NOT in the dat
|
|
||||||
|
|
||||||
The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it.
|
|
||||||
|
|
||||||
### 8. Type=12 elements are style prototypes — skip them entirely
|
|
||||||
|
|
||||||
Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## § Plan 1 surface vs long tail
|
|
||||||
|
|
||||||
**Plan 1 (vitals conformance) uses:**
|
|
||||||
- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container)
|
|
||||||
- DrawModes: `Normal` (1), `Alphablend` (3)
|
|
||||||
- Media: `MediaDescImage`, `MediaDescCursor`
|
|
||||||
- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance)
|
|
||||||
- States: `HideDetail`, `ShowDetail`
|
|
||||||
|
|
||||||
**Plan 2 (long tail):**
|
|
||||||
- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x13–0x19), all `gm*UI` custom types
|
|
||||||
- DrawModes: `Overlay` (2), any future additions
|
|
||||||
- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc.
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# Handoff — next UI phase: action bar / quick slots + inventory + equipment (paperdoll)
|
|
||||||
|
|
||||||
**Date:** 2026-06-16
|
|
||||||
**From:** the session that landed the D.2b widget generalization (merged to `main` at `78c9187`).
|
|
||||||
**Purpose:** kick off a **deep retail-faithful research phase** for the next three game panels, before any implementation. This doc + the new-session prompt at the bottom are the entry point.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Where we are (what you're building on)
|
|
||||||
|
|
||||||
The **D.2b retail-UI toolkit is complete and on `main`.** You have:
|
|
||||||
|
|
||||||
- **A generic, Type-registered widget toolkit** built by `DatWidgetFactory` from the dat `LayoutDesc`: `UiButton` (Type 1), `UiMenu` (6), `UiMeter` (7), `UiScrollbar` (11), `UiText` (12), plus `UiField` (editable, controller-placed) and `UiDatElement` (generic chrome/container fallback). All in `src/AcDream.App/UI/`.
|
|
||||||
- **The assembly pattern**: a window is a dat `LayoutDesc` → `LayoutImporter.Import(...)` walks the `ElementDesc` tree → `DatWidgetFactory` builds each element generically → a thin **`gm*UI::PostInit`-style controller** finds widgets by id (`layout.FindElement(id) as UiX`) and binds live data/behavior. See `VitalsController` and `ChatWindowController` for the two worked examples.
|
|
||||||
- **Key toolkit rules** (read `claude-memory/project_d2b_retail_ui.md` first — it's the START-HERE digest with the full DO-NOT-RETRY list):
|
|
||||||
- `UiElement.ConsumesDatChildren` — behavioral widgets are **leaf** (the importer doesn't build their dat sub-elements; they reproduce them procedurally).
|
|
||||||
- The base-chain Type resolution (`ElementReader.Merge`) already surfaces each element's real registered Type.
|
|
||||||
- Type 3 is **chrome/container** in acdream's layouts (stays `UiDatElement`), NOT a factory-registered editable Field.
|
|
||||||
|
|
||||||
**This phase's job is to build the next real game panels on that toolkit** — but they're complex (live item state, drag-drop, wire messages, icon rendering), so we research first per the project's mandatory **grep named → decompile → cross-ref → pseudocode → port** workflow (CLAUDE.md).
|
|
||||||
|
|
||||||
These panels are the roadmap's **F.5 / D.5 "core panels"** (Attributes / Skills / Paperdoll / Inventory / Spellbook).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The three targets + their retail entry points (concrete anchors)
|
|
||||||
|
|
||||||
All confirmed from the named decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
|
|
||||||
|
|
||||||
### A. Action bar / quick slots → `gmToolbarUI` (element class `0x10000007`)
|
|
||||||
The retail "toolbar" is the shortcut bar. Confirmed methods (grep `gmToolbarUI::`):
|
|
||||||
- `UseShortcut(slot)`, `AddShortcut`, `RemoveShortcut`, `RemoveShortcutInSlotNum`, `FlushShortcuts`, `CreateShortcutToItem`, `IsShortcutEligible(ACCWeenieObject*)`, `IsShortcutSlotAvailable`, `GetFirstEmptyShortcutToTheRightOf`, `RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`.
|
|
||||||
- Slots hold **`UIElement_UIItem`** widgets (`UIElement_UIItem::SetShortcutNum` / `SetDelayedShortcutNum`); the underlying object is an **`ACCWeenieObject`** with `SetShortcutNum`.
|
|
||||||
- Spell shortcuts: `UIElement_ItemList::ItemList_InsertSpellShortcut`, `CM_Magic::SendNotice_AddSpellShortcut`.
|
|
||||||
|
|
||||||
### B. Inventory → `gmInventoryUI` (element class `0x10000023`), `gmBackpackUI` (`0x10000022`)
|
|
||||||
The inventory window + nested backpacks/side-packs. Items are server-spawned **weenies** (`ACCWeenieObject`) — see `claude-memory/feedback_weenie_vs_static.md` (selectable/interactable items are server weenies, not dat-baked).
|
|
||||||
|
|
||||||
### C. Equipment / paperdoll → `gmPaperDollUI` (element class `0x10000024`), `gm3DItemsUI` (`0x10000021`)
|
|
||||||
The paperdoll window shows equipped/wielded items + the character doll. `gm3DItemsUI` is likely the 3D doll viewport (a rotating character model with equipped gear).
|
|
||||||
|
|
||||||
### Shared building blocks (the toolkit pieces these need that we DON'T have yet)
|
|
||||||
- **`UIElement_UIItem`** (element class `0x10000032`) — **the item-in-a-slot widget**: an icon (from the weenie's `IconDataID`), drag-drop (retail `UIElement_Field`'s `CatchDroppedItem` / `MouseOverTop` hooks — note our `UiField` already documents these as future drag-drop hooks), a shortcut number, a quantity/burden overlay, a selection/highlight. **This is the most important new generic widget** — all three panels are grids/lists of these.
|
|
||||||
- **`UIElement_ItemList`** (`0x10000031`) — a scrollable list/grid of items (retail's ListBox-for-items). Maps to a `UiListBox` (Type 5, not yet built) or a `UiItemGrid`.
|
|
||||||
- **The window manager** (the *other* deferred Plan-2 piece): open/close/z-order/persist, drag via Dragbar (Type 2), resize via Resizebar (Type 9). Inventory/paperdoll/toolbar are pop-up/dockable windows that need this.
|
|
||||||
- **Drag-drop infrastructure**: item drag between inventory ↔ equip ↔ toolbar ↔ ground, with the wire messages it triggers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The research questions (the deep-research scope)
|
|
||||||
|
|
||||||
Produce a research doc per panel (or one combined doc) under `docs/research/` answering these, each with a **retail anchor** (named `class::method` + decomp line, or `LayoutDesc`/element-class id) and cross-referenced against the references in §4.
|
|
||||||
|
|
||||||
**Common / cross-cutting (do this first — it unblocks all three):**
|
|
||||||
1. **The dat `LayoutDesc` id** for each panel (`gmToolbarUI` / `gmInventoryUI` / `gmPaperDollUI`). The element-class ids above are the *registered class*; find the actual `LayoutDesc` (`0x21xxxxxx`) that builds each window. Use the `AcDream.Cli` layout-dump tools (`dump-vitals-layout <datdir> 0xId`, the `LayoutIndexDump`) and grep the decomp for the class's `Create`/`PostInit`.
|
|
||||||
2. **`UIElement_UIItem`** (`0x10000032`) — full port spec: what Type does it resolve to in the dat? How does it render an item icon from the weenie's `IconDataID` (→ `RenderSurface`/`Icon` overlay — cross-ref WorldBuilder/ACViewer icon decode)? How are quantity, burden, wielded/selected states drawn? What's the drag-drop state machine (`MouseOverTop`/`CatchDroppedItem`)?
|
|
||||||
3. **The item/container data model**: items are `ACCWeenieObject`s. How does the client learn container contents — which wire messages (CreateObject, the container/inventory messages), and how is the container hierarchy (main pack → backpacks → side-packs) represented? What does acdream already parse (cross-ref the wire-message catalog, §4)?
|
|
||||||
|
|
||||||
**Action bar (`gmToolbarUI`):**
|
|
||||||
4. The shortcut slot model — how many slots/bars, item vs spell shortcuts, what a slot stores (`ShortcutNum`), `IsShortcutEligible`.
|
|
||||||
5. Persistence + wire: is the toolbar server-persisted? What messages add/remove/use a shortcut (`RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`) and what does activation send (use-item / cast-spell)?
|
|
||||||
6. Drag-from-inventory-to-slot + drag-to-reorder (`CreateShortcutToItem`, `GetFirstEmptyShortcutToTheRightOf`).
|
|
||||||
|
|
||||||
**Inventory (`gmInventoryUI` / `gmBackpackUI`):**
|
|
||||||
7. The window layout (the item grid, the side-pack tabs/list, burden/value summary).
|
|
||||||
8. The full set of inventory wire messages (server → client item arrival + client → server actions: pick up, drop, give, move-between-containers, split-stack, merge-stack). Cross-ref ACE (what the server sends/validates) + holtburger (what the client sends).
|
|
||||||
9. Icon rendering: weenie `IconDataID` → texture (+ any underlay/overlay/highlight for ID'd vs unidentified, wielded, selected).
|
|
||||||
|
|
||||||
**Equipment / paperdoll (`gmPaperDollUI` / `gm3DItemsUI`):**
|
|
||||||
10. The equip/wield slots — the coverage/location enum (which slots exist, their screen positions on the doll).
|
|
||||||
11. The wield/unwield wire messages (equip an item, the server's response, the resulting `ObjDesc` change on the character model).
|
|
||||||
12. The paperdoll rendering — is it the 2D doll image + slot icons, or a 3D character viewport (`gm3DItemsUI`)? How does it assemble the equipped-character appearance (cross-ref ACViewer's ObjDesc/CreaturePalette + WorldBuilder for the model)?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. References (the hierarchy — cross-reference at least two per question)
|
|
||||||
|
|
||||||
Per CLAUDE.md's reference table:
|
|
||||||
- **Named retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + `symbols.json`) — **primary oracle for the UI logic** (the `gm*UI` / `UIElement_UIItem` classes). Grep by `class::method`.
|
|
||||||
- **holtburger** (`references/holtburger/`) — **primary oracle for client behavior + wire**: what a client sends/receives for inventory, equip, use-item, drag-drop. Look in `client/` + `session/`.
|
|
||||||
- **ACE** (`references/ACE/Source/ACE.Server/`) — **server expectations**: the inventory/equip/move game-action handlers, what the server validates + broadcasts.
|
|
||||||
- **WorldBuilder** + **ACViewer** — **icon + 3D-model rendering**: `IconDataID` → texture decode (ACViewer `TextureCache.IndexToColor` / WorldBuilder `TextureHelpers`); the equipped-character ObjDesc assembly (ACViewer `StaticObjectManager` / `CreaturePalette`).
|
|
||||||
- **Chorizite.ACProtocol** (`references/Chorizite.ACProtocol/Types/*.cs`) — protocol field order for the inventory/equip messages.
|
|
||||||
|
|
||||||
**Existing acdream research/memory to read first (don't re-research what's done):**
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` — the toolkit + the find-by-id controller pattern (START HERE).
|
|
||||||
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies.
|
|
||||||
- `claude-memory/project_interaction_pipeline.md` — the existing WorldPicker / Select / UseSelected interaction (B.4) — the use/pickup path partly exists.
|
|
||||||
- `claude-memory/MEMORY.md` → the **wire-message catalog** research (`research/2026-06-04-wire-message-catalog.md`): 256 opcodes, what acdream parses vs stubs vs is-missing — the inventory/equip opcodes' parse status is in there.
|
|
||||||
- `docs/research/2026-06-15-layoutdesc-format.md` — the `LayoutDesc`/`ElementDesc` format (for reading the panel layouts).
|
|
||||||
- The `AcDream.Cli` layout-dump tools (`dump-vitals-layout`, `LayoutIndexDump`, `LayoutFixtureDump` from this session's Task 1) — for dumping any panel's `LayoutDesc`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Deliverable + approach
|
|
||||||
|
|
||||||
**Report-only research** — no implementation, no code changes (use the `/investigate` discipline, or just produce research docs). Output: one or more `docs/research/2026-06-NN-*-deep-dive.md` docs (mirroring the existing `2026-06-04-*-deep-dive.md` set), each with:
|
|
||||||
- The retail anchors (class::method + decomp line; `LayoutDesc`/element-class ids).
|
|
||||||
- The wire-message catalog for the panel (direction, trigger, field layout, ACE handler, acdream parse status).
|
|
||||||
- The item/container model + the `UIElement_UIItem` port spec.
|
|
||||||
- The drag-drop mechanics.
|
|
||||||
- **A concrete "what the toolkit needs" list** — the new generic widgets (`UiItemSlot`/`UIElement_UIItem` port, `UiListBox`/item-grid, the window manager) + which `Type` they register at — so the *next* session can go straight to a brainstorm → spec → plan.
|
|
||||||
- An end-of-doc `MEMORY.md` index line.
|
|
||||||
|
|
||||||
**Suggested approach:** this is broad (3 panels × decomp + 5 references + dat + wire). A **multi-agent research Workflow** fits well — e.g. one agent per panel for the class/LayoutDesc/wire scoping, plus one agent on the shared `UIElement_UIItem`/icon-rendering/drag-drop spine, then synthesize. (Ultracode authorizes this.) Or run the panels sequentially. Either way, finish with a synthesis that names the new toolkit widgets.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. New-session prompt (paste this into a fresh session)
|
|
||||||
|
|
||||||
> Deep retail-faithful **research phase** (report-only, no code) for acdream's next three UI panels, building on the now-complete D.2b widget toolkit (merged to `main`). **Read the handoff first: `docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md`.
|
|
||||||
>
|
|
||||||
> **Targets + confirmed retail entry points** (named decomp): action bar / quick slots = `gmToolbarUI` (element class `0x10000007`); inventory = `gmInventoryUI` (`0x10000023`) + `gmBackpackUI` (`0x10000022`); equipment/paperdoll = `gmPaperDollUI` (`0x10000024`) + `gm3DItemsUI` (`0x10000021`). Shared spine: `UIElement_UIItem` (`0x10000032`, the item-in-a-slot widget) + `UIElement_ItemList` (`0x10000031`). Items are server `ACCWeenieObject` weenies.
|
|
||||||
>
|
|
||||||
> **Produce** a `docs/research/` deep-dive per panel (+ a synthesis) answering the §3 research questions in the handoff — each with a retail anchor (class::method + decomp line / `LayoutDesc` + element-class id), the panel's wire-message catalog (cross-ref holtburger=client, ACE=server, Chorizite.ACProtocol=field order), the item/container model, the `UIElement_UIItem` port spec + icon rendering (cross-ref WorldBuilder/ACViewer for `IconDataID`→texture), and the drag-drop mechanics. **End with a concrete "new generic widgets the toolkit needs" list** (the item-slot widget, an item list/grid, the window manager) + the `Type` each registers at, so the following session can brainstorm → spec → plan the build. Use a multi-agent research Workflow (one agent per panel + one on the shared item-slot/icon/drag-drop spine) — Ultracode is authorized. Follow the mandatory grep-named→cross-ref→pseudocode workflow; do not write implementation code this phase.
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
# Action bar / quick slots (`gmToolbarUI`) — retail-faithful deep dive
|
|
||||||
|
|
||||||
**Date:** 2026-06-16
|
|
||||||
**Panel:** action bar / shortcut bar — retail class `gmToolbarUI`, element class `0x10000007`, `LayoutDesc 0x21000016` (root element 300×122).
|
|
||||||
**Scope:** handoff §3 Q1 (LayoutDesc/element map) + Q4 (shortcut slot model) + Q5 (wire + persistence) + Q6 (drag-drop / reorder). Report-only; no code written this phase.
|
|
||||||
**Builds on:** the D.2b importer/widget toolkit (`src/AcDream.App/UI/` + `…/UI/Layout/`). The "spine" item-slot/icon doc referenced in the handoff prompt does **NOT exist** in this worktree (searched `**/*spine*`, `**/*item-slot*`, the named path — all NOT FOUND), so the `UIElement_UIItem` / `UIElement_ItemList` facts below are derived here directly from the decomp; a later synthesis should reconcile with the spine doc if it lands.
|
|
||||||
|
|
||||||
## 1. Summary + confidence legend
|
|
||||||
|
|
||||||
The retail toolbar is **one `gmToolbarUI` window** that contains **18 single-cell item slots** (two rows of 9: top `0x100001A7..AF`, bottom `0x100006B7..BF`), each slot a **`UIElement_ItemList` (element class `0x10000031`)** holding at most one **`UIElement_UIItem` (class `0x10000032`)**. A slot stores nothing but the item it currently holds; the persistent model is `ShortCutManager::shortCuts_[18]` (an array of `ShortCutData{ index_, objectID_, spellID_ }`) living on the `CPlayerModule`. Shortcuts are **server-persisted as a character option** — they arrive in the big `PlayerDescription` login message (the `CharacterOptionDataFlag::SHORTCUT` block, **already parsed by acdream**) and are mutated live by two C2S game actions: **`AddShortCut 0x019C`** and **`RemoveShortCut 0x019D`** (both already have outbound builders in acdream). Activation of a slot does **not** use a "use-shortcut" wire message — it routes through the ordinary **use-item** path (`ItemHolder::UseObject`), so it reuses acdream's existing B.4 interaction pipeline. Drag-from-inventory and drag-to-reorder are handled by `gmToolbarUI` being an `ItemListDragHandler` (multiple inheritance) whose `HandleDropRelease` resolves the target slot and calls `CreateShortcutToItem` / `AddShortcut` / `GetFirstEmptyShortcutToTheRightOf`.
|
|
||||||
|
|
||||||
The 2 Meters + 1 Scrollbar in the layout dump are **NOT** bar paging or extra vitals: they are the **selected-object Health & Mana meters** (`0x100001A1`/`0x100001A2`) and the **stack-size split slider** (`0x100001A4`), all inside the `m_pSelObjectField` sub-panel and **hidden by default** (`SetVisible(0)` in `PostInit`) — they appear only when you select an object / split a stack.
|
|
||||||
|
|
||||||
**Confidence legend** — **CONFIRMED** = quoted from named decomp or a reference file I opened; **LIKELY** = inferred from confirmed facts (source named); **UNVERIFIED** = educated guess, flagged.
|
|
||||||
|
|
||||||
## 2. LayoutDesc / element map (Q1) — CONFIRMED against `.layout-dumps/toolbar-0x21000016.txt`
|
|
||||||
|
|
||||||
`LayoutDesc 0x21000016` (Id 553648150). The dump's `Width=800 Height=600` is the LayoutDesc canvas; the **root element `0x10000191`** (ElementId 268435857, **Type `0x10000463` = the registered `gmToolbarUI` class type**) is **300×122** — that 300×122 matches the handoff's stated size and is the real window. The root's Type value `268435463 = 0x10000007`… correction: dump shows `Type = 268435463` which is `0x10000007` (the `gmToolbarUI` class id) — i.e. the root element registers as the panel class itself, exactly like `gmToolbarUI::GetUIElementType` returns `0x10000007` (decomp line 196707: `return 0x10000007;`). CONFIRMED.
|
|
||||||
|
|
||||||
### 2a. The 18 shortcut slots — element→slot-index map
|
|
||||||
|
|
||||||
`gmToolbarUI::InitShortcutArray` (decomp line 197051) wires the slots by walking `GetChildRecursive(this, <id>)` in order and `DynamicCast(0x10000031)` (= `UIElement_ItemList`), registering each with the drag handler and pushing into `m_shortcutSlots`. The push order **is** the slot index. The 18 ids extracted from the function body (decomp 197054–197560):
|
|
||||||
|
|
||||||
| Slot # | Element id | Row | Dump X,Y (W×H) | Hotkey msg (use / select) |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 0 | `0x100001A7` | top | 6,58 (32×32) | `0x10000042` / `0x1000004E` |
|
|
||||||
| 1 | `0x100001A8` | top | 38,58 | `0x10000043` / `0x1000004F` |
|
|
||||||
| 2 | `0x100001A9` | top | 70,58 | `0x10000044` / `0x10000050` |
|
|
||||||
| 3 | `0x100001AA` | top | 102,58 | `0x10000045` / `0x10000051` |
|
|
||||||
| 4 | `0x100001AB` | top | 134,58 | `0x10000046` / `0x10000052` |
|
|
||||||
| 5 | `0x100001AC` | top | 166,58 | `0x10000047` / `0x10000053` |
|
|
||||||
| 6 | `0x100001AD` | top | 198,58 | `0x10000048` / `0x10000054` |
|
|
||||||
| 7 | `0x100001AE` | top | 230,58 | `0x10000049` / `0x10000055` |
|
|
||||||
| 8 | `0x100001AF` | top | 262,58 | `0x1000004A` / `0x10000056` |
|
|
||||||
| 9 | `0x100006B7` | bottom | 6,90 | `0x1000004B` / `0x10000057` |
|
|
||||||
| 10 | `0x100006B8` | bottom | 38,90 | `0x1000004C` / `0x10000058` |
|
|
||||||
| 11 | `0x100006B9` | bottom | 70,90 | `0x1000004D` / `0x10000059` |
|
|
||||||
| 12 | `0x100006BA` | bottom | 102,90 | `0x10000132` / `0x10000138` |
|
|
||||||
| 13 | `0x100006BB` | bottom | 134,90 | `0x10000133` / `0x10000139` |
|
|
||||||
| 14 | `0x100006BC` | bottom | 166,90 | `0x10000134` / `0x1000013A` |
|
|
||||||
| 15 | `0x100006BD` | bottom | 198,90 | `0x10000135` / `0x1000013B` |
|
|
||||||
| 16 | `0x100006BE` | bottom | 230,90 | `0x10000136` / `0x1000013C` |
|
|
||||||
| 17 | `0x100006BF` | bottom | 262,90 | `0x10000137` / `0x1000013D` |
|
|
||||||
|
|
||||||
CONFIRMED — slot ids from `InitShortcutArray`; X/Y from the dump; hotkey msg ids from `gmToolbarUI::ListenToGlobalMessage` (decomp 197564). The hotkey routing:
|
|
||||||
- `0x10000042..0x1000004D` → `UseShortcut(this, msg-0x10000042, 1)` → slots **0–11**, **use** (arg3=1). (decomp 197576–197591)
|
|
||||||
- `0x1000004E..0x10000059` → `UseShortcut(this, msg-0x1000004E, 0)` → slots **0–11**, **select** (arg3=0). (decomp 197592–197606)
|
|
||||||
- `0x10000132..0x10000137` → `UseShortcut(this, 0xC..0x11, 1)` → slots **12–17**, **use**. (decomp 197616–197645)
|
|
||||||
- `0x10000138..0x1000013D` → `UseShortcut(this, 0xC..0x11, 0)` → slots **12–17**, **select**. (decomp 197646–197674)
|
|
||||||
|
|
||||||
The slot count `18` is independently confirmed by the header struct `ShortCutManager::shortCuts_[18]` (`acclient.h` line 36492–36494: `struct __cppobj ShortCutManager : PackObj { ShortCutData *shortCuts_[18]; };`) and the login-restore loop `for (i=0; i<0x12; i++)` in `UpdateFromPlayerDesc` (decomp 198879). ACE's comment corroborates the UX: *"there are two rows. The top row is 1-9, the bottom row has no hotkeys"* (`Player_Character.cs:250`).
|
|
||||||
|
|
||||||
Slot template: each slot's `ElementDesc` has `BaseElement=0x100001B2` / `BaseLayoutId=553648150` (the dump's last element, `0x100001B2`, ElementId 268435890, which itself inherits `BaseElement=268436281 BaseLayoutId=553648189`). `0x100001B2` is the slot **prototype** (W=32 H=32) — i.e. the 18 slot elements are clones of one `UIElement_ItemList` prototype. LIKELY (from the dump's BaseElement chain; the resolved Type would surface 0x10000031 via `ElementReader.Merge`, exactly as the toolkit memory describes for Type-0 inheritance).
|
|
||||||
|
|
||||||
### 2b. The selected-object sub-panel + the "extra" widgets (resolves the prompt's "2 Meters / 1 Scrollbar = ?")
|
|
||||||
|
|
||||||
From `gmToolbarUI::PostInit` (decomp 198119) — all `GetChildRecursive` + `DynamicCast`:
|
|
||||||
|
|
||||||
| Element id | Field | DynamicCast Type | Dump location | Purpose |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `0x1000019D` | `m_pUseObjectButton` | (button) | 55,27 (23×31) | the **Use** button (sprite `0x06001129`, Ghosted `0x0600120E`) |
|
|
||||||
| `0x100001A5` | `m_pExamineObjectButton` | (button) | 218,27 (22×31) | the **Examine/Appraise** button (sprite `0x06001127`) |
|
|
||||||
| `0x1000019E` | `m_pSelObjectField` | (Type 3 container) | 78,27 (140×31) | the selected-object info sub-panel (dump `0x1000019E`) |
|
|
||||||
| `0x1000019F` | `m_pSelObjectName` | `DynamicCast(0xC)` Text | child of A field | selected object's **name** |
|
|
||||||
| **`0x100001A1`** | `m_pSelObjectHealthMeter` | `DynamicCast(7)` **Meter** | child | **Meter #1 = target Health bar** |
|
|
||||||
| **`0x100001A2`** | `m_pSelObjectManaMeter` | `DynamicCast(7)` **Meter** | child | **Meter #2 = target Mana bar** |
|
|
||||||
| `0x100001A3` | `m_pStackSizeEntryBox` | `DynamicCast(0xC)` Text | child | the **stack-split number entry** (gets `NumberInputFilter`) |
|
|
||||||
| **`0x100001A4`** | `m_pStackSizeSlider` | `DynamicCast(0xB)` **Scrollbar** | 50,13 (90×14), Type 11 | **Scrollbar = the stack-split slider** |
|
|
||||||
|
|
||||||
`PostInit` ends (decomp 198307–198310) by hiding all four: `m_pSelObjectHealthMeter/ManaMeter/StackSizeEntryBox/StackSizeSlider → SetVisible(0)`. **So the 2 Meters and the Scrollbar are NOT toolbar paging or persistent vitals — they are the on-demand "selected object" readout + the stack-split slider, hidden until needed.** CONFIRMED.
|
|
||||||
|
|
||||||
Panel-launcher buttons (open inventory/spellbook/etc.) wired into `m_buttonInfoArray` with a `panelID` attribute (`0x10000029`): `0x10000197, 0x10000198, 0x10000199, 0x1000055A, 0x1000019A, 0x1000019B, 0x100001B1` (decomp 198179–198303). `0x100001B1` (X=238 W=63, sprite `0x06004CF7` Alphablend, with child `0x1000046C` = `m_pInventoryButtonDragOverlay`) is the **inventory button that also serves as a "drop item into your pack" target** (see §5). The `0x1000019C/0x10000196` Type-3 elements (sprites `0x0600112B/0x0600112C`) are decorative dividers; the `0x10000194` element drives `UpdateAmmoNumber` (the ammo-count readout, decomp 198081). Text0x34 in the pre-dump label = the 0x34 (52) text/field/image sub-elements across this whole tree (chrome + the above); they are NOT 52 slots.
|
|
||||||
|
|
||||||
## 3. Shortcut slot model (Q4) — CONFIRMED
|
|
||||||
|
|
||||||
**A slot holds an item, the player module holds the model.** Each `m_shortcutSlots[i]` is a `UIElement_ItemList`; `UseShortcut`/`RemoveShortcutInSlotNum` read the item via `UIElement_ListBox::GetItem(slot, 0)` then `DynamicCast(0x10000032)` (= `UIElement_UIItem`) and read the **object id at field offset `+0x5FC`** on the `UIItem` (decomp 196415, 196519, 196811: `*(uint32_t*)((char*)eax_1 + 0x5fc)`). That `+0x5FC` is the weenie/object id the slot points at. UNVERIFIED exact field name (offset only); LIKELY the `UIItem`'s bound object id.
|
|
||||||
|
|
||||||
**`ShortCutData` (the persistent unit)** — verbatim header (`acclient.h:36484`):
|
|
||||||
```c
|
|
||||||
struct __cppobj ShortCutData : PackObj {
|
|
||||||
int index_; // slot number (0..17)
|
|
||||||
unsigned int objectID_;// item guid (0 if spell shortcut)
|
|
||||||
unsigned int spellID_; // spell id (0 for item shortcut)
|
|
||||||
};
|
|
||||||
```
|
|
||||||
Constructed `CShortCutData(&var_10, index, objectID, spellID)` (decomp 489341: `index_=arg2; objectID_=arg3; spellID_=arg4`). For an **item** shortcut the toolbar always passes `spellID=0` (`CShortCutData(&var_10, i_1, arg2, 0)` in `AddShortcut`, decomp 196874).
|
|
||||||
|
|
||||||
**Number of slots / bars:** 18 slots in 2 visible rows of 9 (top row = hotkeys 1-9, bottom = no hotkeys but addressable via `UseShortcut(0xC..0x11)`). There is **no separate "bar paging"** — all 18 are always present; the layout just stacks two rows. CONFIRMED (§2a).
|
|
||||||
|
|
||||||
**Item vs spell shortcuts.** The data model has a `spellID_` slot, **but in practice the toolbar holds only items.** Confirmation from three angles:
|
|
||||||
1. The toolbar's add paths only ever construct item shortcuts (`AddShortcut`/`CreateShortcutToItem` pass `spellID=0`).
|
|
||||||
2. Spell shortcuts live in a **different** list — the spellbook's `m_spellList` via `UIElement_ItemList::ItemList_InsertSpellShortcut` (decomp 232294) and the spell-bar hotbars (the `SpellLists8` / `hotbar_spells` block, separate from `SHORTCUT`). `CM_Magic::SendNotice_AddSpellShortcut` (decomp 682275) is a **local UI notice** (dispatched via `gmGlobalEventHandler` to notice handlers), **not** a wire send and **not** routed to `gmToolbarUI`.
|
|
||||||
3. Chorizite's own comment on `ShortCutData.SpellId`: *"May not have been used in prod? … I don't think you could put spells in shortcut spot…"* (`ShortCutData.generated.cs:34`). CONFIRMED — the toolbar is item-only; the `spellID_`/spell-bar machinery is a separate spellbook concern (out of scope for the action-bar widget).
|
|
||||||
|
|
||||||
**`IsShortcutEligible(ACCWeenieObject*)`** (decomp 196261, `__stdcall`): returns true unless the object is null, **OR** it's the player itself / a creature you don't own, **OR** it's currently inside the open vendor's container. Logic (decomp 196268–196300):
|
|
||||||
- if `(pwd._bitfield & 4) == 0` (not "owned"?) and not a player → fall through; else require `IsPlayer()`.
|
|
||||||
- then `if ((InqType() & 0x10) != 0)` (Creature type bit) require `IsPlayer()` to continue;
|
|
||||||
- then read `pwd._containerID`; eligible (`return 1`) **iff** `_containerID == 0` OR `_containerID != UISystem->vendorID` — i.e. anything not sitting in the currently-open vendor window is eligible. CONFIRMED (paraphrase of the branch tree).
|
|
||||||
|
|
||||||
**`IsShortcutSlotAvailable(slot)`** (decomp 196575): `slot` in range AND `UIElement_ItemList::GetNumUIItems(slot)==0` (empty). CONFIRMED.
|
|
||||||
|
|
||||||
**Activation — `UseShortcut(slot, useFlag)`** (decomp 196395):
|
|
||||||
1. Get the `UIItem` in the slot; read its object id from `+0x5FC`.
|
|
||||||
2. If a **target mode** is active (`UISystem->targetMode != TARGET_MODE_NONE`, e.g. a spell awaiting a target): `ClientUISystem::ExecuteTargetModeForItem(objId, targetMode)` then clear target mode. (decomp 196412–196421)
|
|
||||||
3. Else if `useFlag != 0`: `ItemHolder::UseObject(objId, 0, 0)` — the **standard use-item** action. (decomp 196429)
|
|
||||||
4. Else (`useFlag==0`): `ACCWeenieObject::SetSelectedObject(objId, 0)` — just select it. (decomp 196433)
|
|
||||||
|
|
||||||
So **toolbar activation is the ordinary use-item path**, not a bespoke message. `ItemHolder::UseObject` (decomp 402923) has a **0.2 s throttle** (`m_timeLastUsed + 0.2`, decomp 402933) and then dispatches the use via the inventory-request path (`DetermineUseResult` → 0x0036 "Use" or 0x0035 "UseWithTarget"). LIKELY (the exact 0x0035/0x0036 branch is deep in `UseObject`; the throttle + dispatch are CONFIRMED, the opcode selection is inferred from acdream's existing `InteractRequests.cs` opcodes 0x0035/0x0036).
|
|
||||||
|
|
||||||
## 4. Wire + persistence (Q5)
|
|
||||||
|
|
||||||
### 4a. Persistence = a character option in `PlayerDescription` (login restore)
|
|
||||||
|
|
||||||
Shortcuts are saved server-side (ACE: `CharacterPropertiesShortcutBar`, `Player_Character.cs:235`) and shipped to the client **inside the `PlayerDescription` login message** in the `CharacterOptionDataFlag::SHORTCUT` (0x1) block — `count:u32` then `count × ShortCutData`. CONFIRMED in three refs:
|
|
||||||
- holtburger `events.rs:514-524` (`PlayerDescriptionEventData.shortcuts`, *"List of user-defined shortcuts for the action bar"* line 124).
|
|
||||||
- ACE `Player_Character.cs:238 GetShortcuts()` reads `Character.GetShortcuts(...)` → `List<Shortcut>` for the description.
|
|
||||||
- **acdream already parses this**: `PlayerDescriptionParser.cs:345-356` reads `count` then `ShortcutEntry(Index, ObjectGuid, SpellId, Layer)` per entry, exposed on `Parsed.Shortcuts`.
|
|
||||||
|
|
||||||
Client-side restore: `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) → `FlushShortcuts()`, gets the `CPlayerModule`'s `ShortCutManager`, then `for (i=0; i<0x12; i++) { objId = shortCuts_[i]->objectID_ (+8); if (objId) AddShortcut(this, objId, i, 0); }` (decomp 198879–198893). The `0` final arg = **do NOT echo to server** (it's already persisted). CONFIRMED.
|
|
||||||
|
|
||||||
### 4b. Live mutation — two C2S game actions
|
|
||||||
|
|
||||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `0x019C` | AddShortCut | C→S | `AddShortcut(…, send=1)` builds `CShortCutData(slot,objId,0)` → `CM_Character::Event_AddShortCut` | `GameActionAddShortcut.Handle` → `Player.HandleActionAddShortcut(shortcut)` → `Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** (outbound `InventoryActions.BuildAddShortcut`, see note) |
|
|
||||||
| `0x019D` | RemoveShortCut | C→S | `RemoveShortcut(…, send=1)` → `CM_Character::Event_RemoveShortCut(slotIndex)` | `GameActionRemoveShortcut.Handle` → `Player.HandleActionRemoveShortcut(index)` → `Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** (`InventoryActions.BuildRemoveShortcut`) |
|
|
||||||
| (—) | shortcut list | S→C | login | part of `PlayerDescription` `SHORTCUT` block | `ShortCutData` in description | **parsed** (`PlayerDescriptionParser.cs:345`) |
|
|
||||||
|
|
||||||
Opcode values triple-confirmed: decomp `Event_AddShortCut` packs `*(uint32_t*)var_c = 0x19c` (decomp 679733) and `Event_RemoveShortCut` packs `0x19d` (decomp 680332); ACE `GameActionType.cs:77-78` (`AddShortCut=0x019C, RemoveShortCut=0x019D`); holtburger `opcodes.rs:371-374` (commented, same values).
|
|
||||||
|
|
||||||
**Wire field order — `ShortCutData` payload (16 bytes), CONFIRMED across 3 refs:**
|
|
||||||
```
|
|
||||||
Index : u32 (slot 0..17)
|
|
||||||
ObjectId : u32 (item guid; 0 for spell)
|
|
||||||
SpellId : u16 (LayeredSpell.id; 0 for item)
|
|
||||||
Layer : u16 (LayeredSpell.layer; 0 for item)
|
|
||||||
```
|
|
||||||
- Chorizite `ShortCutData.generated.cs:41-46` (`Index`, `ObjectId`, then `LayeredSpellId.Read` = u16 id + u16 layer).
|
|
||||||
- ACE `Shortcut.cs:33-42` `ReadShortcut` (`Index`, `ObjectId`, `ReadLayeredSpell`).
|
|
||||||
- holtburger `shortcuts.rs:13-34` (`index u32`, `object_id Guid`, `spell_id u16`, `layer u16`).
|
|
||||||
RemoveShortCut payload = just `Index:u32` (Chorizite `Character_RemoveShortCut.generated.cs:33`; ACE `GameActionRemoveShortcut.cs:9`; decomp packs `*(uint32_t*)eax_3 = arg1` at 680335).
|
|
||||||
|
|
||||||
**⚠ acdream builder field-naming bug to fix at port time (not a wire bug).** `InventoryActions.BuildAddShortcut(seq, slotIndex, objectType, targetId)` (`InventoryActions.cs:99-110`) writes 24 bytes = 8-byte envelope (`0xF7B1` + seq) + `slotIndex`(u32) + `objectType`(u32) + `targetId`(u32). The **byte layout is correct for item shortcuts** (slot, then guid, then a final dword that for items is `0` = SpellId|Layer), but the parameter names are wrong/misleading: the 2nd field is `Index`, the 3rd is `ObjectId`, and the 4th dword is `SpellId(u16)|Layer(u16)` — there is no separate "objectType". A faithful builder should take `(seq, uint index, uint objectGuid, ushort spellId, ushort layer)` and pack the spell as two u16s. For the toolbar's item-only use, callers must pass `objectGuid` as the 3rd arg and `0` as the 4th. LIKELY a latent bug if anyone wired a "objectType" semantic; flag in the divergence register when the toolbar lands. (CONFIRMED file contents; the "bug" judgment is mine.)
|
|
||||||
|
|
||||||
**ACE's reorder note (important UX contract):** *"When a shortcut is added on top of an existing item, the client automatically sends the RemoveShortcut command for that existing item first, then will add the new item, and re-add the existing item to the appropriate place."* (`Player_Character.cs:254`). This is exactly the `HandleDropRelease` sequence in §5. CONFIRMED.
|
|
||||||
|
|
||||||
## 5. Drag-drop for the toolbar (Q6) — CONFIRMED
|
|
||||||
|
|
||||||
`gmToolbarUI` multiply-inherits `ItemListDragHandler` (constructor sets the `ItemListDragHandler::vftable`, decomp 196680) and registers itself as the drag handler on **every** slot's `UIElement_ItemList` in `InitShortcutArray` (`RegisterItemListDragHandler(slot, &this->vtable)`, decomp 197069 etc.). Drops land in **`gmToolbarUI::HandleDropRelease`** (decomp 197971):
|
|
||||||
|
|
||||||
1. Read source `UIItem` (`ebp = msg.dwParam1+8`) and drop-target element (`ebx = msg.dwParam1+0x10`). (decomp 197974–197976)
|
|
||||||
2. **If the target is the inventory button** (`ebx->m_desc.m_elementID == 0x100001B1`): this is "drop item into my pack." `InqDropIconInfo` extracts the dragged object id; then if owned by player → `CPlayerSystem::PlaceInBackpack(objId, 0)`, else → `ItemHolder::AttemptToPlaceInContainer(objId, playerId, …)`. (decomp 198031–198056) — i.e. dropping on the inventory button moves the *real item* into your pack, it does not create a shortcut.
|
|
||||||
3. **Else (target is a shortcut slot):** find which slot `i` is the ancestor of the drop target (`IsAncestorOfMe(ebx, m_shortcutSlots[i])`, decomp 197991), `InqDropIconInfo(ebp, &objId, &var_4, &flags)`. Then on `objId != 0`:
|
|
||||||
- **drop flags `(flags & 0xE) == 0`** (a fresh drag from inventory, not a within-bar move): `RemoveShortcutInSlotNum(i, 1)` (evict whatever was there, returns its objId `eax_13`), `CreateShortcutToItem(objId, i, 1, 0)` (place the dragged item in slot `i`, send=1). If the evicted `eax_13` was a different item, `GetFirstEmptyShortcutToTheRightOf(i)` and `AddShortcut(eax_13, thatSlot, 1)` to relocate it. (decomp 198007–198018)
|
|
||||||
- **else if `(flags & 4) != 0`** (a within-bar reorder, `m_lastShortcutNumDragged` is the source slot): `RemoveShortcutInSlotNum(i, 1)` → `AddShortcut(objId, i, 1)`; if an item was displaced and `IsShortcutSlotAvailable(m_lastShortcutNumDragged)`, put the displaced item back into the **vacated source slot** (`AddShortcut(eax_15, m_lastShortcutNumDragged, 1)`). (decomp 198020–198027)
|
|
||||||
|
|
||||||
This is precisely ACE's "remove the existing one, add the new one, re-add the existing item to the appropriate place." CONFIRMED.
|
|
||||||
|
|
||||||
**Slot-resolution helpers (Q6 core):**
|
|
||||||
- **`CreateShortcutToItem(objId, slotOrNeg1, send, fromServer)`** (decomp 196905): null-check; get `ACCWeenieObject`; if `IsShortcutEligible`. If `slot != 0xFFFFFFFF` → `RemoveShortcut(objId,1); AddShortcut(objId, slot, 1)` (decomp 196928–196930). Else (slot unspecified) it scans for a home (the loop at 196954+, with a "no empty slot" `DisplayStringInfo` notice when full, decomp 196945–196949). This is the entry called by `RecvNotice_AddShortcut` and the keyboard "add selected to toolbar" (`0x1000010D` → `CreateShortcutToItem(selectedID, 0xFFFFFFFF, 1, 0)`, decomp 197613).
|
|
||||||
- **`AddShortcut(objId, slot, send)`** (decomp 196825): if `slot` out of range, find the **first empty** slot (linear scan, decomp 196836–196848). Then `ItemList_Flush(slot); ItemList_AddItem(slot, objId); SetShortcutNum(weenie, slot)` (or `SetDelayedShortcutNum` if the weenie isn't loaded yet, decomp 196861–196867). If `send`, build `CShortCutData(slot, objId, 0)` → `Event_AddShortCut` (wire) + `PlayerModule::AddShortCut` (local model) (decomp 196873–196876).
|
|
||||||
- **`RemoveShortcut(objId, send)`** (decomp 196462): scan slots for the one containing `objId` (`ItemList_IsInList`), `ItemList_Flush`, `SetShortcutNum(weenie, 0xFFFFFFFF)`; if `send`, `Event_RemoveShortCut(slotIndex)` + `PlayerModule::RemoveShortCut(slotIndex)`; returns the slot index (or `0xFFFFFFFF`). (decomp 196471–196496)
|
|
||||||
- **`RemoveShortcutInSlotNum(slot, send)`** (decomp 196502): read the `UIItem` objId at `+0x5FC`, `RemoveShortcut(objId, send)`, return the evicted objId. (decomp 196519–196524)
|
|
||||||
- **`GetFirstEmptyShortcutToTheRightOf(slot)`** (decomp 196536): scan `slot+1 .. end` for an empty `ItemList` (`GetNumUIItems==0`); if none, wrap-scan `0 .. slot`; return `0xFFFFFFFF` if the bar is full. (decomp 196539–196569)
|
|
||||||
- **`FlushShortcuts()`** (decomp 196442): `ItemList_Flush` every slot (visual clear; does NOT touch the server). Used by login restore. (decomp 196451–196457)
|
|
||||||
|
|
||||||
## 6. New toolkit widgets this introduces
|
|
||||||
|
|
||||||
The toolbar needs the same item-slot spine the inventory/paperdoll need; it adds the slot-grid + drag-handler concept on top.
|
|
||||||
|
|
||||||
| Widget | dat Type it registers at | leaf vs container | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolves to a class id, not a numeric toolkit Type (it's a `UIElement` subclass `0x10000032`, registered via `RegisterElementClass`, not Types 1-0x12); in acdream's factory this is a **new behavioral leaf widget** | **leaf** (`ConsumesDatChildren=>true`) | the item-in-a-slot: icon from weenie `IconId` (+ underlay/overlay/highlight), stack-size + selection state, holds the bound object id (retail `+0x5FC`). **Shared with inventory + paperdoll** — build once. |
|
|
||||||
| **`UiItemList`** (port of `UIElement_ItemList` / `UIElement_ListBox`, class `0x10000031`) | new behavioral widget at class `0x10000031` (the dump shows it as the slot prototype `0x100001B2`'s resolved class; Type-5 `ListBox` is the generic relative but item lists are the specialized `0x10000031`) | **leaf** wrt the importer (it manages its own `UIItem` children procedurally) | a 1-cell (toolbar) or N-cell (inventory) container of `UiItemSlot`s; exposes `AddItem/Flush/IsInList/GetNumUIItems/GetItem`. **Shared.** |
|
|
||||||
| **`ToolbarController`** (the `gmToolbarUI::PostInit`-style binder) | not a widget — a controller (like `VitalsController`/`ChatWindowController`) | n/a | finds the 18 slots by id, the use/examine buttons, the selected-object meters/name, the stack slider; binds `UseShortcut`/`AddShortcut`/`RemoveShortcut`; restores from `Parsed.Shortcuts`; sends 0x019C/0x019D. |
|
|
||||||
| **drag-handler seam** | n/a (an interface on `UiItemList` + the controller) | n/a | port of `ItemListDragHandler` — `OnItemListDragOver` / `HandleDropRelease` (slot resolution from §5). The toolkit's `UiRoot` already has drag-drop input plumbing (per the d2b memory: *"UiRoot already has full input (focus/capture/drag-drop/tooltip/click)"*), so this is a binding, not new infra. |
|
|
||||||
|
|
||||||
**Reuses (no new widget needed):** `UiMeter` (Type 7) for the two selected-object bars; `UiText`/`UiField` (Type 12 / the controller-placed editable) for the name + stack-size box; `UiScrollbar` (Type 11) for the stack slider; `UiButton` (Type 1) for Use/Examine/panel-launchers; `UiDatElement` for chrome. The window-manager (open/close/z-order/persist + grip/dragbar drag from D.2b Plan-2) is needed for show/hide + persisting position, same as inventory/paperdoll — it is **not toolbar-specific**.
|
|
||||||
|
|
||||||
## 7. Open questions / UNVERIFIED
|
|
||||||
|
|
||||||
- **`UIElement_UIItem +0x5FC` field name** — confirmed as the bound object id by offset only; the symbolic field name is UNVERIFIED. Cross-check against the spine doc's `UIItem` port if/when it exists, or grep `UIElement_UIItem::SetShortcutNum`/`UIItem_GetState`.
|
|
||||||
- **Exact use-item opcode `UseObject` sends (0x0035 vs 0x0036)** — `ItemHolder::UseObject` throttle + dispatch CONFIRMED; the precise opcode branch (`DetermineUseResult`) was not traced to the send. acdream's `InteractRequests.cs` already has both (0x0035 UseWithTarget, 0x0036 Use); reconcile when wiring activation.
|
|
||||||
- **`UseShortcut` target-mode path** — `ClientUISystem::ExecuteTargetModeForItem` (for "use item on a target", e.g. a healing kit) is out of scope for the action-bar widget itself; it depends on the target-mode subsystem (cursor target picking). File as a follow-up.
|
|
||||||
- **`SetDelayedShortcutNum`** — the "weenie not loaded yet" deferral path (`AddShortcut` decomp 196867) needs a small state machine on the slot to re-bind once `CreateObject` for that guid arrives. Note for the controller port; not yet detailed here.
|
|
||||||
- **Root element Type value** — the dump prints the root's `Type = 268435463` (=`0x10000007`) for `0x10000191` but some other top-level dump fields print `Type = 268435463` ambiguously; I read it as the panel class id, consistent with `GetUIElementType`. LIKELY; verify with `ElementReader.Merge` when the importer runs over `0x21000016`.
|
|
||||||
- **Spell-on-toolbar** — declared dead (Chorizite + the toolbar's item-only add paths). If a future server/ACE variant DOES persist a spell shortcut (`spellID_!=0`), the `UiItemSlot` would need a spell-icon branch. Low priority; the wire field exists so parsing already handles it.
|
|
||||||
|
|
||||||
## 8. MEMORY.md index line
|
|
||||||
|
|
||||||
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + the stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease` (`CreateShortcutToItem`/`GetFirstEmptyShortcutToTheRightOf`). New toolkit widgets: `UiItemSlot` + `UiItemList` (shared spine) + `ToolbarController`.
|
|
||||||
|
|
@ -1,416 +0,0 @@
|
||||||
# Equipment / Paperdoll panel — retail-faithful deep-dive
|
|
||||||
|
|
||||||
**Date:** 2026-06-16
|
|
||||||
**Scope:** D.2b "core panels" research phase, the equipment/paperdoll target from
|
|
||||||
`docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md` §3 Q1 + Q10/Q11/Q12.
|
|
||||||
**Status:** REPORT-ONLY. No code changed. The deliverable is this doc.
|
|
||||||
**Panels:** `gmPaperDollUI` (element class `0x10000024`, LayoutDesc `0x21000024`) and
|
|
||||||
`gm3DItemsUI` (element class `0x10000021`, LayoutDesc `0x21000021`).
|
|
||||||
|
|
||||||
## 1. Summary + confidence legend
|
|
||||||
|
|
||||||
The retail **paperdoll** (`gmPaperDollUI`) is a **3D character viewport plus ~25
|
|
||||||
single-cell equip slots**, NOT a 2D doll image. The window's element `0x100001D5`
|
|
||||||
(Type `13` = `UIElement_Viewport`) hosts a live `CreatureMode` mini-scene; the
|
|
||||||
character's `CPhysicsObj` is cloned from the player and re-dressed via the SAME
|
|
||||||
ObjDesc machinery the in-world renderer uses (`DoObjDescChangesFromDefault`). Every
|
|
||||||
equip slot is a **single-cell `UIElement_ItemList` (class `0x10000031`)**, one per
|
|
||||||
`EquipMask` location, mapped element-id → coverage-mask by
|
|
||||||
`gmPaperDollUI::GetLocationInfoFromElementID`. Equipping is the
|
|
||||||
`GetAndWieldItem` game action (opcode `0x001A`, `item_guid + EquipMask`); the
|
|
||||||
server's visible reply is `ObjDescEvent` (`0xF625`) which triggers
|
|
||||||
`RedressCreature`. **acdream already parses `ObjDescEvent` (0xF625) and the full
|
|
||||||
ObjDesc/ModelData block, and already has a complete per-instance animated-character
|
|
||||||
render path** (`EntitySpawnAdapter` → `AnimatedEntityState` with palette/part/hidden-
|
|
||||||
part overrides). The paperdoll viewport can REUSE that path — the gap is a
|
|
||||||
**`UiViewport` (Type 0xD) widget** that renders a single entity into a UI rect (a
|
|
||||||
scissored mini 3D pass), an **equip-slot variant of the item-slot widget**
|
|
||||||
(`UIElement_ItemList` 0x10000031, single cell), and the **window manager**.
|
|
||||||
`gm3DItemsUI` (0x21000021) is a SEPARATE "Contents of Backpack" pane (an
|
|
||||||
`UIElement_ItemList` + a text label + a scrollbar), NOT the doll — it does not host
|
|
||||||
a viewport.
|
|
||||||
|
|
||||||
`gm3DItemsUI` is misnamed for our purposes: despite "3DItems", its `PostInit` wires
|
|
||||||
a `m_itemList` (`UIElement_ItemList`) and a `m_contentsText` and sets the text to
|
|
||||||
"Contents of Backpack". It is an inventory contents list, addressed by the inventory
|
|
||||||
deep-dive; included here only because the handoff paired it with the paperdoll.
|
|
||||||
|
|
||||||
**Confidence legend:**
|
|
||||||
- **CONFIRMED** — quoted from a source I opened (decomp line / file:line).
|
|
||||||
- **LIKELY** — inferred from confirmed facts; the inference is named.
|
|
||||||
- **UNVERIFIED** — educated guess; flagged loudly.
|
|
||||||
|
|
||||||
**Note on a missing input:** the handoff promised a "spine agent" doc at
|
|
||||||
`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and the
|
|
||||||
START-HERE memory `claude-memory/project_d2b_retail_ui.md`. **Both are NOT FOUND in
|
|
||||||
this worktree** (`Glob **/project_d2b_retail_ui.md` and `**/*spine*.md` returned
|
|
||||||
nothing). I therefore re-derived the icon/item-model claims I needed from primary
|
|
||||||
sources (decomp + acclient.h + ACE + ACViewer + acdream source) rather than citing a
|
|
||||||
doc I could not open. Where this overlaps the spine's scope (icon decode, the
|
|
||||||
`UIElement_UIItem` widget, container model) I keep it terse and defer to the spine
|
|
||||||
doc once it lands.
|
|
||||||
|
|
||||||
## 2. LayoutDesc / element map
|
|
||||||
|
|
||||||
### 2a. Paperdoll `gmPaperDollUI` 0x10000024 → LayoutDesc 0x21000024 (224×214)
|
|
||||||
|
|
||||||
**CONFIRMED** registration: `gmPaperDollUI::Register` (decomp line 174445):
|
|
||||||
`UIElement::RegisterElementClass(0x10000024, gmPaperDollUI::Create);`. Pre-dump
|
|
||||||
`.layout-dumps/paperdoll-0x21000024.txt` root `0x100001D4` is 224×214, Type
|
|
||||||
`268435492 = 0x10000024` (the gmPaperDollUI class). **CONFIRMED.**
|
|
||||||
|
|
||||||
Construction chain: `gmPaperDollUI::gmPaperDollUI` (line 174228) calls
|
|
||||||
`UIElement_Field::UIElement_Field(this, ...)` — i.e. the paperdoll IS-A Field
|
|
||||||
subclass (matters for drag-drop: it inherits Field's drop hooks). The slot/viewport
|
|
||||||
wiring happens in the init routine that calls `GetChildRecursive` per id
|
|
||||||
(lines 175480-175548) — the analog of a `PostInit`. **CONFIRMED.**
|
|
||||||
|
|
||||||
Key elements in 0x21000024 (from the pre-dump + the init routine):
|
|
||||||
|
|
||||||
| Element id | dump Type | Resolves to | Role | Anchor (cite) |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `0x100001D4` | 0x10000024 | gmPaperDollUI (root) | window | dump:13 |
|
|
||||||
| `0x100001D5` | **13** | `UIElement_Viewport` (0xD) | **the 3D character doll** | dump:125; `m_pPaperDoll = GetChildRecursive(this,0x100001d5)->DynamicCast(0xd)` line 175509-175517 |
|
|
||||||
| `0x100001D6` | 0 → base 0x100002BF/0x21000080 | `m_paperDollDragMask` | doll click/drag mask region (100×214) | dump:157; line 175538 |
|
|
||||||
| `0x1000046D` | 0 → base | `m_paperDollDragOverlay` | drag overlay sprite (32×32) | dump:173; line 175539 |
|
|
||||||
| `0x10000595` | 0 → ItemList | `m_sigilOneItem` (SigilOne 0x10000000) | aetheria sigil slot, hidden by default | line 175540-175542 |
|
|
||||||
| `0x10000596` | 0 → ItemList | `m_sigilTwoItem` (SigilTwo 0x20000000) | sigil slot | line 175543-175545 |
|
|
||||||
| `0x10000597` | 0 → ItemList | `m_sigilThreeItem` (SigilThree 0x40000000) | sigil slot | line 175546-175548 |
|
|
||||||
| `0x100005BE` | 0 → Button base 0x21000044 | a `UIElement_Button` | the close/expand button (120×14) | dump:349; line 175549 |
|
|
||||||
| ~25 more `0x1000xxxx` ids | **0** → base `0x100001E4` | single-cell `UIElement_ItemList` (0x10000031) | the equip slots (§3) | dump:29-476 |
|
|
||||||
|
|
||||||
The shared equip-slot base chain (**CONFIRMED**):
|
|
||||||
- Each slot element has `Type = 0`, `BaseElement = 268435940 = 0x100001E4`,
|
|
||||||
`BaseLayoutId = 553648164 = 0x21000024` (dump e.g. lines 33,49,65…).
|
|
||||||
- Element `0x100001E4` (dump:477) has `Type = 0`, `BaseElement = 268436281 =
|
|
||||||
0x10000339`, `BaseLayoutId = 553648189 = 0x2100003D`.
|
|
||||||
- `0x2100003D` root element `0x10000339` (`.layout-dumps/itemlist-0x2100003D.txt:16`)
|
|
||||||
has `Type = 268435505 = 0x10000031` = `UIElement_ItemList`, 32×32.
|
|
||||||
⇒ **every paperdoll equip slot resolves (via `ElementReader.Merge` zero-wins-base
|
|
||||||
Type resolution) to `UIElement_ItemList` 0x10000031, a single 32×32 cell.**
|
|
||||||
|
|
||||||
The init routine confirms each is cast to ItemList and registered as a drag target,
|
|
||||||
e.g. (line 175485-175496):
|
|
||||||
```
|
|
||||||
eax_66 = GetChildRecursive(this, 0x100005b2); // LowerLegArmor slot
|
|
||||||
eax_67 = eax_66->vtable->DynamicCast(0x10000031); // → UIElement_ItemList
|
|
||||||
this->m_lowerLegSlot = eax_67;
|
|
||||||
UIElement_ItemList::RegisterItemListDragHandler(eax_67, &this->vtable);
|
|
||||||
this->m_lowerLegSlot->vtable->SetVisible(0); // hidden until an item lands
|
|
||||||
```
|
|
||||||
**CONFIRMED.** Slots default invisible and are shown only when occupied (the empty
|
|
||||||
slot shows the doll body behind it; an occupied slot shows the item icon).
|
|
||||||
|
|
||||||
### 2b. gm3DItemsUI 0x10000021 → LayoutDesc 0x21000021 (234×120) — NOT the doll
|
|
||||||
|
|
||||||
**CONFIRMED** registration: `gm3DItemsUI::Register` (line 176723):
|
|
||||||
`UIElement::RegisterElementClass(0x10000021, gm3DItemsUI::Create);`.
|
|
||||||
`gm3DItemsUI::PostInit` (line 176728-176745):
|
|
||||||
```
|
|
||||||
this->m_contentsText = UIElement::GetChildRecursive(this, 0x100001c5);
|
|
||||||
eax_1 = UIElement::GetChildRecursive(this, 0x100001c6);
|
|
||||||
this->m_itemList = eax_1->vtable->DynamicCast(0x10000031); // UIElement_ItemList
|
|
||||||
... UIElement_Text::SetText(this->m_contentsText, u"Contents of Backpack");
|
|
||||||
```
|
|
||||||
Pre-dump `.layout-dumps/items3d-0x21000021.txt`: root `0x100001C4` (234×120, Type
|
|
||||||
`268435489 = 0x10000021`), child `0x100001C5` (text, base 0x10000436/0x21000077),
|
|
||||||
child `0x100001C6` (the ItemList grid, base 0x100002B9/0x2100003D — same ItemList
|
|
||||||
base as the slots), child `0x100001C7` (a scrollbar-shaped 16×96, base
|
|
||||||
0x100002C7/0x2100003E). **No Viewport element.** ⇒ gm3DItemsUI is a scrollable
|
|
||||||
**item-contents list**, not a 3D doll. **CONFIRMED.** (The "3D" in the name is
|
|
||||||
historical; it has no `UIElement_Viewport` and no `CreatureMode`.)
|
|
||||||
|
|
||||||
## 3. Equip-slot model + the coverage / location enum
|
|
||||||
|
|
||||||
### 3a. The element-id → EquipMask mapping (`GetLocationInfoFromElementID`)
|
|
||||||
|
|
||||||
`gmPaperDollUI::GetLocationInfoFromElementID(elementId, out uint mask, out UI_SLOT_SIDE side)`
|
|
||||||
(decomp line 173620) is a giant switch. It is the SSOT for which slot is which. The
|
|
||||||
mask values are exactly ACE's `EquipMask` (`ACE/Source/ACE.Entity/Enum/EquipMask.cs`).
|
|
||||||
**CONFIRMED** — full table below (decomp line / mask / EquipMask name / SLOT_SIDE):
|
|
||||||
|
|
||||||
| Element id | mask (hex) | EquipMask name | SLOT_SIDE | decomp line |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `0x100005AB` | `0x1` | HeadWear | NULL | 173723 |
|
|
||||||
| `0x100001E2` | `0x2` | ChestWear | NULL | 173688 |
|
|
||||||
| `0x100001E3` | `0x40` | UpperLegWear | NULL | 173694 |
|
|
||||||
| `0x100005B0` | `0x20` | HandWear | NULL | 173753 |
|
|
||||||
| `0x100005B3` | `0x100` | FootWear | NULL | 173771 |
|
|
||||||
| `0x100005AC` | `0x200` | ChestArmor | NULL | 173729 |
|
|
||||||
| `0x100005AD` | `0x400` | AbdomenArmor | NULL | 173735 |
|
|
||||||
| `0x100005AE` | `0x800` | UpperArmArmor | NULL | 173741 |
|
|
||||||
| `0x100005AF` | `0x1000` | LowerArmArmor | NULL | 173747 |
|
|
||||||
| `0x100005B1` | `0x2000` | UpperLegArmor | NULL | 173759 |
|
|
||||||
| `0x100005B2` | `0x4000` | LowerLegArmor | NULL | 173765 |
|
|
||||||
| `0x100001DA` | `0x8000` | NeckWear | NULL | 173640 |
|
|
||||||
| `0x100001DB` | `0x10000` | WristWearLeft | LEFT | 173646 |
|
|
||||||
| `0x100001DD` | `0x20000` | WristWearRight | RIGHT | 173658 |
|
|
||||||
| `0x100001DC` | `0x40000` | FingerWearLeft | LEFT | 173652 |
|
|
||||||
| `0x100001DE` | `0x80000` | FingerWearRight | RIGHT | 173664 |
|
|
||||||
| `0x100001E1` | `0x200000` | Shield | NULL | 173682 |
|
|
||||||
| `0x100001E0` | `0x800000` | MissileAmmo | NULL | 173676* |
|
|
||||||
| `0x100001DF` | `0x3500000` | (weapon composite — see 3b) | NULL | 173670 |
|
|
||||||
| `0x100005E9` | `0x8000000` | Cloak | NULL | 173777 |
|
|
||||||
| `0x10000595` | `0x10000000` | SigilOne | NULL | 173705 |
|
|
||||||
| `0x10000596` | `0x20000000` | SigilTwo | NULL | 173711 |
|
|
||||||
| `0x10000597` | `0x40000000` | SigilThree | NULL | 173717 |
|
|
||||||
| `0x1000058E` | `0x4000000` | TrinketOne | NULL | 173630 |
|
|
||||||
|
|
||||||
\* **`0x100001E0`** — the decomp pseudo-C shows `*arg3 = "activation type (%s)…"`
|
|
||||||
(a string-pointer artifact where the Binary Ninja lifter lost the immediate). The
|
|
||||||
preceding/following cases are `0x200000` (Shield) and `0x200000`/`0x40`, and the only
|
|
||||||
remaining ready-slot mask not otherwise assigned in this switch is `MissileAmmo
|
|
||||||
(0x00800000)`. So **`0x100001E0` = MissileAmmo `0x800000` (LIKELY** — inferred from
|
|
||||||
the EquipMask gap + neighbors; the literal value is corrupted in the decomp).
|
|
||||||
|
|
||||||
`UI_SLOT_SIDE` (CONFIRMED `acclient.h:4546`): `SLOT_SIDE_NULL=0, SLOT_SIDE_LEFT=1,
|
|
||||||
SLOT_SIDE_RIGHT=2`. SIDE distinguishes the paired jewelry slots (left/right
|
|
||||||
wrist + finger) that share the same wear concept but different physical sides.
|
|
||||||
|
|
||||||
### 3b. The weapon composite slot `0x3500000`
|
|
||||||
|
|
||||||
`0x100001DF → 0x3500000` = `MeleeWeapon(0x100000) | MissileWeapon(0x400000) |
|
|
||||||
TwoHanded(0x2000000) | Held(0x1000000)` (= `0x3500000`). **CONFIRMED** by bit
|
|
||||||
decomposition against EquipMask.cs. This is the single "weapon hand" doll slot that
|
|
||||||
accepts any wieldable weapon. `OnItemListDragOver` has a special case at line 174302:
|
|
||||||
`if (ecx_3 == 0x200000 && (eax_3 & 0x100000) != 0) eax_3 |= ecx_3;` — i.e. a
|
|
||||||
melee-capable item may also drop into the Shield(0x200000) slot test. **CONFIRMED.**
|
|
||||||
|
|
||||||
### 3c. How the client knows what is equipped — `GetUpperInvObj(mask)`
|
|
||||||
|
|
||||||
`gmPaperDollUI::GetUpperInvObj(uint coverageMask)` (line 174565) is how the doll
|
|
||||||
finds the item currently in a slot:
|
|
||||||
```
|
|
||||||
eax = ClientObjMaintSystem::GetWeenieObject(player_id);
|
|
||||||
eax_3 = ACCWeenieObject::GetInvPlacementList(eax); // PackableList<InventoryPlacement>
|
|
||||||
for (i = eax_3->head; i; i = i->next) {
|
|
||||||
if (arg2 & i->data.loc_) // coverageMask & placement.loc_
|
|
||||||
eax_5 = InventoryPlacement::DetermineHigherPriority(...);
|
|
||||||
}
|
|
||||||
return iid; // the equipped item's guid
|
|
||||||
```
|
|
||||||
`InventoryPlacement` (**CONFIRMED** `acclient.h:33178`):
|
|
||||||
```cpp
|
|
||||||
struct InventoryPlacement : PackObj { uint iid_; uint loc_; uint priority_; };
|
|
||||||
```
|
|
||||||
So the player weenie carries a **`PackableList<InventoryPlacement>`** where each
|
|
||||||
node is `{itemGuid, locationMask (EquipMask), priority}`. `loc_` is the EquipMask
|
|
||||||
slot; `priority_` resolves overlap (e.g. armor over clothing on the same body part —
|
|
||||||
this is `CoverageMask` priority, `ACE/Source/ACE.Entity/Enum/CoverageMask.cs`).
|
|
||||||
**CONFIRMED.** The paperdoll reads this list to populate each slot's icon and to
|
|
||||||
drive part-selection lighting (`GetSelectionMaskFromObject`, line 174762, maps an
|
|
||||||
item guid back to which doll body parts to highlight, via the same masks).
|
|
||||||
|
|
||||||
**Cross-ref ACE:** `EquipMask` (loc) and `CoverageMask` (priority) are documented in
|
|
||||||
ACE as "sent as loc / in the priority field of the equipped-items list portion of the
|
|
||||||
player description event F7B0-0013" (`EquipMask.cs:5-6`, `CoverageMask.cs:6-7`).
|
|
||||||
**CONFIRMED** — this is the same `InventoryPlacement {iid, loc, priority}` triple the
|
|
||||||
client stores, populated from PlayerDescription's equipped section.
|
|
||||||
|
|
||||||
**acdream parse status of the placement list:** PARTIAL. `PlayerDescriptionParser`
|
|
||||||
(0x0013) "walks all sections through enchantments; the trailing options / inventory /
|
|
||||||
**equipped** sections are partial" (`PlayerDescriptionParser.cs:70-77`). So acdream
|
|
||||||
does NOT yet surface the equipped `InventoryPlacement` list. The per-item equip
|
|
||||||
*state* is, however, available from `CreateObject`/`ObjDescEvent` ModelData
|
|
||||||
(palette/part swaps already applied to the model). **CONFIRMED** (parser comment).
|
|
||||||
|
|
||||||
## 4. Wield / unwield wire + the ObjDesc change
|
|
||||||
|
|
||||||
### 4a. Wire table
|
|
||||||
|
|
||||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `0x001A` (GameAction) | GetAndWieldItem | C→S | drop an item onto an equip slot / doll (auto-wield) | `GameActionGetAndWieldItem.Handle` (`Actions/GameActionGetAndWieldItem.cs:7-14`) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`C2S/Actions/Inventory_GetAndWieldItem.generated.cs:14-42`: `uint ObjectId; EquipMask Slot`) | **MISSING** (no sender in acdream; `Grep GetAndWieldItem\|0x001A src` finds only the UI font-property 0x1A, unrelated) |
|
|
||||||
| `0x0019` (GameAction) | PutItemInContainer / move-to-pack (un-wield) | C→S | drag a wielded item back into a pack | ACE `GameActionPutItemInContainer` | `Inventory_PutItemInContainer*` | MISSING (inventory deep-dive scope) |
|
|
||||||
| `0xF625` | ObjDescEvent | S→C | server applies/removes the wielded item → appearance change | `GameMessageObjDescEvent` ctor → `worldObject.SerializeUpdateModelData` (`Messages/GameMessageObjDescEvent.cs:10-17`) | (ModelData block) | **PARSED** — `ObjDescEvent.cs:33-73` (opcode `0xF625`, `CreateObject.ReadModelData`) |
|
|
||||||
| `0xF745`/`0x0024` (CreateObject) | CreateObject | S→C | the wielded item object itself arrives | ACE creation message | `Item_CreateObject` | PARSED — `CreateObject.cs` |
|
|
||||||
| `0xF7B0`/`0x0013` (GameEvent) | PlayerDescription (equipped list) | S→C | full state incl. `InventoryPlacement` equipped section | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — `PlayerDescriptionParser.cs` (equipped section not surfaced) |
|
|
||||||
|
|
||||||
Wire payload of `GetAndWieldItem` (**CONFIRMED** both refs agree):
|
|
||||||
- ACE reads `uint itemGuid; (EquipMask)int32 location` (`GameActionGetAndWieldItem.cs:10-11`).
|
|
||||||
- Chorizite writes `uint ObjectId; (uint)EquipMask Slot` (`.generated.cs:38-41`).
|
|
||||||
- holtburger sends `GetAndWieldItem { item_guid, equip_mask }`
|
|
||||||
(`holtburger-core/src/client/commands.rs:808-814`):
|
|
||||||
```rust
|
|
||||||
self.send_game_action(GameAction::GetAndWieldItem(Box::new(
|
|
||||||
GetAndWieldItemActionData { item_guid: item, equip_mask: target_mask })))
|
|
||||||
```
|
|
||||||
with `target_mask` resolved by `resolve_and_clear_slots(item, slot)` (line 799) —
|
|
||||||
i.e. the client picks the EquipMask for the target slot, exactly like the doll's
|
|
||||||
`GetLocationInfoFromElementID`. **CONFIRMED.**
|
|
||||||
|
|
||||||
`GameActionType.GetAndWieldItem = 0x001A` (**CONFIRMED**
|
|
||||||
`ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:14`).
|
|
||||||
|
|
||||||
### 4b. The ObjDesc change on the model (`ObjDescEvent` → `RedressCreature`)
|
|
||||||
|
|
||||||
Server side: equipping changes the creature's `ObjDesc` (clothing base, sub-palettes,
|
|
||||||
texture changes, anim-part swaps) and broadcasts `ObjDescEvent (0xF625)` carrying the
|
|
||||||
FULL new appearance (ACE comment: "It contains the entire description of what they're
|
|
||||||
wearing", `GameMessageObjDescEvent.cs:6-9`).
|
|
||||||
|
|
||||||
Client side: `gmPaperDollUI::RecvNotice_PlayerObjDescChanged` (line 174324) tail-calls
|
|
||||||
`gmPaperDollUI::RedressCreature` (line 173990). **CONFIRMED.** RedressCreature:
|
|
||||||
```
|
|
||||||
if (m_pInventoryObject == 0 && smartbox->player != 0) { // first time:
|
|
||||||
eax_5 = CPhysicsObj::makeObject(GetPhysicsObject(player_id)); // clone player obj
|
|
||||||
this->m_pInventoryObject = eax_5;
|
|
||||||
CPhysicsObj::set_heading(eax_5, 191.367905f, 1); // face ~191° (toward viewer)
|
|
||||||
CPhysicsObj::set_sequence_animation(m_pInventoryObject, m_didAnimation.id, 1, 1, 0);
|
|
||||||
CreatureMode::AddObject(&m_pPaperDoll->creature_mode_objects, m_pInventoryObject);
|
|
||||||
}
|
|
||||||
visualDesc = SmartBox::get_player_visualdesc(smartbox);
|
|
||||||
CPhysicsObj::DoObjDescChangesFromDefault(this->m_pInventoryObject, visualDesc); // re-dress
|
|
||||||
```
|
|
||||||
**CONFIRMED** (lines 173997-174012). So the doll is a CLONE of the player's
|
|
||||||
`CPhysicsObj`, and re-dressing is `CPhysicsObj::DoObjDescChangesFromDefault` applied
|
|
||||||
to the cloned object using the player's current `VisualDesc` — **the same ObjDesc
|
|
||||||
apply used for in-world creatures**. The ObjDesc fields (ACViewer
|
|
||||||
`Entity/ObjDesc.cs:18-54`): `PaletteID`, `SubPalettes`, `TextureChanges`,
|
|
||||||
`AnimPartChanges` — **all four already parsed by acdream's `CreateObject.ReadModelData`
|
|
||||||
/ `ObjDescEvent`** (`CreateObject.cs:652-679`: subPalette/textureChange/animPartChange
|
|
||||||
counts + entries). **CONFIRMED.**
|
|
||||||
|
|
||||||
## 5. Paperdoll 3D rendering + reuse analysis
|
|
||||||
|
|
||||||
### 5a. It is a 3D viewport, not a 2D image
|
|
||||||
|
|
||||||
**CONFIRMED.** The doll is `UIElement_Viewport` (Type `0xD`), element `0x100001D5`.
|
|
||||||
`UIElement_Viewport::Create` (line 119029-119037) allocates the element + a
|
|
||||||
`CreatureMode` sub-object at `+0x5f0`; `PostInit` calls
|
|
||||||
`CreatureMode::InitializeScene` (line 119084). `SetCamera` forwards to
|
|
||||||
`CreatureMode::SetCameraPosition/Direction` (line 119089-119094). `Register` ⇒
|
|
||||||
`RegisterElementClass(0xd, …)` (line 119126). So a Viewport is a mini 3D scene
|
|
||||||
embedded in a UI rect, with its own camera, lights, and an object list.
|
|
||||||
|
|
||||||
The paperdoll init (line 175517-175535) does, once:
|
|
||||||
```
|
|
||||||
m_pPaperDoll = GetChildRecursive(this, 0x100001d5)->DynamicCast(0xd); // the viewport
|
|
||||||
UIElement_Viewport::SetCamera(m_pPaperDoll, &dir, &pos); // pos/dir vec3s
|
|
||||||
UIElement_Viewport::SetLight(m_pPaperDoll, DISTANT_LIGHT, 2.0, &dir); // one distant light
|
|
||||||
CreatureMode::UseSharpMode(&m_pPaperDoll->creature_mode_objects); // sharper mip bias
|
|
||||||
gmPaperDollUI::RedressCreature(this); // build + dress the doll
|
|
||||||
```
|
|
||||||
**CONFIRMED.** `UpdateForRace` (line 174129) re-points the camera per body-type
|
|
||||||
(case 6/7/8/9/0xC/0xD = the playable races/genders) and swaps `m_didAnimation` (the
|
|
||||||
idle pose DID) via `DBObj::GetDIDByEnum`. **CONFIRMED.**
|
|
||||||
|
|
||||||
### 5b. The viewport render loop (`CreatureMode::Render`)
|
|
||||||
|
|
||||||
`CreatureMode::Render` (line 91665) is the per-frame doll draw. Walk-through
|
|
||||||
(**CONFIRMED** lines 91665-91776):
|
|
||||||
1. Enter "creature mode" (disables world LOD degrade so the doll is full detail).
|
|
||||||
2. For each object in `creature_mode_objects`: `CPhysicsObj::update_position` (advance
|
|
||||||
the idle animation).
|
|
||||||
3. Set ambient color, sunlight, FOV (`Render::SetFOVRad`), push a frame.
|
|
||||||
4. `Render::update_viewpoint(&creature_view_frame)`, `set_default_view()`.
|
|
||||||
5. `RenderDevice::DrawObjCellForDummies(creature_cell)` — draw the object's private
|
|
||||||
cell, then `D3DPolyRender::FlushAlphaList`.
|
|
||||||
|
|
||||||
i.e. the doll lives in its own tiny `creature_cell`, lit by one distant light, drawn
|
|
||||||
with a dedicated camera into the viewport rect. `CreatureMode::AddObject` (line 94374)
|
|
||||||
adds the cloned `CPhysicsObj` to that cell:
|
|
||||||
`CPhysicsObj::AddObjectToSingleCell(obj, creature_cell); SetPlacementFrame(obj,0,1);`.
|
|
||||||
**CONFIRMED.**
|
|
||||||
|
|
||||||
### 5c. Can acdream REUSE its existing character render path? — YES
|
|
||||||
|
|
||||||
**acdream already renders animated, equipped characters in-world.** The per-instance
|
|
||||||
path is `EntitySpawnAdapter` (`src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`):
|
|
||||||
- `OnCreate(WorldEntity)` builds an `AnimatedEntityState(sequencer)` and applies
|
|
||||||
`entity.HiddenPartsMask`, every `entity.PartOverrides` (`SetPartOverride(partIndex,
|
|
||||||
gfxObjId)` — weapons/clothing/helmets that replace the Setup default), and
|
|
||||||
pre-warms per-instance palette/texture decode via
|
|
||||||
`GetOrUploadWithPaletteOverride(surfaceId, texOverride, paletteOverride)`.
|
|
||||||
**CONFIRMED** `EntitySpawnAdapter.cs:100-168`.
|
|
||||||
- `WorldEntity` carries `SourceGfxObjOrSetupId`, `MeshRefs`, `PaletteOverride`,
|
|
||||||
`PartOverrides` (`record struct PartOverride(byte PartIndex, uint GfxObjId)`), and
|
|
||||||
`HiddenPartsMask`. **CONFIRMED** `WorldEntity.cs:14,28,37,97,104,213`.
|
|
||||||
|
|
||||||
This is the EXACT data a re-dress produces: ObjDesc → base palette + sub-palettes
|
|
||||||
(`PaletteOverride`), texture changes (`SurfaceOverrides`), anim-part swaps
|
|
||||||
(`PartOverrides`). acdream already turns an `ObjDescEvent`/`CreateObject` ModelData
|
|
||||||
into these fields. **So the paperdoll doll = "take the local player's WorldEntity (or
|
|
||||||
a clone of it), feed it through the existing animated-character pipeline, and draw it
|
|
||||||
with a fixed camera + one distant light into a UI rect."** This is the C# analog of
|
|
||||||
`makeObject(player) + DoObjDescChangesFromDefault + CreatureMode::Render`.
|
|
||||||
|
|
||||||
### 5d. What a `UiViewport` (Type 0xD) widget needs to host the 3D render
|
|
||||||
|
|
||||||
The toolkit's `UiRenderContext` is a **2D** sprite/text submission bucket (see
|
|
||||||
`UiElement.OnDraw(UiRenderContext)`). A 3D model render cannot go through it. A
|
|
||||||
`UiViewport` widget therefore needs (LIKELY design — flagged):
|
|
||||||
1. **A render-into-rect hook.** The widget's screen rect (`ScreenPosition` +
|
|
||||||
Width/Height) defines a GL scissor + viewport. A 3D pass renders the single entity
|
|
||||||
there, AFTER the world pass and BEFORE/INTERLEAVED with the 2D UI pass. The cleanest
|
|
||||||
seam is a dedicated overlay callback the `UiHost`/`GameWindow` invokes for any
|
|
||||||
`UiViewport` present, NOT a draw inside `OnDraw` (which only has a 2D context).
|
|
||||||
**UNVERIFIED** — the exact integration point (a new `IUiViewportRenderer` Core
|
|
||||||
interface implemented in App, per Code-Structure Rule 2) is a design call for the
|
|
||||||
brainstorm/spec phase, not yet decided.
|
|
||||||
2. **A private mini-scene** mirroring `CreatureMode`: one entity (`AnimatedEntityState`
|
|
||||||
for the player clone), a fixed camera (position/direction vec3 like
|
|
||||||
`SetCamera`, e.g. the retail values `dir.z=0.12, pos=(~-2.4, ~0.88)` floats from
|
|
||||||
`UpdateForRace` — see the `0x3df5c28f / 0xc019999a / 0x3f6147ae` immediates at line
|
|
||||||
175524-175526, which are little-endian floats ≈ 0.12, −2.4, 0.88; **LIKELY** —
|
|
||||||
I read the hex but did not byte-convert each), one distant light, and an idle
|
|
||||||
animation playing on the sequencer.
|
|
||||||
3. **A heading toward the viewer** (`set_heading(191.37°)`, line 174001) and optional
|
|
||||||
click-drag rotation (the doll spins under the mouse — that's
|
|
||||||
`m_paperDollDragMask`/`CreateClickMap`, line 174636; **part-selection lighting** for
|
|
||||||
"which armor piece is this?" highlight uses `ApplyPartSelectionLighting`, line
|
|
||||||
174034, but that is a polish feature, not MVP).
|
|
||||||
4. **Reuse `EntitySpawnAdapter`'s state** — feed it the player's `WorldEntity` so the
|
|
||||||
doll automatically reflects equip changes when `ObjDescEvent` updates the player's
|
|
||||||
ModelData. The re-dress is then "rebuild the player WorldEntity's PartOverrides/
|
|
||||||
PaletteOverride from the new ObjDesc and refresh the viewport's entity state" — the
|
|
||||||
C# analog of `RedressCreature`.
|
|
||||||
|
|
||||||
This is the single biggest new piece. The 3D machinery exists; the work is the
|
|
||||||
**UI↔3D bridge** (a scissored single-entity pass driven by a UI rect).
|
|
||||||
|
|
||||||
## 6. New toolkit widgets this introduces
|
|
||||||
|
|
||||||
| Widget (proposed) | dat Type it registers at | leaf vs container | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **`UiViewport`** | **0xD** (`UIElement_Viewport`, reg line 119126) | **leaf** (`ConsumesDatChildren => true`) | Hosts a single 3D entity (the paperdoll character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`; reuses `EntitySpawnAdapter`/`AnimatedEntityState` for the model. Needs a new render-into-rect seam (a Core `IUiViewportRenderer` interface implemented in App). **The biggest new piece.** |
|
|
||||||
| **`UiItemSlot`** (equip-slot variant of the shared item-slot) | **0x10000031** (`UIElement_ItemList`, single 32×32 cell) | **leaf** (`ConsumesDatChildren => true`) | One equip slot. Renders the equipped item's icon (from the weenie `IconDataID`), is a drag-drop target keyed to its `EquipMask` (from `GetLocationInfoFromElementID`), shows/hides per occupancy. NOTE: this is the single-cell case of the shared `UIElement_UIItem`/`UIElement_ItemList` spine widget — the equipment panel is a fixed grid of ~25 of these, one per EquipMask, NOT a scrollable list. **Defer the shared icon/drag mechanics to the spine doc** (`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`, NOT FOUND yet); this panel only adds the EquipMask binding + the fixed-position-per-slot layout. |
|
|
||||||
| **Window manager** (shared, not paperdoll-specific) | n/a (uses Dragbar Type 2 / Resizebar Type 9 already present on chrome) | n/a | Open/close/z-order/persist for the paperdoll window. `UiElement.Draggable/Resizable` already exist; the manager wires them + persistence. Shared with inventory/toolbar — same item the handoff §2 calls "the other deferred Plan-2 piece". |
|
|
||||||
|
|
||||||
`gm3DItemsUI`'s pane reuses `UiItemSlot`/the spine `UiItemList` + a `UiScrollbar`
|
|
||||||
(Type 0xB, already built) + a `UiText` (already built) — no NEW widget. It is an
|
|
||||||
inventory-contents list (inventory deep-dive scope), not a doll.
|
|
||||||
|
|
||||||
## 7. Open questions / UNVERIFIED
|
|
||||||
|
|
||||||
- **`0x100001E0` = MissileAmmo `0x800000`** — LIKELY (the decomp immediate is
|
|
||||||
corrupted to a string pointer at line 173676; inferred from the EquipMask gap +
|
|
||||||
neighbors). Re-dump element `0x100001E0`'s position vs the ammo doll slot, or
|
|
||||||
re-decompile `0x004a388a` in Ghidra to recover the real immediate, to confirm.
|
|
||||||
- **The exact viewport camera/light immediates** (lines 175524-175526, 174144-174146)
|
|
||||||
— I read the hex but did not byte-convert all of them to floats; the paperdoll
|
|
||||||
brainstorm should decode `0x3df5c28f≈0.12`, `0xc019999a≈−2.4`, `0xc0400000=−3.0`,
|
|
||||||
`0xc059999a≈−3.4`, `0x3f6147ae≈0.88`, `0x3f800000=1.0` precisely for a faithful
|
|
||||||
framing. **UNVERIFIED.**
|
|
||||||
- **The UI↔3D render seam** (how a UI rect drives a scissored single-entity 3D pass,
|
|
||||||
and whether it draws after the world pass or as a UI overlay) — DESIGN-OPEN, to be
|
|
||||||
settled in brainstorm. Code-Structure Rule 2 means the seam is a Core interface
|
|
||||||
implemented in App. **UNVERIFIED.**
|
|
||||||
- **acdream's PlayerDescription equipped section** is not surfaced
|
|
||||||
(`PlayerDescriptionParser.cs:70-77`). To populate slot icons at login (vs only
|
|
||||||
reacting to later `ObjDescEvent`s), the parser must be extended to read the
|
|
||||||
`InventoryPlacement` equipped list. Filed as a dependency, not yet an issue.
|
|
||||||
- **Whether the doll clones the player `WorldEntity` or builds a fresh one** — retail
|
|
||||||
clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`, line
|
|
||||||
173999). acdream has no player `CPhysicsObj`-as-renderable today (the local player
|
|
||||||
isn't a `WorldEntity` in the per-instance adapter — it's the camera). LIKELY the
|
|
||||||
paperdoll builds a dedicated `WorldEntity` from the local player's
|
|
||||||
Setup+ObjDesc and feeds it to a private `EntitySpawnAdapter`-like host. **UNVERIFIED.**
|
|
||||||
- **`gm3DItemsUI` true role** — its `m_itemList` + "Contents of Backpack" text is
|
|
||||||
CONFIRMED, but whether retail ever shows 3D item models in it (the name suggests a
|
|
||||||
historical 3D-preview) — NOT FOUND any Viewport in its layout; treated as a 2D
|
|
||||||
contents list. If a 3D item preview surfaces elsewhere, revisit.
|
|
||||||
|
|
||||||
## 8. MEMORY.md index line
|
|
||||||
|
|
||||||
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
# Inventory panel deep-dive — `gmInventoryUI` + `gmBackpackUI`
|
|
||||||
|
|
||||||
**Date:** 2026-06-16
|
|
||||||
**Phase:** D.2b core-panels research (report-only). Sibling of the action-bar
|
|
||||||
and paperdoll deep-dives; builds on the `UIElement_UIItem` / icon / drag-drop
|
|
||||||
**spine** research (see §1 note). Answers handoff §3 questions **Q1** (this
|
|
||||||
panel's `LayoutDesc`), **Q7** (window layout), **Q8** (full inventory
|
|
||||||
wire-message set), **Q9** (icon rendering states).
|
|
||||||
|
|
||||||
## 1. Summary + confidence legend
|
|
||||||
|
|
||||||
The retail inventory window is two cooperating dat windows. **`gmInventoryUI`
|
|
||||||
(class `0x10000023`, `LayoutDesc 0x21000023`, 300×362)** is the OUTER frame: a
|
|
||||||
title bar, a chrome border, and three slots that host CHILD windows —
|
|
||||||
`gmPaperDollUI` (the equipped-gear doll), `gmBackpackUI` (the pack list), and
|
|
||||||
`gm3DItemsUI` (the 3D rotating-character viewport). **`gmBackpackUI` (class
|
|
||||||
`0x10000022`, `LayoutDesc 0x21000022`, 61×339)** is the left strip: a burden
|
|
||||||
**Meter** (Type 7) + a `%`-burden text label, the main-pack item grid
|
|
||||||
(`UIElement_ItemList` `0x10000031`), and the side-pack tab column (a second
|
|
||||||
`UIElement_ItemList`). Every cell in those grids is a `UIElement_UIItem`
|
|
||||||
(class `0x10000032`) — the shared spine widget. Items are server-spawned
|
|
||||||
**`ACCWeenieObject`** weenies; the client learns container contents from
|
|
||||||
`CreateObject (0xF745)` + `PlayerDescription (0x0013)` at login and from the
|
|
||||||
`0xF7B0` GameEvent family (`ViewContents 0x0196`, `InventoryPutObjInContainer
|
|
||||||
0x0022`, `WieldObject 0x0023`, …) thereafter; it manipulates them with
|
|
||||||
`0xF7B1` GameActions (`PutItemInContainer 0x0019`, `DropItem 0x001B`,
|
|
||||||
`GetAndWieldItem 0x001A`, the `Stackable*` family, `GiveObjectRequest 0x00CD`).
|
|
||||||
|
|
||||||
acdream already has the outbound builders for most actions
|
|
||||||
(`InventoryActions.cs`, `InteractRequests.cs`) and parsers for most inbound
|
|
||||||
events (`GameEvents.cs`), plus a live `ItemRepository`. The gaps are concrete
|
|
||||||
and enumerated in §4: a missing `DropItem`/`GetAndWieldItem`/`ViewContents`/
|
|
||||||
`NoLongerViewingContents` parser-or-builder, a 4th field on
|
|
||||||
`InventoryPutObjInContainer`, and `CreateObject` not yet extracting
|
|
||||||
`IconId`/`WeenieClassId`/`StackSize`/capacities.
|
|
||||||
|
|
||||||
> **Spine dependency.** The handoff said the SPINE agent's doc would live at
|
|
||||||
> `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
|
|
||||||
> At the time of writing **that file does NOT exist** (only the handoff
|
|
||||||
> `2026-06-16-action-bar-inventory-equipment-handoff.md` is present — verified
|
|
||||||
> by `Glob docs/research/2026-06-16-*.md`). I therefore derived the
|
|
||||||
> inventory-relevant `UIElement_UIItem` facts FIRST-HAND from the decomp and
|
|
||||||
> cite them here; where the spine doc later goes deeper (icon DBObj render,
|
|
||||||
> drag state machine), this doc should be read as the inventory-specific layer
|
|
||||||
> on top of it.
|
|
||||||
|
|
||||||
**Confidence legend:**
|
|
||||||
- **CONFIRMED** — quoted from a source I opened (decomp `class::method` + line,
|
|
||||||
or a real `file:line`).
|
|
||||||
- **LIKELY** — inferred from a confirmed source; the inference is named.
|
|
||||||
- **UNVERIFIED** — educated guess, flagged loudly; do not port without checking.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. LayoutDesc / element map (Q1, Q7)
|
|
||||||
|
|
||||||
### 2.1 `gmInventoryUI` — outer frame, `LayoutDesc 0x21000023` (300×362)
|
|
||||||
|
|
||||||
**CONFIRMED Q1.** `gmInventoryUI::Register` registers element class `0x10000023`:
|
|
||||||
> `gmInventoryUI::Register (decomp line 176285): UIElement::RegisterElementClass(0x10000023, gmInventoryUI::Create);`
|
|
||||||
|
|
||||||
The window is built from `LayoutDesc 0x21000023` (pre-dump
|
|
||||||
`.layout-dumps/inventory-0x21000023.txt`). The root element `0x100001CC`
|
|
||||||
(Type `268435491 = 0x10000023` = the gmInventoryUI class itself) is 300×362 at
|
|
||||||
ZLevel 1000. `gmInventoryUI::PostInit` (decomp 176236) resolves its named
|
|
||||||
children by id — these element ids match the dump 1:1, which is what confirms
|
|
||||||
the map:
|
|
||||||
|
|
||||||
| Dump element | X,Y,W,H | Type (resolved) | PostInit binds to | Role |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `0x100001CC` (root) | 0,0 300×362 | `0x10000023` gmInventoryUI | — | window root |
|
|
||||||
| `0x100001CD` | 0,23 224×214 | `0x10000024` (base `0x21000024`) | `m_paperDollUI` (DynamicCast `0x10000024`) | nested **PaperDoll** window |
|
|
||||||
| `0x100001CE` | 239,23 61×339 | `0x10000022` (base `0x21000022`) | `m_backpackUI` (DynamicCast `0x10000022`) | nested **Backpack** strip |
|
|
||||||
| `0x100001CF` | 0,237 234×120 | `0x10000021` (base `0x21000021`) | `m_3DItemsUI` (DynamicCast `0x10000021`) | nested **3D items** viewport |
|
|
||||||
| `0x100001D3` | 0,0 276×25 | base `0x21000191` | `m_titleText` (`GetChildRecursive`) | title bar ("Inventory of %s") |
|
|
||||||
| `0x100001D2` | 276,0 24×23 | base `0x10000... 0x21000192` | (button: chrome) | close/X button (states Normal/pressed) |
|
|
||||||
| `0x100001D1` | 0,361 300×1 | Type 3 (Field/chrome) | — | bottom rule line (sprite `0x06004D0B`) |
|
|
||||||
| `0x100001D0` | 0,0 300×362 | Type 3 (Field/chrome) | — | full-window backdrop (`0x06004D0A`, Alphablend) ZLevel 100 |
|
|
||||||
|
|
||||||
PostInit excerpt (CONFIRMED):
|
|
||||||
> `gmInventoryUI::PostInit (176240–176259): m_titleText = GetChildRecursive(this, 0x100001d3); … = GetChildRecursive(this, 0x100001cd)->DynamicCast(0x10000024) [paperdoll]; … 0x100001ce ->DynamicCast(0x10000022) [backpack]; … 0x100001cf ->DynamicCast(0x10000021) [3DItems];`
|
|
||||||
|
|
||||||
**Implication for the toolkit (LIKELY):** the inventory frame is mostly chrome
|
|
||||||
+ a title `UIElement_Text` + an X button — the real work is delegated to three
|
|
||||||
NESTED `LayoutDesc` windows. The importer already recurses generic containers,
|
|
||||||
but it has never instantiated a *nested gm\*UI window* (an element whose Type is
|
|
||||||
a high `0x10000xxx` game class with its own `BaseLayoutId`). This is the
|
|
||||||
"sub-window mount" gap (§6).
|
|
||||||
|
|
||||||
### 2.2 `gmBackpackUI` — pack strip, `LayoutDesc 0x21000022` (61×339)
|
|
||||||
|
|
||||||
**CONFIRMED Q1.** `gmBackpackUI::Register` (decomp 176531):
|
|
||||||
> `UIElement::RegisterElementClass(0x10000022, gmBackpackUI::Create);`
|
|
||||||
|
|
||||||
Built from `LayoutDesc 0x21000022` (pre-dump `.layout-dumps/backpack-0x21000022.txt`).
|
|
||||||
Root `0x100001C8` (Type `268435490 = 0x10000022`) is 61×339. `gmBackpackUI::PostInit`
|
|
||||||
(decomp 176596) binds the children — again matching the dump exactly:
|
|
||||||
|
|
||||||
| Dump element | X,Y,W,H | Type | PostInit binds to | Role |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `0x100001C8` (root) | 0,0 61×339 | `0x10000022` gmBackpackUI | — | window root |
|
|
||||||
| `0x100001D7` | 0,7 36×15 | base `0x10000376`/`0x2100003F` | — | "Burden" caption text |
|
|
||||||
| `0x100001D8` | 0,18 36×15 | base `0x10000376`/`0x2100003F` | `m_burdenText` | the `%`-load number text |
|
|
||||||
| `0x100001D9` | 44,8 11×58 | **7 (Meter)** | `m_burdenMeter` (DynamicCast 7) | **the burden bar** (vertical) |
|
|
||||||
| `0x100001C9` | 6,32 36×36 | `0x10000031` ItemList | `m_topContainer` (DynamicCast `0x10000031`) | main-pack first cell / list head |
|
|
||||||
| `0x100001CA` | 6,73 36×252 | `0x10000031` ItemList | `m_containerList` (DynamicCast `0x10000031`) | the **item grid** (main pack) |
|
|
||||||
| `0x100001CB` | 41,73 16×252 | base `0x10000... 0x2100003E` | — | side-pack tab column / scrollbar gutter |
|
|
||||||
|
|
||||||
PostInit excerpt (CONFIRMED):
|
|
||||||
> `gmBackpackUI::PostInit (176600–176629): m_burdenText = GetChildRecursive(this, 0x100001d8); m_burdenMeter = GetChildRecursive(0x100001d9)->DynamicCast(7); … m_topContainer = GetChildRecursive(0x100001c9)->DynamicCast(0x10000031); m_containerList = GetChildRecursive(0x100001ca)->DynamicCast(0x10000031);`
|
|
||||||
|
|
||||||
**The burden Meter (Q7 answer).** Element `0x100001D9` is the Type-7 meter the
|
|
||||||
backpack dump shows with back sprite `0x0600121C` (grandchild `0x00000002`) +
|
|
||||||
fill sprite `0x0600121D`. It is a VERTICAL 11×58 bar (the only meter in the
|
|
||||||
window) — confirmed by `gmBackpackUI::SetLoadLevel` writing it:
|
|
||||||
> `gmBackpackUI::SetLoadLevel (176565–176573): m_burdenMeter; …(float)arg2; var_10 = 0x69; UIElement::SetAttribute_Float();`
|
|
||||||
|
|
||||||
That is the SAME meter-fill mechanism as vitals (property `0x69` = fill ratio,
|
|
||||||
pushed at runtime — see `2026-06-15-layoutdesc-format.md §3`). The fill value
|
|
||||||
is `load × 0.3333…` clamped to [0,1] (CONFIRMED 176542:
|
|
||||||
`x87_r7_1 = arg2 * 0.33333333333333331`), and the text is formatted `%d%%`
|
|
||||||
from `floor(load × 300)` (CONFIRMED 176576–176583:
|
|
||||||
`floor(arg2 * 300.0)` → `SetText(m_burdenText, "%d%%")`). So the bar is FULL
|
|
||||||
at 100% load and the number reads 0–300% (retail's encumbrance scale: 100% =
|
|
||||||
your computed max burden, you can carry up to 300%).
|
|
||||||
|
|
||||||
> **Where is the VALUE total / coin total?** NOT in `gmBackpackUI` — there is
|
|
||||||
> no value Meter or value text element in `0x21000022`. The inventory window
|
|
||||||
> shows BURDEN only; the pyreal/coin total is the player's Coin Value displayed
|
|
||||||
> elsewhere (UNVERIFIED — likely a separate stat readout; the panel dump has
|
|
||||||
> no value field). Do not invent a value summary for this window.
|
|
||||||
|
|
||||||
**The side-pack list.** `m_containerList` (`0x100001CA`) is the main item grid;
|
|
||||||
`0x100001CB` is the narrow 16-wide column to its right (scrollbar gutter / tab
|
|
||||||
strip). The retail "side packs" (sub-bags) are opened as ADDITIONAL container
|
|
||||||
views — `gmInventoryUI::RecvNotice_OpenContainedContainer` (decomp 176290)
|
|
||||||
routes a contained-container open into a second `UIElement_ItemList`:
|
|
||||||
> `RecvNotice_OpenContainedContainer (176318): UIElement_ItemList::ItemList_OpenContainer(*(…+0x608), arg2, 1);`
|
|
||||||
> (offset `+0x604` = the main/own list; `+0x608` = the secondary/other-container list)
|
|
||||||
|
|
||||||
The two `UIElement_ItemList`s at member offsets `+0x604` and `+0x608` are the
|
|
||||||
"my main pack" list and the "currently-open other container" list — CONFIRMED
|
|
||||||
by the dual flush/open pattern in `RecvNotice_SetDisplayInventory`
|
|
||||||
(176114/176123/176141) and `RecvNotice_PlayerDescReceived` (176374/176375
|
|
||||||
`ItemList_SetChildList(+0x604, …); ItemList_SetChildList(+0x608, …)`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Container model for this panel (Q3 / cross-cutting, inventory slice)
|
|
||||||
|
|
||||||
**Items are server weenies (`ACCWeenieObject`).** CONFIRMED throughout the
|
|
||||||
inventory code: `ClientObjMaintSystem::GetWeenieObject(itemID)` is the only way
|
|
||||||
the panel resolves an item id to its data (e.g. `UIItem_Update` 230235,
|
|
||||||
`RecvNotice_OpenContainedContainer` 176293). This matches
|
|
||||||
`claude-memory/feedback_weenie_vs_static.md` (interactable items are
|
|
||||||
server-spawned weenies). [CONFIRMED]
|
|
||||||
|
|
||||||
**Container hierarchy = 2-deep.** A character has a main pack (capacity ~102) +
|
|
||||||
N side-packs (sub-bags); a side-pack cannot hold another side-pack. acdream's
|
|
||||||
`Container` model already encodes this (`ItemInstance.cs:154` `Container` with
|
|
||||||
`SidePacks` + `IsSidePack => SideCapacity == 0`). [CONFIRMED in acdream; the
|
|
||||||
2-deep rule is retail-standard and matches ACE]
|
|
||||||
|
|
||||||
**How the client learns contents:**
|
|
||||||
1. **At login** — `PlayerDescription (0x0013)` carries the player's full
|
|
||||||
inventory + equipped lists; acdream already registers both into
|
|
||||||
`ItemRepository` (`GameEventWiring.cs:405–432`). [CONFIRMED]
|
|
||||||
2. **Per-item spawn** — `CreateObject (0xF745)` for each visible weenie; for an
|
|
||||||
item in your pack the server sends the weenie (with `IconId`, capacities,
|
|
||||||
stack size in the WeenieHeader). acdream's `CreateObject.TryParse` extracts
|
|
||||||
guid/name/itemType but **discards IconId, WeenieClassId, StackSize, Value,
|
|
||||||
ItemCapacity, ContainerCapacity** (it `_ =`-skips the IconId at
|
|
||||||
`CreateObject.cs:516` and never reads StackSize/Value). [CONFIRMED gap]
|
|
||||||
3. **Open a container** — `ViewContents (0x0196)` lists `{guid, containerType}`
|
|
||||||
per slot; `gmInventoryUI` / `UIElement_ItemList` insert a `UIElement_UIItem`
|
|
||||||
per entry. [CONFIRMED on ACE/holtburger side; acdream has NO ViewContents
|
|
||||||
parser]
|
|
||||||
4. **Live moves** — `InventoryPutObjInContainer (0x0022)`, `WieldObject
|
|
||||||
(0x0023)`, `InventoryPutObjectIn3D (0x019A)` relocate one weenie;
|
|
||||||
`gmInventoryUI::RecvNotice_ServerSaysMoveItem` (176175) + the
|
|
||||||
`UIElement_ItemList` rebuild the affected cells. [CONFIRMED]
|
|
||||||
|
|
||||||
**The notice ids `gmInventoryUI::PostInit` registers (CONFIRMED 176269–176277)**
|
|
||||||
— these are the internal client notice opcodes (NOT wire opcodes) the window
|
|
||||||
listens to: `0x4dd1f0, 0x4dd1f1, 0x4dd1f2, 0x4dd1f6, 0x4dd266, 0x186ab,
|
|
||||||
0x186a8, 0x4dd25b, 0x4dd25d`. They map (via the vftable, 980257–980562) to
|
|
||||||
`RecvNotice_ItemAttributesChanged / ServerSaysMoveItem / EndPendingInPlayer /
|
|
||||||
ShowPendingInPlayer / OpenContainedContainer / NewParentContainer /
|
|
||||||
PlayerDescReceived / SetDisplayInventory / UpdateCharacterInformation`. These
|
|
||||||
are the controller hooks acdream's `InventoryController` (new, §6) must expose
|
|
||||||
to drive the live grid.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Wire-message catalog (Q8)
|
|
||||||
|
|
||||||
All client→server ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq;
|
|
||||||
u32 subOpcode; …`); all server→client item events ride the `0xF7B0` GameEvent
|
|
||||||
envelope (`u32 0xF7B0; u32 target; u32 seq; u32 eventOpcode; …`).
|
|
||||||
**ACE handler** = the file under
|
|
||||||
`ACE/Source/ACE.Server/Network/GameAction/Actions/` (C→S) or
|
|
||||||
`…/GameEvent/Events/` (S→C). **Chorizite/holtburger** field order verified;
|
|
||||||
where I cite holtburger it is `inventory/actions.rs` or `inventory/events.rs`
|
|
||||||
(both opened, with hex pack/unpack fixtures).
|
|
||||||
|
|
||||||
### 4.1 Client → server (GameActions, `0xF7B1`)
|
|
||||||
|
|
||||||
| Opcode | Name | Dir | Trigger | ACE handler | Field order (holtburger/ACE) | acdream parse status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up ground item (container = self) | `GameActionPutItemInContainer.Handle` | `u32 itemGuid, u32 containerGuid, i32 placement` | **parsed** — `InteractRequests.BuildPickUp` (`InteractRequests.cs:97`) |
|
|
||||||
| `0x001A` | GetAndWieldItem | C→S | equip an item from inventory onto the doll | (`GameActionType` 0x001A; handler `Player_Inventory`) | `u32 itemGuid, u32 equipMask` (holtburger `actions.rs:8` `GetAndWieldItemActionData`) | **MISSING** (no builder) |
|
|
||||||
| `0x001B` | DropItem | C→S | drop an item on the ground | `GameActionDropItem.Handle` | `u32 itemGuid` (holtburger `actions.rs:140`) | **MISSING** (no builder; acdream reuses 0x0019 for moves only) |
|
|
||||||
| `0x0035` | UseWithTarget | C→S | use src item on target (key→door) | (Interact) | `u32 sourceGuid, u32 targetGuid` | **parsed** — `InteractRequests.BuildUseWithTarget` |
|
|
||||||
| `0x0036` | UseItem | C→S | use/equip-by-doubleclick a single item | `GameActionUseItem` | `u32 targetGuid` | **parsed** — `InteractRequests.BuildUse` |
|
|
||||||
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableMerge` |
|
|
||||||
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` | `u32 stackGuid, u32 containerGuid, i32 place, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitToContainer` |
|
|
||||||
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` | `u32 stackGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitTo3D` |
|
|
||||||
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (e.g. arrows) | `GameActionStackableSplitToWield` | `u32 stackGuid, u32 equipMask, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitToWield` |
|
|
||||||
| `0x00CD` | GiveObjectRequest | C→S | give item (or N of a stack) to an NPC/player | `GameActionGiveObjectRequest.Handle` | `u32 targetGuid, u32 itemGuid, i32 amount` | **parsed** — `InventoryActions.BuildGiveObjectRequest` |
|
|
||||||
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | (`GameActionType` 0x0195) | `u32 containerGuid` (holtburger `actions.rs:280`) | **MISSING** (no builder) |
|
|
||||||
| `0x019C` | AddShortcut | C→S | pin to quickbar (toolbar phase, listed for completeness) | (`GameActionType`) | `u32 slot, u32 objType, u32 targetId` | **parsed** — `InventoryActions.BuildAddShortcut` |
|
|
||||||
| `0x019D` | RemoveShortcut | C→S | unpin quickbar slot | (`GameActionType`) | `u32 slot` | **parsed** — `InventoryActions.BuildRemoveShortcut` |
|
|
||||||
|
|
||||||
**Opcode source (CONFIRMED):** `ACE/.../GameAction/GameActionType.cs:13–76` —
|
|
||||||
`PutItemInContainer=0x0019, GetAndWieldItem=0x001A, DropItem=0x001B,
|
|
||||||
UseWithTarget=0x0035, StackableMerge=0x0054, StackableSplitToContainer=0x0055,
|
|
||||||
StackableSplitTo3D=0x0056, GiveObjectRequest=0x00CD, NoLongerViewingContents=0x0195,
|
|
||||||
StackableSplitToWield=0x019B`. ACE handler field order CONFIRMED by reading each
|
|
||||||
`GameAction*.Handle` (DropItem reads 1 u32; PutItemInContainer reads 3;
|
|
||||||
GiveObjectRequest reads 3; StackableMerge reads 3; SplitToContainer reads 4;
|
|
||||||
SplitTo3D reads 2). holtburger hex fixtures (`actions.rs` test module)
|
|
||||||
independently confirm every field layout.
|
|
||||||
|
|
||||||
> **acdream byte-order note:** `InteractRequests.BuildPickUp` writes `placement`
|
|
||||||
> as `i32` (`InteractRequests.cs:106`), matching ACE's `ReadInt32()`. The split
|
|
||||||
> builders write `amount`/`placement` as `u32` — on the wire identical bytes,
|
|
||||||
> but ACE reads them as `i32` (negative split amounts can't occur, so this is
|
|
||||||
> safe). [CONFIRMED, harmless]
|
|
||||||
|
|
||||||
### 4.2 Server → client (GameEvents, `0xF7B0`)
|
|
||||||
|
|
||||||
| Opcode | Name | Dir | Trigger | ACE handler | Field order | acdream parse status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| `0x0022` | InventoryPutObjInContainer | S→C | server confirms item now in container at slot | `GameEventItemServerSaysContainId` | `u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType` | **parsed (INCOMPLETE)** — `GameEvents.ParsePutObjInContainer` reads only 3 fields, **drops `containerType`** |
|
|
||||||
| `0x0023` | WieldObject | S→C | server confirms item equipped to slot | `GameEventWieldItem` | `u32 objectId, i32 equipMask` | **parsed + wired** — `GameEvents.ParseWieldObject`, `GameEventWiring.cs:231` |
|
|
||||||
| `0x0196` | ViewContents | S→C | full contents list of a container you opened | `GameEventViewContents` | `u32 containerGuid, u32 count, [u32 guid, u32 containerType]×count` | **MISSING** (no parser) |
|
|
||||||
| `0x019A` | InventoryPutObjectIn3D | S→C | server confirms item dropped to world | `GameEventItemServerSaysMoveItem` | `u32 objectGuid` | **parsed (UNWIRED)** — `GameEvents.ParsePutObjectIn3D` exists, not in `WireAll` |
|
|
||||||
| `0x00A0` | InventoryServerSaveFailed | S→C | reject a speculative client move (roll back) | `GameEventInventoryServerSaveFailed` | `u32 itemGuid, u32 weenieError` | **parsed (UNWIRED, INCOMPLETE)** — `GameEvents.ParseInventoryServerSaveFailed` reads only the guid, drops error (holtburger reads both: `events.rs:147`) |
|
|
||||||
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` | `u32 containerGuid` | **parsed (UNWIRED)** — `GameEvents.ParseCloseGroundContainer` exists, not in `WireAll` |
|
|
||||||
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (full property bundle) | `GameEventIdentifyObjectResponse` | `u32 guid, u32 flags, u32 success, …property tables…` | **parsed + wired** — `AppraiseInfoParser` via `GameEventWiring.cs:245` |
|
|
||||||
| `0xF745` | CreateObject (GameMessage, not GameEvent) | S→C | spawn a weenie (incl. an item in your pack) | `GameMessageCreateObject` → `WorldObject.SerializeCreateObject` | weenie header (Name, WeenieClassId, **IconId**, ItemType, …) + ModelData + PhysicsData | **parsed (INCOMPLETE)** — `CreateObject.TryParse` skips IconId/WeenieClassId/StackSize/Value/capacities |
|
|
||||||
| `SetStackSize` (`0x0197`/UIQueue) | SetStackSize | S→C | update a stack's count + value after merge/split | `GameMessageSetStackSize` | `u32 seq, u32 guid, u32 stackSize, u32 value` | **MISSING** (no parser) |
|
|
||||||
| `InventoryRemoveObject` (UIQueue) | InventoryRemoveObject | S→C | remove an item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` | `u32 guid` | **MISSING** (no parser) |
|
|
||||||
|
|
||||||
**Opcode + field-order sources (CONFIRMED):**
|
|
||||||
- `0x0022` four fields: `GameEventItemServerSaysContainId.cs:10–13` writes
|
|
||||||
`itemGuid, containerGuid, PlacementPosition, ContainerType`; holtburger
|
|
||||||
`events.rs:65` reads `item_guid, container_guid, slot, container_type`
|
|
||||||
(+ hex fixture `events.rs:217` slot=3 type=1). acdream's parser
|
|
||||||
(`GameEvents.cs:352`) stops after 3 u32s — `containerType` is dropped.
|
|
||||||
- `0x0196` shape: `GameEventViewContents.cs:13–26` writes `Guid, count, {guid,
|
|
||||||
containerType}×n`; holtburger `events.rs:20` (+ fixture `events.rs:195`).
|
|
||||||
- `0x0023`: `GameEventWieldItem.cs:11–12` writes `objectId, (int)newLocation`.
|
|
||||||
- `0x019A`: `GameEventItemServerSaysMoveItem.cs:11` writes only `Guid`.
|
|
||||||
- `0x00A0`: `GameEventInventoryServerSaveFailed.cs` (error code present;
|
|
||||||
holtburger reads it).
|
|
||||||
- `SetStackSize`: `GameMessageSetStackSize.cs:12–15` (`seq, guid, stackSize,
|
|
||||||
value`).
|
|
||||||
- `InventoryRemoveObject`: `GameMessageInventoryRemoveObject.cs:11` (`guid`).
|
|
||||||
|
|
||||||
### 4.3 acdream wire gaps (concrete TODO list for the build session)
|
|
||||||
|
|
||||||
- **Add C→S builders:** `DropItem (0x001B)`, `GetAndWieldItem (0x001A)`,
|
|
||||||
`NoLongerViewingContents (0x0195)`. (Equip + drop are core inventory verbs.)
|
|
||||||
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize`,
|
|
||||||
`InventoryRemoveObject`.
|
|
||||||
- **Fix `ParsePutObjInContainer`** to read the 4th `containerType` u32.
|
|
||||||
- **Fix `ParseInventoryServerSaveFailed`** to read the `weenieError` u32.
|
|
||||||
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
|
|
||||||
`InventoryPutObjectIn3D`, `CloseGroundContainer`, `InventoryServerSaveFailed`
|
|
||||||
(parsers exist or will, but `WireAll` doesn't register them today —
|
|
||||||
CONFIRMED `GameEventWiring.cs` registers only `WieldObject`,
|
|
||||||
`InventoryPutObjInContainer`, `IdentifyObjectResponse`, `PlayerDescription`).
|
|
||||||
- **Extend `CreateObject.TryParse`** to capture `IconId` (already in the wire,
|
|
||||||
currently `_`-discarded at `CreateObject.cs:516`), `WeenieClassId`,
|
|
||||||
`StackSize`, `Value`, `ItemCapacity`, `ContainerCapacity` — the inventory
|
|
||||||
cell needs all of these to draw an icon + quantity + capacity bar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Drag-drop for inventory (Q5, this panel's slice)
|
|
||||||
|
|
||||||
The drag-drop machinery lives on `UIElement_UIItem` (the spine widget). The
|
|
||||||
inventory-relevant parts I confirmed first-hand:
|
|
||||||
|
|
||||||
- **A slot accepts a drop** via `UIElement_UIItem::SetDragAcceptState(state)`,
|
|
||||||
toggling the `m_elem_Icon_DragAccept` sub-element's STATE
|
|
||||||
(`0x10000040` = reject / `0x10000041` = accept; CONFIRMED
|
|
||||||
`SetDragAcceptState` 229271–229277, and call sites at 174307/174313,
|
|
||||||
201327/201333 flip between the two). [CONFIRMED]
|
|
||||||
- **A drag in progress** uses `m_dragIcon` (a translucent copy of the icon,
|
|
||||||
created in `PostInit` 229738–229740 via `UIElementManager::CreateChildElement`
|
|
||||||
with id `0x10000345`, `SetVisible(0)` until a drag starts). [CONFIRMED]
|
|
||||||
- **The drop RESULT is a wire action**, chosen by source→destination:
|
|
||||||
inventory→pack slot = `PutItemInContainer (0x0019)`; inventory→doll =
|
|
||||||
`GetAndWieldItem (0x001A)`; inventory→ground = `DropItem (0x001B)`;
|
|
||||||
stack→compatible stack = `StackableMerge (0x0054)`; partial-stack drag =
|
|
||||||
one of the `StackableSplit*` (the count picker dialog supplies `amount`);
|
|
||||||
item→NPC = `GiveObjectRequest (0x00CD)`. [LIKELY — inferred from the action
|
|
||||||
set in §4 + the ACE handler names; the exact source/dest→opcode table is the
|
|
||||||
spine doc's job, but these are the inventory verbs]
|
|
||||||
- **Speculative-then-confirm:** the client may move the cell locally and wait;
|
|
||||||
if the server rejects, `InventoryServerSaveFailed (0x00A0)` rolls it back
|
|
||||||
(the slot's pending/ghost state is `SetWaitingState` → `m_elem_Icon_Ghosted`
|
|
||||||
greys it; CONFIRMED `SetWaitingState` 229190–229208 toggles
|
|
||||||
`m_elem_Icon_Ghosted` visibility). acdream's `ItemRepository` already
|
|
||||||
documents this revert path (`ItemRepository.cs:30`). [CONFIRMED mechanism]
|
|
||||||
|
|
||||||
For acdream's toolkit, the drop target is a `UiItemSlot` (§6) that reports a
|
|
||||||
drop to the `InventoryController`, which picks the opcode and sends it via
|
|
||||||
`LiveCommandBus` + the builders in §4 — mirroring the existing interaction
|
|
||||||
pipeline (`claude-memory/project_interaction_pipeline.md`, B.4
|
|
||||||
WorldPicker→Use). The `UiRoot` already has drag-drop input plumbing
|
|
||||||
(per `project_d2b_retail_ui.md`: "UiRoot already has full input
|
|
||||||
(focus/capture/drag-drop/tooltip/click) — dormant until wired").
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. New toolkit widgets this introduces
|
|
||||||
|
|
||||||
The inventory panel needs four new pieces beyond the shipped spine widgets
|
|
||||||
(Button/Menu/Meter/Scrollbar/Text/Field/UiDatElement):
|
|
||||||
|
|
||||||
| Widget | dat Type it registers at | Leaf or container | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **`UiItemSlot`** (port of `UIElement_UIItem`) | **`0x10000032`** (`UIElement_UIItem::Register` line 229339); resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3** | **leaf** (`ConsumesDatChildren=>true`) — it owns the icon + all overlay sub-elements (`m_elem_Icon` `0x1000033b`, `m_elem_Icon_Overlays` `…33c`, `m_elem_Icon_Selected` `…342`, `m_elem_Icon_Ghosted` `…349`, `m_elem_Icon_Quantity` `…4f5`, `m_elem_Icon_CapacityBar` `…347`/`StructureBar` `…348` Type-7 meters, cooldown ring `…54f–558`) and reproduces them procedurally | one item-in-a-slot: icon + quantity + capacity/structure bars + selection/ghost/drag-accept/open-container overlays. **Shared by all 3 panels.** *(This is the spine widget; named here for the inventory's needs.)* |
|
|
||||||
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`) | **`0x10000031`** (`UIElement_ItemList`; the backpack root element is itself this class) | **container** of `UiItemSlot`s (it lays out an N-column grid + scroll) | the main-pack grid + the side-pack list. Methods to port: `ItemList_AddItem`, `ItemList_InsertItem`, `ItemList_Flush`, `ItemList_OpenContainer`, `ItemList_SetChildList`, `ItemList_SetParentContainer`, `ItemList_OpenFirstContainer` (all CONFIRMED as called from `gmInventoryUI`/`gmBackpackUI`). Two instances per backpack (own list `+0x604`, other-container list `+0x608`). |
|
|
||||||
| **Sub-window mount** (importer capability, not a widget per se) | element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` (e.g. `0x100001CD`→paperdoll `0x21000024`) | container | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot (paperdoll + backpack + 3DItems inside the inventory frame). The importer recurses generic children today but has never mounted another gm\*UI window. |
|
|
||||||
| **Window manager** (the deferred Plan-2 piece) | drives Dragbar (Type 2) + Resizebar (Type 9) + open/close/z-order/persist | infra | inventory/paperdoll/toolbar are pop-up windows; needs the faithful grip/dragbar drag (today vitals/chat use whole-window drag, accepted IA-12 approximation). |
|
|
||||||
|
|
||||||
Plus a thin **`InventoryController`** (the `gmInventoryUI::PostInit` analogue):
|
|
||||||
find-by-id binds `m_titleText`/`m_paperDollUI`/`m_backpackUI`/`m_3DItemsUI`,
|
|
||||||
subscribes to `ItemRepository` events, and exposes the notice hooks
|
|
||||||
(`ServerSaysMoveItem`, `SetDisplayInventory`, `OpenContainedContainer`,
|
|
||||||
`PlayerDescReceived`) — exactly mirroring `VitalsController`/`ChatWindowController`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Open questions / UNVERIFIED
|
|
||||||
|
|
||||||
1. **Value/coin total in the window.** No value Meter or value text exists in
|
|
||||||
`0x21000022` or `0x21000023`. Retail likely shows pyreals elsewhere (the
|
|
||||||
coin readout). **UNVERIFIED** — do not add a value summary to this window
|
|
||||||
without finding its real home.
|
|
||||||
2. **Side-pack tabs vs. a single scrolling list.** Element `0x100001CB` (16×252,
|
|
||||||
base `0x2100003E`) is the narrow column right of the grid. Whether it renders
|
|
||||||
side-pack TABS (one per sub-bag) or a SCROLLBAR is **UNVERIFIED** — I read the
|
|
||||||
geometry + the dual-ItemList open pattern but did not decode `0x2100003E`.
|
|
||||||
Dump `0x2100003E` to settle it.
|
|
||||||
3. **`UIElement_ItemList` grid geometry** (columns, cell pitch). The cell
|
|
||||||
template is 36×36 (from `0x100001C9`); UIElement_UIItem `0x21000037` is 32×32
|
|
||||||
per the handoff. The exact column count + wrap is in `ItemList_AddItem` /
|
|
||||||
`ItemList_SetChildList` (not fully read here). **LIKELY** a fixed-column grid;
|
|
||||||
confirm by reading `UIElement_ItemList::ItemList_AddItem`.
|
|
||||||
4. **`CreateObject` IconId for pack items.** I confirmed the IconId is on the
|
|
||||||
wire and currently discarded, but did not byte-trace that ACE actually sets
|
|
||||||
IconId on a *contained* (non-visible-in-3D) item's CreateObject vs. relying on
|
|
||||||
PlayerDescription. **LIKELY** present (the spine icon path needs it); verify
|
|
||||||
against a live capture before trusting it as the sole icon source.
|
|
||||||
5. **The icon composite layering** (underlay/base/effects-overlay) — I anchored
|
|
||||||
it from `IconData::IconData` (407532+) and the cache key (408842): underlay =
|
|
||||||
`pwd._iconUnderlayID` OR type-default `GetByEnum(0x10000004,
|
|
||||||
LowestSetBit(itemType)+1)`; base = `m_idIcon`; effects overlay =
|
|
||||||
`GetByEnum(0x10000005, LowestSetBit(_effects)+1)` (default `0x21`). The exact
|
|
||||||
blend/DBObj-render is the **spine doc's** territory — treat my §5/§6 citations
|
|
||||||
as the inventory-state hooks, not the full render port. [CONFIRMED anchors,
|
|
||||||
render detail deferred to spine]
|
|
||||||
6. **Q9 identified-vs-unidentified state.** Retail does NOT gate the icon on
|
|
||||||
appraise-state; the underlay/overlay come from the weenie's own
|
|
||||||
`_iconUnderlayID`/`_iconOverlayID`/`_effects` (server-sent), and "unidentified"
|
|
||||||
shows the same icon (the tooltip detail is what's gated by appraise, via
|
|
||||||
`IdentifyObjectResponse`). **LIKELY** (no identified→icon-swap code seen in
|
|
||||||
`UIItem_Update`); the only icon-affecting client states are
|
|
||||||
selected/waiting(ghost)/open-container/drag-accept (all §5). Confirm there's
|
|
||||||
no appraise-gated icon variant before claiming it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. MEMORY.md index line
|
|
||||||
|
|
||||||
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.
|
|
||||||
|
|
@ -1,557 +0,0 @@
|
||||||
# UI item-slot SPINE — icon-composite render + widget-level drag-drop — deep dive
|
|
||||||
|
|
||||||
**Date:** 2026-06-16
|
|
||||||
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only.
|
|
||||||
**Role:** completes the workflow's MISSING 5th doc — the shared item-slot/icon/drag-drop
|
|
||||||
**spine** that the action-bar, inventory, and paperdoll deep-dives all depend on. The
|
|
||||||
spine agent died on a transient API error before writing anything; this doc is the
|
|
||||||
recovery + the gap-fill.
|
|
||||||
**Deliverable:** this doc only. No C# changed; no game run.
|
|
||||||
|
|
||||||
> ## What this doc adds vs. the four existing docs
|
|
||||||
> The three panel agents + the synthesis already recovered the **identity** facts of the
|
|
||||||
> two spine widgets first-hand and re-verified them (synthesis §0 re-verifications,
|
|
||||||
> §1 table, §2). I do **not** re-derive those — I cite and extend them. My NEW,
|
|
||||||
> spine-owned contributions are the three things the panel docs explicitly deferred:
|
|
||||||
> 1. **The icon-composite render port spec** (synthesis §4 Step 0, §5 risk #1) — the
|
|
||||||
> full `IconData::RenderIcons` blit pipeline, and the definitive answer to the
|
|
||||||
> direct-RenderSurface-vs-Icon-composite decode question.
|
|
||||||
> 2. **The widget-level drag-drop state machine** (synthesis §5 risk #1, §8) — the
|
|
||||||
> `UIElement_Field`/`UIElement_UIItem` hooks every cell inherits, below the per-panel
|
|
||||||
> `HandleDropRelease` the panel docs covered.
|
|
||||||
> 3. **The consolidated, authoritative `UIElement_UIItem` port spec** with the resolved
|
|
||||||
> field names — including the **`+0x5FC` resolution** (synthesis §5 risk #2): it is
|
|
||||||
> `UIElement_UIItem::itemID`.
|
|
||||||
>
|
|
||||||
> **Obsoletes in the synthesis** (the parent should patch these now that the spine
|
|
||||||
> exists): the ⚠ banner (synthesis lines 13-31), §4 Step 0's "re-do / complete the
|
|
||||||
> spine research (blocking)", §5 risk #1 (spine never written), §5 risk #2's "stays
|
|
||||||
> UNVERIFIED", §6's "⚠ the SPINE doc was never written", §8's blocking note, and the
|
|
||||||
> two panel-doc index lines' "spine still owed" caveats. Details in the closing
|
|
||||||
> summary.
|
|
||||||
|
|
||||||
## 1. Summary + confidence legend
|
|
||||||
|
|
||||||
Every item-bearing slot in all three D.2b panels is the same pair of retail widgets:
|
|
||||||
the **item-cell** `UIElement_UIItem` (element class `0x10000032`) sits inside a
|
|
||||||
**slot/grid** `UIElement_ItemList` (element class `0x10000031`). The cell holds a bound
|
|
||||||
object id (`itemID`), resolves it to an `ACCWeenieObject`, and draws a composited 32×32
|
|
||||||
icon plus a stack of overlay sub-elements (quantity text, capacity/structure Type-7
|
|
||||||
meters, a 10-step cooldown ring, selected/ghosted/open-container/drag-accept/sell/trade
|
|
||||||
overlays). The icon itself is **composited at runtime from up to five `0x06xx`
|
|
||||||
RenderSurfaces** (base + custom-underlay + custom-overlay + item-type-default-underlay +
|
|
||||||
spell-effect-overlay) blitted into one private 32×32 surface — NOT a single texture.
|
|
||||||
Drag-drop is a generic chain inherited from `UIElement_Field`: the cell is both a
|
|
||||||
drag-SOURCE (`ItemList_BeginDrag` on left-press-and-move) and a drop-TARGET
|
|
||||||
(`MouseOverTop` rollover → accept/reject state, `CatchDroppedItem` on release →
|
|
||||||
`HandleDropRelease`), with `InqDropIconInfo` extracting the dragged object id + flags
|
|
||||||
that tell a fresh-from-inventory drag (`flags&0xE==0`) from a within-list reorder
|
|
||||||
(`flags&4`).
|
|
||||||
|
|
||||||
**acdream is well-positioned:** `ItemInstance` already models `IconId`/`IconUnderlayId`/
|
|
||||||
`IconOverlayId`/`StackSize`/`ContainerId`/`ContainerSlot`; `TextureCache.
|
|
||||||
GetOrUploadRenderSurface` already decodes a `0x06` id directly; `UiRoot` already has a
|
|
||||||
real drag-drop state machine (`DragSource`/`DragPayload`/`BeginDrag`/`UpdateDragHover`/
|
|
||||||
`FinishDrag`, even commented with the retail `0x15→0x21→0x1C→0x3E` event chain). The
|
|
||||||
concrete gaps: `CreateObject` discards `IconId`; there is no multi-layer icon-compositor;
|
|
||||||
`UiField` names the `CatchDroppedItem`/`MouseOverTop` hooks in a doc-comment but does not
|
|
||||||
implement them yet.
|
|
||||||
|
|
||||||
**Confidence legend:**
|
|
||||||
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
|
|
||||||
`file:line` I opened this session.
|
|
||||||
- **LIKELY** — inferred from a CONFIRMED source; the inference is named.
|
|
||||||
- **UNVERIFIED** — educated guess, flagged loudly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `UIElement_UIItem` port spec (consolidated + authoritative)
|
|
||||||
|
|
||||||
### 2.1 Identity + the resolved struct (`+0x5FC` = `itemID`)
|
|
||||||
|
|
||||||
`UIElement_UIItem::Register` (decomp 229339):
|
|
||||||
`UIElement::RegisterElementClass(0x10000032, UIElement_UIItem::Create);` — class
|
|
||||||
`0x10000032`. It is a `UIElement_Field` subclass: the destructor chains
|
|
||||||
`UIElement_Field::~UIElement_Field(this)` (decomp 229326), and `Field::Register` is
|
|
||||||
`RegisterElementClass(3, …)` (decomp 126190) ⇒ the underlying generic Type is **3**.
|
|
||||||
CONFIRMED.
|
|
||||||
|
|
||||||
**`+0x5FC` RESOLVED — it is `UIElement_UIItem::itemID`.** The toolbar doc anchored the
|
|
||||||
bound object id by raw offset `+0x5FC` only (toolbar §3, UNVERIFIED name). The named
|
|
||||||
decomp resolves it: `UIItem_Update` reads `uint32_t itemID = this->itemID;` (decomp
|
|
||||||
230230) and `this->weenObj = ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp
|
|
||||||
230235). `HandleTargetedUseLeftClick` reads `uint32_t itemID = arg2->itemID;` (decomp
|
|
||||||
230422). `ItemList_AddItem`'s rebuild loop tests `eax_2->itemID == arg2` (decomp 233107).
|
|
||||||
So the field the toolbar's `RemoveShortcutInSlotNum` read at `+0x5FC` is **`itemID`** —
|
|
||||||
the bound weenie/object guid. CONFIRMED. (The companion spell-shortcut id is
|
|
||||||
`this->spellID`, decomp 230239/230414.)
|
|
||||||
|
|
||||||
**Resolved instance fields** (all CONFIRMED from `UIItem_Update` 230226-230393,
|
|
||||||
`UIItem_SetIcon` 230143, `PostInit` 229668, `SetShortcutNum` 229465, the setters
|
|
||||||
229190-229286, and the `acclient.h` `IconData`/`PublicWeenieDesc` structs):
|
|
||||||
|
|
||||||
| Field | Meaning | Anchor |
|
|
||||||
|---|---|---|
|
|
||||||
| `itemID` | bound object/weenie guid (the retail `+0x5FC`) | 230230 |
|
|
||||||
| `spellID` | spell-shortcut id (0 for an item) | 230239, 230414 |
|
|
||||||
| `weenObj` | cached `ACCWeenieObject*` from `GetWeenieObject(itemID)` | 230235 |
|
|
||||||
| `selected` | mirror of `weenObj->selected` | 230269 |
|
|
||||||
| `effects` | mirror of `weenObj->pwd._effects` | 230293 |
|
|
||||||
| `waiting` | mirror of `weenObj->waiting` (the pending/ghost flag) | 230336 |
|
|
||||||
| `isOpenable`/`isContainer`/`isContainerHolder` | container-capability flags from `_bitfield`/`_itemsCapacity`/`_containersCapacity` | 230298-230331 |
|
|
||||||
| `m_quantity` | stack count to display | 229285 |
|
|
||||||
| `m_selectable` | whether selection is allowed | 229266 |
|
|
||||||
| `unghostable` | suppress the ghost overlay | 229199 |
|
|
||||||
| `m_shortcutNum` / `m_shortcutGhosted` / `m_delayedShortcutNum` | toolbar slot index + deferred-bind sentinel `0xFFFFFFFF` | 229542-229543, 230344-230349 |
|
|
||||||
| `m_sellState` / `m_tradeState` | vendor-sell / trade-window markers | 230362, 230377 |
|
|
||||||
| `m_dragIcon` | translucent drag-ghost copy (created in PostInit, id `0x10000345`) | 229738 |
|
|
||||||
|
|
||||||
### 2.2 Sub-element id map (from `PostInit`, decomp 229672-229733) — all CONFIRMED
|
|
||||||
|
|
||||||
`PostInit` binds each overlay/feature sub-element by `GetChildRecursive(this, id)`. These
|
|
||||||
ids live in the cell template `LayoutDesc 0x21000037`; the importer must reproduce them
|
|
||||||
procedurally (the cell is a behavioral leaf). The dump `.layout-dumps/uiitem-0x21000037.txt`
|
|
||||||
gives the per-state sprite ids (column 3 below).
|
|
||||||
|
|
||||||
| Member | Element id | Type | Role | Dump sprite(s) (state → 0x06id) |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `m_elem_Icon` | `0x1000033B` | 3 | the composited icon, AND the empty-slot bg | `ItemSlot_Empty → 0x060074CF` (dump:45) |
|
|
||||||
| `m_elem_Icon_Overlays` | `0x1000033C` | — | enchantment/effect overlay layer | (state-driven; see §3) |
|
|
||||||
| `m_elem_Text` | `0x10000344` | 12 (Text) | spell name / label text | — |
|
|
||||||
| `m_elem_Icon_CapacityBar` | `0x10000347` | 7 (Meter) | container fill (numContained/itemsCapacity) | `DirectState 0x06004D22`+`0x06004D23` (dump:693,710) |
|
|
||||||
| `m_elem_Icon_StructureBar` | `0x10000348` | 7 (Meter) | structure/charges fill | `DirectState 0x06004D24`+`0x06004D25` (dump:727,744) |
|
|
||||||
| `m_elem_Icon_Selected` | `0x10000342` | 3 | selection highlight | `0x06001A97 / 0x06001396 / 0x060067D2` per variant (dump:95,311,541) |
|
|
||||||
| `m_elem_Icon_Ghosted` | `0x10000349` | 3 | greyed "pending server confirm" overlay | `DirectState 0x0600109A` (dump:761) |
|
|
||||||
| `m_elem_Icon_ShortcutNum` | `0x1000034A` | 3 | the slot-number badge (toolbar) | media set at runtime via `SetMediaImage` (229508) |
|
|
||||||
| `m_elem_Icon_SellState` | `0x10000437` | 3 | vendor-sell marker | — |
|
|
||||||
| `m_elem_Icon_TradeState` | `0x10000438` | 3 | trade-window marker | — |
|
|
||||||
| `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) |
|
|
||||||
| `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) |
|
|
||||||
| `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — |
|
|
||||||
| `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | `DirectState 0x0600109D / 0x060012D9 / 0x06001DAE / 0x060067CF..D1 …` (dump:778-863) |
|
|
||||||
| `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) |
|
|
||||||
|
|
||||||
**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept`
|
|
||||||
(from the dump): `ItemSlot_Empty` (the empty-slot background sprite, default
|
|
||||||
`0x060074CF`), `ItemSlot_DragOver_Accept` (`0x060011F9`), `ItemSlot_DragOver_Reject`
|
|
||||||
(`0x060011F8`), `ItemSlot_DragOver_DropIn` (`0x060011F7`). The DragAccept neutral/reset
|
|
||||||
**UIStateId** is `0x1000003f`; the inventory agent's `0x10000040`(reject)/`0x10000041`
|
|
||||||
(accept) SetState ids (synthesis §0 re-verification, decomp 229180-229413) are the
|
|
||||||
internal element states `SetDragAcceptState` writes — both are real; the LayoutDesc named
|
|
||||||
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
|
|
||||||
the C++ side. CONFIRMED.
|
|
||||||
|
|
||||||
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
|
|
||||||
|
|
||||||
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound
|
|
||||||
weenie or its display state changes. Walk-through (CONFIRMED 230226-230392):
|
|
||||||
1. Resolve `weenObj = GetWeenieObject(itemID)` (230235). If null & has a spellID →
|
|
||||||
`UIItem_SetState(0x1000001d)` + `UIItem_SetIcon`; if null & no spell →
|
|
||||||
`UIItem_SetState(0x1000001c)` (= empty) + `ClearTooltip`. (230232-230250)
|
|
||||||
2. Set `m_elem_Icon` / `m_elem_Text` / `m_elem_Icon_Overlays` to state `0x1000001d`
|
|
||||||
(= occupied). (230256-230265)
|
|
||||||
3. **`UIItem_SetIcon(this)`** — (re)build the composited icon (§3). (230268)
|
|
||||||
4. Sync `selected` ↔ `weenObj->selected`, toggling `m_elem_Icon_Selected` visibility
|
|
||||||
(gated on `m_selectable`). (230269-230290)
|
|
||||||
5. Recompute `isOpenable`/`isContainer`/`isContainerHolder` from
|
|
||||||
`_bitfield`/`_itemsCapacity`/`_containersCapacity` (the player's own cell is always
|
|
||||||
openable). (230298-230331)
|
|
||||||
6. `UpdateCapacityDisplay` (Type-7 meter = numContained/itemsCapacity, decomp 229554-),
|
|
||||||
`UpdateStructureDisplay`, `UpdateQuantityDisplay`, `UpdateCooldownDisplay`.
|
|
||||||
(230332-230335)
|
|
||||||
7. Sync `waiting` → `SetWaitingState` (toggles `m_elem_Icon_Ghosted`). (230336-230342)
|
|
||||||
8. Apply any deferred `m_delayedShortcutNum` (re-bind once the weenie loaded). (230344-230350)
|
|
||||||
9. Sync `m_shortcutNum`/`m_shortcutGhosted` (230352-230360), `m_sellState`/`m_tradeState`
|
|
||||||
overlays (230362-230389), then `UpdateTooltip`. (230392)
|
|
||||||
|
|
||||||
Companion methods (CONFIRMED): `UIItem_SetIcon` 230143 (§3); `SetShortcutNum(slot,
|
|
||||||
ghosted)` 229465 (writes the slot badge via `SetMediaImage`, mirrors into
|
|
||||||
`ACCWeenieObject::SetShortcutNum`); `SetDelayedShortcutNum` 229238; `SetWaitingState`
|
|
||||||
229190; `SetSelectedState` 229243; `SetSelectableState` 229263; `SetDragAcceptState`
|
|
||||||
229271; `SetOpenContainerState` 229216; `SetQuantity` 229282; `UpdateCapacityDisplay`
|
|
||||||
229554.
|
|
||||||
|
|
||||||
### 2.4 acdream item-cell port = `UiItemSlot`
|
|
||||||
|
|
||||||
A behavioral **leaf** widget (`ConsumesDatChildren => true`) keyed off resolved class
|
|
||||||
`0x10000032`, exactly like the shipped behavioral widgets. It binds an `ItemInstance`
|
|
||||||
(by `itemID`), draws the composited icon (§3), the quantity `UiText`, the capacity/
|
|
||||||
structure `UiMeter`s, the cooldown ring, and the overlay states; it is a drag source +
|
|
||||||
drop target (§5). This aligns with the synthesis §2 row (no correction). The retail
|
|
||||||
sub-element ids in §2.2 become the named child slots the controller toggles.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Icon rendering pipeline — THE CRUX
|
|
||||||
|
|
||||||
### 3.1 The decode question, answered definitively
|
|
||||||
|
|
||||||
**Both halves of the synthesis's question are true, layered:** each icon LAYER is a
|
|
||||||
`0x06xx` **RenderSurface decoded directly** (the D.2b memory's `GetOrUploadRenderSurface`
|
|
||||||
path), but the **on-screen icon is a runtime COMPOSITE of up to five of those layers**
|
|
||||||
blitted into one private 32×32 surface. It is NOT a single weenie texture, and it is NOT
|
|
||||||
an "Icon DBObj type that references other surfaces" — there is no Icon DBObj; the
|
|
||||||
composite logic lives entirely in client code (`IconData::RenderIcons`), and every input
|
|
||||||
id is a plain RenderSurface.
|
|
||||||
|
|
||||||
**Proof chain (all CONFIRMED):**
|
|
||||||
- `UIElement_UIItem::UIItem_SetIcon` (decomp 230171) sets the cell's image from
|
|
||||||
`ACCWeenieObject::GetIcon(weenObj)`:
|
|
||||||
`eax_15 = Graphic::Graphic(eax_13, ACCWeenieObject::GetIcon(eax_12)); … UIRegion::SetImage(this->m_elem_Icon, eax_15);`
|
|
||||||
- `ACCWeenieObject::GetIcon` (decomp 408999): `return ACCWeenieObject::GetIconData(this)->m_pIcon;`
|
|
||||||
- `ACCWeenieObject::GetIconData` (decomp 408224) caches a per-object `IconData` (hash by
|
|
||||||
guid), constructing one via `IconData::IconData(eax_4, this, this->id)` (408253) on
|
|
||||||
first use; `IconData::IconData` calls `IconData::RenderIcons(this, arg2)` (407957).
|
|
||||||
- The `IconData` struct (`acclient.h:54112`, verbatim): `m_idIcon`, `m_idCustomOverlay`,
|
|
||||||
`m_idCustomUnderlay`, `m_itemType`, `m_effects`, `Graphic *m_pIcon`, `Graphic *m_pDragIcon`.
|
|
||||||
|
|
||||||
The base id is the weenie's `_iconID`: `ACCWeenieObject::InqIconID` (decomp 406951)
|
|
||||||
returns `this->pwd._iconID.id`. `_iconID`/`_iconOverlayID`/`_iconUnderlayID` are all
|
|
||||||
`IDClass<_tagDataID,32,0>` in `PublicWeenieDesc` (`acclient.h:37168-37170`). CONFIRMED.
|
|
||||||
|
|
||||||
**Every layer is DBObj type `0xc`** — `RenderIcons` fetches each with
|
|
||||||
`DBObj::Get(QualifiedDataID(&v, id, 0xc))` (decomp 407587/407589/407592). DBObj type
|
|
||||||
`0xc` = `DB_TYPE_RENDERSURFACE` = `Texture` in ACE's `DatFileType` enum, id range
|
|
||||||
`0x06000000-0x07FFFFFF` (`references/.../ACE.DatLoader/DatFileType.cs:127-128`). So all
|
|
||||||
five ids are `0x06xx` RenderSurfaces — **decode each via
|
|
||||||
`TextureCache.GetOrUploadRenderSurface`** per the D.2b memory gotcha, NOT `GetOrUpload`
|
|
||||||
(feeding a `0x06` id to `GetOrUpload` walks the Surface→SurfaceTexture chain and returns
|
|
||||||
1×1 magenta — `TextureCache.cs:112-128`, `project_d2b_retail_ui.md` "Dat sprites — the
|
|
||||||
decode path"). CONFIRMED.
|
|
||||||
|
|
||||||
### 3.2 The composite — `IconData::RenderIcons` (decomp 407524), CONFIRMED
|
|
||||||
|
|
||||||
`RenderIcons` builds TWO graphics: `m_pDragIcon` (the drag-ghost, no underlay) and
|
|
||||||
`m_pIcon` (the full slot icon). Field captures first (407528-407532):
|
|
||||||
```
|
|
||||||
m_idIcon = InqIconID() # = pwd._iconID (base)
|
|
||||||
m_idCustomOverlay = pwd._iconOverlayID # server "enchanted" overlay
|
|
||||||
m_idCustomUnderlay= pwd._iconUnderlayID # server "magic" underlay
|
|
||||||
m_itemType = InqType()
|
|
||||||
m_effects = pwd._effects
|
|
||||||
```
|
|
||||||
Player special-case (407546-407549): if `IsThePlayer()`, `m_idIcon =
|
|
||||||
GetDIDByEnum(0x10000004, 7)` (the player container icon) and `m_itemType =
|
|
||||||
TYPE_CONTAINER`.
|
|
||||||
|
|
||||||
Two enum-resolved layers (407552-407584):
|
|
||||||
- **type-default underlay** `eax_11 = DBObj::GetByEnum(LowestSetBit(m_itemType)+1, …)`
|
|
||||||
with enum `0x10000004` (the SkillTable DID-mapper namespace reused as the icon-type
|
|
||||||
table); if `m_itemType` has no bits, index `0x21`. (407555-407564)
|
|
||||||
- **effect overlay** `arg2 = DBObj::GetByEnum(LowestSetBit(m_effects)+1, …)` with enum
|
|
||||||
`0x10000005`; if null, fall back to index `0x21` of the same enum. (407568-407584)
|
|
||||||
|
|
||||||
Then it resolves the three direct ids as DBObjs (407587-407592): `eax_19` =
|
|
||||||
m_idCustomUnderlay, `ebp` = m_idIcon (base), `edi_1`/`var_38` = m_idCustomOverlay.
|
|
||||||
|
|
||||||
**Drag-icon surface** (`m_pDragIcon`, 407594-407625): a 32×32 local surface
|
|
||||||
(`CreateLocalSurface` → `Create(0x20, 0x20, GetUISurfaceFormat, 1)`); blit base
|
|
||||||
`ebp` `Blit_Normal`, then custom-overlay `var_38` `Blit_4Alpha`; `ReplaceColor(...,
|
|
||||||
&pwd._iconOverlayID)` applies the overlay tint; wrapped in a `Graphic`.
|
|
||||||
|
|
||||||
**Full slot icon** (`m_pIcon`, 407626-407647): a second 32×32 surface; blit
|
|
||||||
**type-default underlay `eax_11` `Blit_Normal`**, then **custom-underlay `eax_19`
|
|
||||||
`Blit_3Alpha`**, then **the drag-icon surface `eax_26` `Blit_3Alpha`** on top (base +
|
|
||||||
overlay already baked into it). Wrapped in a `Graphic` → `m_pIcon`.
|
|
||||||
|
|
||||||
**Net composite (bottom → top):**
|
|
||||||
1. item-type default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) — Normal
|
|
||||||
2. server custom underlay (`pwd._iconUnderlayID`) — 3Alpha
|
|
||||||
3. base icon (`pwd._iconID`) — Normal *(baked into the drag layer first)*
|
|
||||||
4. server custom overlay (`pwd._iconOverlayID`) + its tint — 4Alpha
|
|
||||||
5. spell-effect overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) — *(captured `arg2`;
|
|
||||||
note: in the 2013 BN lifting the effect-overlay capture lands but I did not see its
|
|
||||||
explicit `Blit` in the slot-surface block; it feeds the same path. LIKELY blitted as
|
|
||||||
part of the overlay stage — flagged, see §7.)*
|
|
||||||
|
|
||||||
Cache invalidation: `IconData::UpdateIcons` (407962) re-renders only when `InqIconID()`,
|
|
||||||
`_iconOverlayID`, `_iconUnderlayID`, `InqType()`, or `_effects` changed (407968-407976);
|
|
||||||
`ACCWeenieObject::IconDataChanged` (408201) drives it on a property update.
|
|
||||||
|
|
||||||
### 3.3 The decode pipeline acdream should use
|
|
||||||
|
|
||||||
1. On `CreateObject` (and `ObjDescEvent`/property-update), capture `IconId` (`_iconID`),
|
|
||||||
`IconUnderlayId` (`_iconUnderlayID`), `IconOverlayId` (`_iconOverlayID`), `_effects`,
|
|
||||||
and `ItemType` into the `ItemInstance` (the model already has the first three fields;
|
|
||||||
`_effects` needs adding). **Gap:** `CreateObject.TryParse` discards `IconId` —
|
|
||||||
re-verified at `CreateObject.cs:516` (`_ = ReadPackedDwordOfKnownType(body, ref pos,
|
|
||||||
IconTypePrefix); // IconId`) and `:515` (`_ = ReadPackedDword(...) // WeenieClassId`).
|
|
||||||
CONFIRMED.
|
|
||||||
2. For each of the up-to-five layer ids, decode the `0x06xx` RenderSurface **directly**
|
|
||||||
via `TextureCache.GetOrUploadRenderSurface` (per the D.2b gotcha).
|
|
||||||
3. Composite into one 32×32 RGBA target in the order of §3.2. Two faithful options:
|
|
||||||
(a) a CPU compositor matching retail's blit modes (Normal = src-over opaque,
|
|
||||||
3Alpha/4Alpha = the AC alpha blits — see ACViewer `ImgTex`/`RenderSurface` decode for
|
|
||||||
the per-format alpha handling), uploaded as one cached GL texture keyed by the
|
|
||||||
(iconId, underlay, overlay, effects, itemType) tuple; or (b) draw the layers as
|
|
||||||
stacked sprites at the cell rect each frame. Retail does (a) (one `m_pIcon` surface),
|
|
||||||
and caching matches retail's `IconData` per-object cache + `UpdateIcons` dirty check —
|
|
||||||
recommend (a).
|
|
||||||
4. The type-default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) and effect
|
|
||||||
overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) require resolving the retail
|
|
||||||
icon-type / effect DID-mapper enums to concrete `0x06` ids. These map through the dat
|
|
||||||
DidMapper/EnumMapper tables (`DatFileType` 38/36). **For MVP, the base `_iconID`
|
|
||||||
alone is the dominant visual** (most items have no custom underlay/overlay and no
|
|
||||||
effects); the underlay/overlay/effect layers are the "magic/enchanted/glow" polish.
|
|
||||||
LIKELY-safe to ship base-only first, then layer in the composite. (synthesis §5
|
|
||||||
risk #3 — verify IconId is set on a CONTAINED item's CreateObject against a live
|
|
||||||
capture before treating it as the sole source.)
|
|
||||||
|
|
||||||
**Palette note (cross-ref).** Item icons are pre-rendered `0x06` RenderSurfaces; they do
|
|
||||||
NOT take a creature/clothing subpalette overlay at icon-composite time (the composite
|
|
||||||
only blits + tints with `_iconOverlayID`). ACViewer's `TextureCache.cs::IndexToColor`
|
|
||||||
subpalette-overlay is for paletted INDEX16/P8 *world* textures — the canonical reference
|
|
||||||
for THAT path, but the icon path uses the surfaces as-decoded. acdream's WB
|
|
||||||
`TextureHelpers.cs` (in-tree) is the decode reference for the `0x06` formats themselves
|
|
||||||
(BGRA/DXT/P8/INDEX16). CONFIRMED the composite has no subpalette step; LIKELY a paletted
|
|
||||||
UI icon would need a palette (today `GetOrUploadRenderSurface` passes `palette: null` →
|
|
||||||
magenta on a paletted sprite, `TextureCache.cs:135` — flagged §7).
|
|
||||||
|
|
||||||
### 3.4 Identified-vs-unidentified does NOT swap the icon (synthesis §5 risk #14)
|
|
||||||
|
|
||||||
CONFIRMED in the negative: `UIItem_Update`/`UIItem_SetIcon`/`RenderIcons` derive the icon
|
|
||||||
purely from server-sent weenie props (`_iconID`/`_iconUnderlayID`/`_iconOverlayID`/
|
|
||||||
`_effects`/`InqType`) — there is **no appraise/identified branch** anywhere in the icon
|
|
||||||
path. Appraise (`IdentifyObjectResponse 0x00C9`) gates the TOOLTIP detail
|
|
||||||
(`UpdateTooltip`, 230392), not the icon. So a slot shows the same icon before and after
|
|
||||||
appraise. The inventory agent's risk #14 LIKELY is now CONFIRMED.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Item / container data model + acdream gap analysis
|
|
||||||
|
|
||||||
### 4.1 Items are `ACCWeenieObject` weenies
|
|
||||||
|
|
||||||
The cell never holds item data — it holds an `itemID` and resolves it live via
|
|
||||||
`ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp 230235). This matches
|
|
||||||
`claude-memory/feedback_weenie_vs_static.md` (interactable items are server-spawned
|
|
||||||
weenies, not dat-baked). The data the cell binds to:
|
|
||||||
|
|
||||||
| Cell display | Source field (`PublicWeenieDesc`, `acclient.h:37163+`) |
|
|
||||||
|---|---|
|
|
||||||
| base icon | `_iconID` (37168) |
|
|
||||||
| magic underlay | `_iconUnderlayID` (37170) |
|
|
||||||
| enchanted overlay | `_iconOverlayID` (37169) |
|
|
||||||
| effect glow | `_effects` (37183) |
|
|
||||||
| stack count | `_stackSize` / `_maxStackSize` (37188-37189) |
|
|
||||||
| capacity bar | `_itemsCapacity` / `_containersCapacity` (37176-37177) |
|
|
||||||
| structure bar | `_structure` / `_maxStructure` (37186-37187) |
|
|
||||||
| value/burden | `_value` (37179) / `_burden` (37193) |
|
|
||||||
| container membership | `_containerID` / `_wielderID` / `_location` / `_priority` (37171-37175) |
|
|
||||||
|
|
||||||
### 4.2 How the client learns container contents
|
|
||||||
|
|
||||||
- **Login:** `PlayerDescription (0x0013)` carries the full inventory + equipped lists.
|
|
||||||
- **Per-item spawn:** `CreateObject (0xF745)` for each weenie (incl. a pack item) with
|
|
||||||
the WeenieHeader fields above.
|
|
||||||
- **Open a container:** `ViewContents (0x0196)` lists `{guid, containerType}` per slot →
|
|
||||||
`UIElement_ItemList::ItemList_OpenContainer` builds a `UIElement_UIItem` per entry.
|
|
||||||
- **Live moves:** `ACCWeenieObject::ServerSaysMoveItem` (decomp 408086) is the client's
|
|
||||||
per-weenie relocation: it updates `_containerID`/`_wielderID`/`_location`, re-parents
|
|
||||||
in the local content lists (`RemoveContent`/`AddContent`), sets `current_state`
|
|
||||||
(`IN_CONTAINER`/`IN_3D_VIEW`), and clears the `waiting` ghost. This is driven by the
|
|
||||||
`0x0022`/`0x0023`/`0x019A` GameEvents. CONFIRMED.
|
|
||||||
|
|
||||||
Hierarchy is 2-deep (main pack → side-packs; a side-pack holds no side-pack) — the
|
|
||||||
backpack hosts two `UIElement_ItemList`s, the own list (`+0x604`) and the open-other-
|
|
||||||
container list (`+0x608`) (inventory §2.2). The outbound verbs are the `ACCWeenieObject::
|
|
||||||
UIAttempt*` family — `UIAttemptWield` → `Event_GetAndWieldItem` (decomp 407763, with a
|
|
||||||
stack-split-to-wield branch when `_stackSize>1`), `UIAttemptPutInContainer` →
|
|
||||||
`Event_PutItemInContainer` (407797), `UIAttemptPutIn3D` → `Event_DropItem` (407821),
|
|
||||||
`UIAttemptMerge`/`UIAttemptSplitToContainer`/`UIAttemptSplitTo3D`/`UIAttemptGive`
|
|
||||||
(407840-407897, 407780). Each records a `prevRequest` for the speculative-then-confirm
|
|
||||||
rollback. CONFIRMED.
|
|
||||||
|
|
||||||
### 4.3 acdream model status (focus: what the cell binds to)
|
|
||||||
|
|
||||||
- **`ItemInstance.cs` (verified):** already has `IconId` (cs:136), `IconUnderlayId`
|
|
||||||
(137), `IconOverlayId` (138), `StackSize`/`StackSizeMax` (139-140), `Burden` (141),
|
|
||||||
`Value` (142), `ContainerId` (143), `ContainerSlot` (144), `ValidLocations`/
|
|
||||||
`CurrentlyEquippedLocation` (134-135). **Missing for the icon composite:** `_effects`
|
|
||||||
(effect glow) and an `ItemType` already present (Type, 133). The synthesis §0 claim is
|
|
||||||
CONFIRMED.
|
|
||||||
- **`ItemRepository.cs` (verified):** already models the container map, the move events
|
|
||||||
(`WieldObject`/`InventoryPutObjInContainer`/`InventoryPutObjectIn3D`/`ViewContents`/
|
|
||||||
`CloseGroundContainer`, cs:23-27) and the `InventoryServerSaveFailed` speculative-
|
|
||||||
revert (cs:28-31). CONFIRMED.
|
|
||||||
- **`CreateObject.cs` (verified):** discards `IconId` (cs:516) + `WeenieClassId`
|
|
||||||
(cs:515) + StackSize/Value/capacities — the cell's icon + quantity + capacity-bar
|
|
||||||
source. CONFIRMED gap.
|
|
||||||
- The full wire-gap TODO is the synthesis §3.3 — not duplicated here; the
|
|
||||||
data-model-binding subset is: extend `CreateObject` to capture
|
|
||||||
IconId/WeenieClassId/StackSize/Value/ItemCapacity/ContainerCapacity (+ `_effects`),
|
|
||||||
and add `_effects` to `ItemInstance`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Drag-drop spine — the WIDGET-LEVEL state machine
|
|
||||||
|
|
||||||
The per-panel docs covered the panel-class `HandleDropRelease` (e.g. `gmToolbarUI :
|
|
||||||
ItemListDragHandler`). THIS is the shared lower layer every item-cell inherits.
|
|
||||||
|
|
||||||
### 5.1 The retail event chain on the cell (`UIElement_UIItem::ListenToElementMessage`, decomp 229344)
|
|
||||||
|
|
||||||
The cell handles four element messages (CONFIRMED 229347-229418):
|
|
||||||
- **`0x21` = begin-drag** (left-press-and-move on an occupied cell): walk to the parent
|
|
||||||
`UIElement_ItemList` (`GetParent()->DynamicCast(0x10000031)`) and call
|
|
||||||
`ItemList_BeginDrag(list, ptWindow.x, ptWindow.y)` (229357-229360). The list spawns the
|
|
||||||
`m_dragIcon` ghost and arms the drag.
|
|
||||||
- **`0x3e` = drag-over**, with two sub-cases keyed on `dwParam1`:
|
|
||||||
- `dwParam1 == 0` (drag left this cell): reset DragAccept to neutral
|
|
||||||
`SetState(0x1000003f)` (229381-229387).
|
|
||||||
- else (drag hovering): if a global drag is active (`UIElementManager::s_pInstance->
|
|
||||||
m_dragElement != 0`), forward to `ItemList_DragOver(list, target, dragElement)`
|
|
||||||
(229390-229406); the list decides accept/reject and flips the DragAccept overlay.
|
|
||||||
- **`0x15` = drop/release**: clear the weenie's waiting flag and hide
|
|
||||||
`m_elem_Icon_Ghosted` (229363-229379). (The retail event-id sequence is
|
|
||||||
`0x15→0x21→0x1C→0x3E`, which acdream's `UiRoot` already cites verbatim — `UiRoot.cs:448`.)
|
|
||||||
|
|
||||||
### 5.2 The drop-TARGET rollover (`UIElement_Field::MouseOverTop`, decomp 126098)
|
|
||||||
|
|
||||||
Every cell inherits Field's drop-target rollover. When a drag is in progress
|
|
||||||
(`UIElementManager::s_pInstance->m_dragElement != 0`) and this field has the
|
|
||||||
CatchDroppedItem attribute (`GetAttribute_Bool(0x36)`, plus `0x70`/`0x38`), it calls
|
|
||||||
`m_dragDropCallback(m_dragElement, this)` to test acceptance and sets element state **9**
|
|
||||||
(accept) or **0xa** (reject), saving the old state for restore on leave (126124-126153).
|
|
||||||
`UIElement_Field::CatchDroppedItem` (decomp 126159) restores the rollover state then
|
|
||||||
chains `UIElement::CatchDroppedItem` (the real drop handler). CONFIRMED.
|
|
||||||
|
|
||||||
The `0x36` attribute (CatchDroppedItem flag) is exactly what `UIElement_UIItem::PostInit`
|
|
||||||
sets `true` on every cell (decomp 229744: `SetPropertyName(0x36); …(1); SetProperty`),
|
|
||||||
with `0x3a` and `0x39` set false (229755/229766). So **every item-cell is a drop target
|
|
||||||
by construction.** CONFIRMED.
|
|
||||||
|
|
||||||
### 5.3 `InqDropIconInfo` — what the drop carries (decomp 230533)
|
|
||||||
|
|
||||||
`UIElement_ItemList::InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads
|
|
||||||
the dragged element's properties via `InqProperty(0x1000000f..0x10000014)` and assembles
|
|
||||||
the flag word (230595-230617): `flags = (bit8 from 0x10000014) | (bit2 from 0x10000013)
|
|
||||||
| (bit4 from 0x10000012) | (bit1 from var_39/0x10000011)`. The synthesis flag semantics
|
|
||||||
hold: **`flags & 0xE == 0`** ⇒ fresh drag from inventory (place-new); **`flags & 4`** ⇒
|
|
||||||
within-list reorder (the source slot is `m_lastShortcutNumDragged`). `objId` = the
|
|
||||||
dragged object guid; `containerId` = its source container. CONFIRMED (the bit→source
|
|
||||||
mapping is the toolbar/inventory docs' `HandleDropRelease`).
|
|
||||||
|
|
||||||
### 5.4 The drag handler interface (`ItemListDragHandler` + `RegisterItemListDragHandler`)
|
|
||||||
|
|
||||||
`UIElement_ItemList::RegisterItemListDragHandler(list, handler)` stores
|
|
||||||
`this->m_dragHandler = handler` (decomp 230461-230464). Each panel registers ITSELF as
|
|
||||||
the handler on every slot list (toolbar §5, paperdoll §2a). On a drop, the list routes
|
|
||||||
to the handler's `HandleDropRelease`, which resolves the target slot + the
|
|
||||||
`InqDropIconInfo` payload and issues the wire action (the per-panel docs). The shared
|
|
||||||
contract the spine defines is: **`ItemListDragHandler { OnItemListDragOver(list,
|
|
||||||
target, drag); HandleDropRelease(msg) }`** + `RegisterItemListDragHandler(handler)`.
|
|
||||||
|
|
||||||
### 5.5 Drag-ghost / cursor lifecycle
|
|
||||||
|
|
||||||
`m_dragIcon` (id `0x10000345`) is created in `PostInit` from a DBObj and kept hidden
|
|
||||||
(`SetVisible(0)`, decomp 229738-229740); on begin-drag the list makes the global
|
|
||||||
`m_dragElement` track the cursor (the translucent icon copy), and on drop it is hidden
|
|
||||||
again. The drag-ghost graphic is the SAME `m_pDragIcon` the icon compositor built (§3.2)
|
|
||||||
— base + overlay, no underlay. CONFIRMED.
|
|
||||||
|
|
||||||
### 5.6 What acdream's `UiRoot` already has vs. needs
|
|
||||||
|
|
||||||
**Already there (verified `UiRoot.cs`):** `DragSource`/`DragPayload` (cs:71-73),
|
|
||||||
`BeginDrag` (cs:450), `UpdateDragHover` emitting `DragOver`/`DragEnter`/`DragLeave`
|
|
||||||
(cs:458-482), `FinishDrag` emitting `DropReleased` with an `accepted` flag (cs:484-496),
|
|
||||||
the 3-pixel `DragDistanceThreshold` promote-on-move (cs:84,183-189), and the retail
|
|
||||||
`0x15→0x21→0x1C→0x3E` chain noted in the comment (cs:448). `CapturesPointerDrag` on
|
|
||||||
`UiElement` distinguishes interior-drag from window-move.
|
|
||||||
|
|
||||||
**Needs to grow:** a per-cell *accept test* hook (the retail `m_dragDropCallback` /
|
|
||||||
`CatchDroppedItem` — `UiField` only NAMES these in its doc-comment, it does NOT implement
|
|
||||||
them: `UiField.cs:7-11` "Carries retail Field's drag-drop hooks
|
|
||||||
(CatchDroppedItem/MouseOverTop) as stubs for future item-window use" — there is no such
|
|
||||||
method body in the class). So the spine adds: (1) an `OnDragOver`→accept/reject result on
|
|
||||||
`UiItemSlot` that flips its DragAccept overlay state, (2) an `OnDrop` that calls the
|
|
||||||
panel's drag handler with the resolved `{objId, srcContainer, flags}`, and (3) the
|
|
||||||
`m_dragIcon` translucent ghost as the drag visual. CONFIRMED gap.
|
|
||||||
|
|
||||||
### 5.7 Generic pick-up → drag → drop → dispatch (pseudocode)
|
|
||||||
|
|
||||||
```
|
|
||||||
on left-press over an OCCUPIED UiItemSlot: # retail msg 0x21 path
|
|
||||||
UiRoot.Captured = slot; _dragCandidate = true
|
|
||||||
on mouse-move while captured & moved > 3px:
|
|
||||||
UiRoot.BeginDrag(slot, payload = { objId = slot.itemID,
|
|
||||||
srcContainer = weenie._containerID,
|
|
||||||
srcSlotIndex = slot.shortcutNum })
|
|
||||||
show slot.m_dragIcon tracking the cursor # retail m_dragElement
|
|
||||||
on drag-over a target UiItemSlot/UiItemList: # retail msg 0x3e / MouseOverTop
|
|
||||||
accepted = targetHandler.OnDragOver(target, payload) # m_dragDropCallback
|
|
||||||
target.SetDragAccept(accepted ? Accept(0x10000041) : Reject(0x10000040))
|
|
||||||
on drag leaving the target:
|
|
||||||
target.SetDragAccept(Neutral 0x1000003f)
|
|
||||||
on release over target: # retail msg 0x15 / CatchDroppedItem
|
|
||||||
info = InqDropIconInfo(payload) # objId, srcContainer, flags
|
|
||||||
targetHandler.HandleDropRelease(target, info) # per-panel: picks the opcode:
|
|
||||||
# toolbar slot : flags&0xE==0 -> CreateShortcutToItem ; flags&4 -> reorder
|
|
||||||
# pack slot : PutItemInContainer 0x0019
|
|
||||||
# equip slot : GetAndWieldItem 0x001A (target's EquipMask)
|
|
||||||
# ground : DropItem 0x001B
|
|
||||||
# compatible stack: StackableMerge 0x0054 / split dialog -> Stackable*Split*
|
|
||||||
# NPC : GiveObjectRequest 0x00CD
|
|
||||||
slot.SetWaitingState(true) # speculative ghost until server confirm
|
|
||||||
hide drag ghost; clear DragSource
|
|
||||||
on server reply (move event) or rollback (InventoryServerSaveFailed 0x00A0):
|
|
||||||
slot.SetWaitingState(false); UIItem_Update(...) # confirm or revert
|
|
||||||
```
|
|
||||||
The opcode-selection table is the per-panel docs' job (already covered); the spine owns
|
|
||||||
the pick-up → ghost → accept-test → release → `InqDropIconInfo` → dispatch-to-handler
|
|
||||||
chain above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. New toolkit widgets this spine introduces
|
|
||||||
|
|
||||||
| Widget | Registers at | Leaf vs container | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolved class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying Type 3); a behavioral leaf in `DatWidgetFactory` keyed off the resolved class id | **LEAF** (`ConsumesDatChildren=>true`) — reproduces the icon + §2.2 overlay sub-elements procedurally | one item-in-a-slot: composited icon (§3) + quantity `UiText` + capacity/structure `UiMeter`s + 10-step cooldown ring + selected/ghosted/open-container/drag-accept/sell/trade overlay states; binds `itemID` (retail `+0x5FC`). **The spine widget — build once.** |
|
|
||||||
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | resolved class id `0x10000031` (dump root `0x10000339`, Type `268435505`, 32×32 — CONFIRMED `itemlist-0x2100003D.txt:13-23`) | **leaf to the importer** (`ConsumesDatChildren=>true`; manages its own `UiItemSlot` children procedurally) — logically a container of slots at runtime | a 1-cell (toolbar/equip) or N-cell (inventory) grid of `UiItemSlot`s; owns the drag handler registration. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/BeginDrag/DragOver/InqDropIconInfo/RegisterItemListDragHandler`. |
|
|
||||||
|
|
||||||
These exactly match the synthesis §2 / §7 rows — **no correction**. The `UiViewport`
|
|
||||||
(Type `0xD`), window manager, and sub-window-mount are NOT spine widgets (paperdoll /
|
|
||||||
shared-infra; out of scope here). One precision the spine adds: the `UiField` Type-3
|
|
||||||
drag hooks are documented-but-unimplemented (§5.6) — the `UiItemSlot` is where they get a
|
|
||||||
body, not the generic `UiField`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Open questions / UNVERIFIED — resolved + carried forward
|
|
||||||
|
|
||||||
**Resolved by this doc (synthesis §5 risks → now CONFIRMED):**
|
|
||||||
- **#1 icon-composite render** — RESOLVED. Each layer is a `0x06` RenderSurface decoded
|
|
||||||
directly; the icon is a 5-layer composite (`IconData::RenderIcons` 407524). §3.
|
|
||||||
- **#2 `+0x5FC` field name** — RESOLVED. It is `UIElement_UIItem::itemID` (decomp
|
|
||||||
230230). §2.1.
|
|
||||||
- **#14 identified-vs-unidentified does NOT swap the icon** — CONFIRMED in the negative
|
|
||||||
(no appraise branch in the icon path). §3.4.
|
|
||||||
|
|
||||||
**Carried forward (still need a follow-up):**
|
|
||||||
- **Effect-overlay blit into the slot surface (§3.2 layer 5)** — the effect DBObj
|
|
||||||
(`GetByEnum(0x10000005, lsb(effects)+1)`) is captured (`arg2`, 407575) but I did not
|
|
||||||
see its explicit `Blit` into the `m_pIcon` surface in the 2013 BN lifting (the visible
|
|
||||||
blits are type-default-underlay, custom-underlay, and the base+overlay drag layer).
|
|
||||||
LIKELY it blits as part of the overlay stage; confirm with a Ghidra decompile of
|
|
||||||
`0x0058d180` or a cdb trace before relying on the exact effect layering. UNVERIFIED.
|
|
||||||
- **Type-default underlay + effect-overlay enum→DID resolution** — `GetByEnum(0x10000004,
|
|
||||||
…)` / `GetByEnum(0x10000005, …)` resolve through the dat DidMapper/EnumMapper tables;
|
|
||||||
the concrete `0x06` ids per item-type / effect were not enumerated. MVP can ship
|
|
||||||
base-`_iconID`-only. §3.3. UNVERIFIED.
|
|
||||||
- **Paletted UI icons** — `GetOrUploadRenderSurface` passes `palette: null`
|
|
||||||
(`TextureCache.cs:135`), returning magenta on a paletted (INDEX16/P8) icon. Most item
|
|
||||||
icons are pre-baked BGRA/DXT, but verify no item icon is paletted before shipping; if
|
|
||||||
one is, wire a UI palette (the D.2b memory flags this as a known TODO). UNVERIFIED.
|
|
||||||
- **CreateObject IconId on a CONTAINED item** (synthesis §5 risk #3) — byte-trace a live
|
|
||||||
capture that ACE sets `IconId` on a non-3D-visible pack item's CreateObject vs.
|
|
||||||
relying on PlayerDescription. LIKELY present; verify. (WireMCP capture of `0xF745`.)
|
|
||||||
- **`m_dragDropCallback` shape** — retail's per-field accept callback signature
|
|
||||||
(`callback(dragElement, this) -> bool`, decomp 126124) is confirmed; the acdream
|
|
||||||
binding (a delegate on `UiItemSlot`/the handler) is a design call for the build spec.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. MEMORY.md index line
|
|
||||||
|
|
||||||
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, the `+0x5FC` bound id RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX RESOLVED: each layer is a `0x06` RenderSurface decoded DIRECTLY via `GetOrUploadRenderSurface`, but the on-screen icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524: type-default underlay + `_iconUnderlayID` + `_iconID` base + `_iconOverlayID`+tint + effect overlay, blitted into one 32×32 surface; NOT a single texture, NOT appraise-gated). Drag-drop state machine: cell inherits `UIElement_Field::MouseOverTop`/`CatchDroppedItem` (drop-target rollover, attr 0x36) + `ListenToElementMessage` msgs 0x21 begin-drag/0x3e drag-over/0x15 drop; `InqDropIconInfo` flags 0xE==0 fresh-drag, &4 reorder; `UiRoot` already has the drag chain (0x15→0x21→0x1C→0x3E), `UiField` only STUBS the hooks. acdream gap: `CreateObject` discards IconId (cs:516). Sub-element id map + named states (`ItemSlot_Empty 0x060074CF`, DragOver Accept/Reject/DropIn 0x060011F9/F8/F7) included.
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
# D.2b core panels — SYNTHESIS (toolbar + inventory + paperdoll)
|
|
||||||
|
|
||||||
**Date:** 2026-06-16
|
|
||||||
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only synthesis.
|
|
||||||
**Role:** synthesis lead reconciling the three panel deep-dives into one authoritative
|
|
||||||
build plan. The deliverable is this doc; no code was written.
|
|
||||||
**Inputs (all read in full):**
|
|
||||||
- toolbar: [`2026-06-16-action-bar-toolbar-deep-dive.md`](2026-06-16-action-bar-toolbar-deep-dive.md)
|
|
||||||
- inventory: [`2026-06-16-inventory-deep-dive.md`](2026-06-16-inventory-deep-dive.md)
|
|
||||||
- paperdoll: [`2026-06-16-equipment-paperdoll-deep-dive.md`](2026-06-16-equipment-paperdoll-deep-dive.md)
|
|
||||||
- handoff: [`2026-06-16-action-bar-inventory-equipment-handoff.md`](2026-06-16-action-bar-inventory-equipment-handoff.md)
|
|
||||||
|
|
||||||
> ## Note: the SPINE doc was completed in a follow-up pass
|
|
||||||
> The handoff promised a "spine agent" doc covering the shared item-slot widget, icon
|
|
||||||
> decode, and the full drag-drop state machine. During the original workflow run the
|
|
||||||
> spine agent died on a transient API error, so this synthesis was first written against
|
|
||||||
> a `null` spine digest. **The spine doc has since been written:**
|
|
||||||
> [`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
|
|
||||||
> It resolves the two items this synthesis had left open: (1) the **icon-composite
|
|
||||||
> render path** — each icon LAYER is a `0x06` RenderSurface decoded DIRECTLY, but the
|
|
||||||
> on-screen icon is a **5-layer runtime composite** blitted into one private 32×32
|
|
||||||
> surface (`IconData::RenderIcons` decomp 407524), NOT a single texture and NOT
|
|
||||||
> appraise-gated; (2) the item-cell **bound-object field `+0x5FC` = `UIElement_UIItem::itemID`**
|
|
||||||
> (decomp 230230). The shared `UIElement_UIItem` / `UIElement_ItemList` identity facts
|
|
||||||
> below were first-hand-derived + re-verified by the panel agents and remain sound.
|
|
||||||
> §4 Step 0 and the §5 risks below have been updated to reflect the completed spine doc.
|
|
||||||
|
|
||||||
## 0. Summary + confidence legend
|
|
||||||
|
|
||||||
The three D.2b core panels are all built from the **same two reusable retail widgets**:
|
|
||||||
the **item-slot** (`UIElement_UIItem`, class `0x10000032`) and the **item-list/grid**
|
|
||||||
(`UIElement_ItemList`, class `0x10000031`). Every slot in every panel is one of these —
|
|
||||||
the toolbar is 18 single-cell item-lists, the inventory is N-cell item-list grids, and
|
|
||||||
the paperdoll is ~25 single-cell item-lists keyed to `EquipMask`. Build those two
|
|
||||||
widgets once and all three panels fall out. The paperdoll adds one genuinely new piece:
|
|
||||||
a **`UiViewport`** (`UIElement_Viewport`, Type `0xD`) that renders a live 3D character
|
|
||||||
clone into a UI rect — the single biggest new engineering item. All three panels are
|
|
||||||
pop-up windows, so they all need the deferred **window manager** (open/close/z-order/
|
|
||||||
persist + Dragbar Type 2 + Resizebar Type 9 drag-resize). On the wire, acdream is in
|
|
||||||
good shape: most C→S builders and S→C parsers already exist; the concrete gaps are a
|
|
||||||
handful of missing builders (`DropItem`, `GetAndWieldItem`, `NoLongerViewingContents`),
|
|
||||||
missing parsers (`ViewContents`, `SetStackSize`, `InventoryRemoveObject`), two
|
|
||||||
incomplete parsers (a dropped 4th field on `0x0022`; a dropped error code on `0x00A0`),
|
|
||||||
and `CreateObject` discarding `IconId`/`StackSize`/capacities the cells need.
|
|
||||||
|
|
||||||
**Confidence legend** (carried from the source docs, re-checked here):
|
|
||||||
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
|
|
||||||
`file:line` that I or a panel agent opened.
|
|
||||||
- **LIKELY** — inferred from a confirmed source; the inference is named.
|
|
||||||
- **UNVERIFIED** — educated guess, flagged loudly; needs a decomp/cdb follow-up before
|
|
||||||
porting.
|
|
||||||
|
|
||||||
**Synthesis-lead re-verifications (opened first-hand, this session):**
|
|
||||||
- `CreateObject.cs:515-516` — `_ = ReadPackedDword(...) // WeenieClassId; _ = ReadPackedDwordOfKnownType(..., IconTypePrefix);` → **IconId and WeenieClassId are discarded**. CONFIRMED.
|
|
||||||
- `acclient_2013_pseudo_c.txt:135087-135088` — `UIElement_ItemList::Register();` and `UIElement_UIItem::Register();` are real adjacent symbols (`0x0047a483`/`0x0047a488`). CONFIRMED.
|
|
||||||
- `acclient_2013_pseudo_c.txt:135130-135132` — `gmBackpackUI::Register / gmInventoryUI::Register / gmPaperDollUI::Register` all real. CONFIRMED.
|
|
||||||
- `acclient_2013_pseudo_c.txt:175242-175508` — the ~25 paperdoll equip slots each `DynamicCast(0x10000031)` (`m_neckSlot, m_headSlot, m_weaponReadySlot, m_ammoReadySlot, …`) + `RegisterItemListDragHandler`. CONFIRMED.
|
|
||||||
- `acclient_2013_pseudo_c.txt:229180-229413` — the `m_elem_Icon_*` family (`_Ghosted`, `_OpenContainer`, `_Selected`, `_DragAccept`) and its `SetState` reject/accept/neutral states (`0x10000040` / `0x10000041` / `0x1000003f`) are real on `UIElement_UIItem`. CONFIRMED (corroborates the inventory agent's first-hand derivation).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Confirmed class ids + LayoutDesc ids + sizes
|
|
||||||
|
|
||||||
All confirmed via `*::Register` (`RegisterElementClass`) in the decomp + the
|
|
||||||
pre-dumped `.layout-dumps/` trees. The element-class id and the LayoutDesc id are
|
|
||||||
distinct namespaces (`0x10000xxx` = element class registered in C++; `0x21000xxx` =
|
|
||||||
the dat LayoutDesc that builds the window).
|
|
||||||
|
|
||||||
| Panel / widget | Element class id | LayoutDesc id | Root element | Size (W×H) | Register anchor |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `gmToolbarUI` (action bar) | `0x10000007` | `0x21000016` | `0x10000191` | 300×122 | `gmToolbarUI::Register` (decomp 196897); `GetUIElementType`→`0x10000007` (196707) |
|
|
||||||
| `gmInventoryUI` (frame) | `0x10000023` | `0x21000023` | `0x100001CC` | 300×362 | `gmInventoryUI::Register` (decomp 176285 / `0x004a6a60`) |
|
|
||||||
| `gmBackpackUI` (pack strip) | `0x10000022` | `0x21000022` | `0x100001C8` | 61×339 | `gmBackpackUI::Register` (decomp 176531 / `0x004a6e80`) |
|
|
||||||
| `gmPaperDollUI` (equip doll) | `0x10000024` | `0x21000024` | `0x100001D4` | 224×214 | `gmPaperDollUI::Register` (decomp 174445 / `0x004a4560`) |
|
|
||||||
| `gm3DItemsUI` ("Contents of Backpack") | `0x10000021` | `0x21000021` | `0x100001C4` | 234×120 | `gm3DItemsUI::Register` (decomp 176723) |
|
|
||||||
| `UIElement_UIItem` (item-slot, shared) | `0x10000032` | `0x21000037` (32×32 cell template) | — | 32×32 | `UIElement_UIItem::Register` (decomp 229339 / `0x0047a488`) |
|
|
||||||
| `UIElement_ItemList` (item-list/grid, shared) | `0x10000031` | `0x2100003D` (single 32×32 cell) | `0x10000339` | 32×32 cell | `UIElement_ItemList::Register` (decomp / `0x0047a483`) |
|
|
||||||
|
|
||||||
**Nesting (CONFIRMED `gmInventoryUI::PostInit` 176236-176259):** the inventory FRAME
|
|
||||||
(`0x21000023`) hosts three NESTED gm\*UI windows by id — `0x100001CD`→paperdoll
|
|
||||||
(`DynamicCast 0x10000024`), `0x100001CE`→backpack (`DynamicCast 0x10000022`),
|
|
||||||
`0x100001CF`→3D-items (`DynamicCast 0x10000021`). This "sub-window mount" (an element
|
|
||||||
whose Type is a high `0x10000xxx` game class with its own `BaseLayoutId`) is a
|
|
||||||
capability the importer does **not** have yet.
|
|
||||||
|
|
||||||
**Note on `gm3DItemsUI`:** despite the "3D" name it is a 2D "Contents of Backpack"
|
|
||||||
item-list (`gm3DItemsUI::PostInit` 176728 sets `m_contentsText`→"Contents of Backpack",
|
|
||||||
`m_itemList`→`DynamicCast(0x10000031)`; its layout has NO Viewport). The 3D character
|
|
||||||
doll is in `gmPaperDollUI`, not here. CONFIRMED.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. CONSOLIDATED new toolkit widgets (the single authoritative list)
|
|
||||||
|
|
||||||
This reconciles the four docs into one list. The shipped D.2b toolkit already has
|
|
||||||
Button(1)/Dragbar(2)/Field(3)/Menu(6)/Meter(7)/Panel(8)/Scrollbar(0xB)/Text(0xC) plus
|
|
||||||
`UiDatElement` for generic chrome — those are **reused**, not re-listed.
|
|
||||||
|
|
||||||
**Type-registration model:** the shipped numeric Type registry (1=Button … 0x12=Proto)
|
|
||||||
is the toolkit's generic-widget dispatch. The item-slot / item-list / viewport are NOT
|
|
||||||
in that numeric table — in retail they are **`UIElement` subclasses registered by a
|
|
||||||
full class id** via `RegisterElementClass(0x10000xxx, …)`, and in the dat their
|
|
||||||
elements have `Type=0` and inherit the real class id through the `BaseElement` chain
|
|
||||||
(resolved by `ElementReader.Merge`'s zero-wins-base rule). So in acdream's
|
|
||||||
`DatWidgetFactory` they are **new behavioral leaf widgets keyed off the resolved class
|
|
||||||
id**, exactly the same pattern as the existing behavioral widgets — they just key off
|
|
||||||
`0x10000031`/`0x10000032`/`0xD` rather than a small numeric Type. (The numeric Type
|
|
||||||
that `0xD`=Viewport occupies in the confirmed registry IS a generic toolkit Type, so
|
|
||||||
`UiViewport` can register at Type `0xD` directly; the item-slot/item-list register at
|
|
||||||
their class ids.)
|
|
||||||
|
|
||||||
| Widget | Registers at | Leaf vs container | Panels that use it | Purpose |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3**). Behavioral leaf. | **LEAF** (`ConsumesDatChildren=>true`) — reproduces its icon + overlay sub-elements procedurally | **all three** (toolbar slots, inventory cells, paperdoll equip slots) | one item-in-a-slot: icon (underlay/base/effects-overlay) + quantity text + capacity/structure Type-7 bars + cooldown ring; holds the bound object id (retail `+0x5FC`); selection/ghost/drag-accept/open-container overlay states. **The spine widget — build once.** |
|
|
||||||
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | class id `0x10000031`. Behavioral widget. | **leaf wrt the importer** (manages its own `UiItemSlot` children procedurally) — but logically a **container** of slots | **all three** (toolbar = 1-cell instances; inventory = N-cell grids; paperdoll = 1-cell equip slots) | a 1-cell or N-cell grid of `UiItemSlot`s. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/OpenFirstContainer`. Backpack uses **two** instances (own list `+0x604`, other-container list `+0x608`). |
|
|
||||||
| **`UiViewport`** (port of `UIElement_Viewport`, Type `0xD`) | numeric Type **`0xD`** (confirmed registry; `UIElement_Viewport::Register`→`RegisterElementClass(0xd,…)` decomp 119126) | **LEAF** (`ConsumesDatChildren=>true`) | **paperdoll only** | hosts a single live 3D entity (the character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`. **Needs a new Core→App render-into-rect seam (`IUiViewportRenderer`, Code-Structure Rule 2). The biggest new piece.** |
|
|
||||||
| **Window manager** (shared infra; drives Dragbar Type 2 + Resizebar Type 9) | not a registered widget — infra that drives existing Type-2/Type-9 chrome + `UiElement.Draggable/Resizable` | n/a | **all three** (plus future pop-ups) | open/close/z-order/persist for pop-up windows + faithful grip/dragbar drag-resize. Today vitals/chat use whole-window drag (accepted IA-12 approximation). This is "the other deferred Plan-2 piece." |
|
|
||||||
| **Sub-window mount** (LayoutImporter capability, not a widget) | n/a — an element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` | container | **inventory** (frame nests paperdoll + backpack + 3D-items) | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot. The importer recurses generic children today but has never mounted another gm\*UI window. |
|
|
||||||
| **Per-panel controllers** (`ToolbarController`, `InventoryController`, `PaperDollController`) | not widgets — controllers like `VitalsController`/`ChatWindowController` | n/a | one per panel | find-by-id binding + wire send/receive + model restore. The `gm*UI::PostInit` analogues. (Listed for completeness; each is panel-specific, not a shared widget.) |
|
|
||||||
|
|
||||||
### 2a. Reconciled disagreements between the agents
|
|
||||||
|
|
||||||
The four docs were **consistent** on the big-three widget identities; the differences
|
|
||||||
were wording, not substance. Reconciled:
|
|
||||||
|
|
||||||
1. **Item-slot Type — no real conflict.** Toolbar + inventory + paperdoll all call it
|
|
||||||
`UIElement_UIItem`, class **`0x10000032`**, a `UIElement_Field` subclass (underlying
|
|
||||||
Type 3), built as a behavioral **leaf**. The paperdoll doc's widget table named its
|
|
||||||
equip-slot variant "`UiItemSlot` registering at `0x10000031`" — that is the *equip
|
|
||||||
slot* (a single-cell `UIElement_ItemList`), NOT the inner item-cell. **Reconciliation:
|
|
||||||
`UIElement_ItemList` (`0x10000031`) is the slot/grid container; `UIElement_UIItem`
|
|
||||||
(`0x10000032`) is the item-cell inside it.** The paperdoll equip slot is a 1-cell
|
|
||||||
`UIElement_ItemList` that holds at most one `UIElement_UIItem` — same two-widget spine
|
|
||||||
as everywhere else, just constrained to one cell. (CONFIRMED: every paperdoll slot is
|
|
||||||
`DynamicCast(0x10000031)`, decomp 175242-175508; the inner cell is the
|
|
||||||
`UIElement_UIItem` `0x10000032` per the inventory agent's `UIItem_Update`/`m_elem_Icon`
|
|
||||||
citations, re-verified at 229180-229413.)
|
|
||||||
|
|
||||||
2. **Item-list "leaf vs container".** Toolbar + paperdoll said **leaf** (the importer
|
|
||||||
doesn't build its dat children; it reproduces cells procedurally); inventory said
|
|
||||||
**container** (it lays out an N-column grid). **Reconciliation: it is a behavioral
|
|
||||||
LEAF to the importer** (`ConsumesDatChildren=>true`, the importer must NOT recurse its
|
|
||||||
dat children) but it is logically a **container of `UiItemSlot`s at runtime** (it
|
|
||||||
creates/destroys cells procedurally as items arrive). Both descriptions are correct
|
|
||||||
at different layers; the binding rule for the factory is `ConsumesDatChildren=>true`.
|
|
||||||
|
|
||||||
3. **`UiViewport` Type.** Only the paperdoll doc introduced it; **Type `0xD`**,
|
|
||||||
confirmed against the registry (`0xD`=Viewport) and `RegisterElementClass(0xd,…)`.
|
|
||||||
No conflict.
|
|
||||||
|
|
||||||
4. **Window manager.** All three docs named it identically (shared, drives Dragbar
|
|
||||||
Type 2 + Resizebar Type 9, open/close/z-order/persist). No conflict.
|
|
||||||
|
|
||||||
5. **`+0x5FC` (bound object id on the item-cell).** The toolbar doc anchors this by
|
|
||||||
OFFSET only (UNVERIFIED symbolic name). The inventory/spine-territory render of the
|
|
||||||
cell would have named it; since the spine doc is missing, **it stays UNVERIFIED** —
|
|
||||||
carried to §5.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Cross-panel wire-message catalog (de-duplicated)
|
|
||||||
|
|
||||||
All C→S ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`);
|
|
||||||
all S→C item events ride the `0xF7B0` GameEvent envelope (`u32 0xF7B0; u32 target;
|
|
||||||
u32 seq; u32 eventOpcode; …`). De-duplicated across the three panels; the "Panels"
|
|
||||||
column shows which panel(s) use each. acdream parse-status is the union of what the
|
|
||||||
three agents found (each cross-checked against `src/AcDream.Core.Net/`).
|
|
||||||
|
|
||||||
### 3.1 Client → server (GameActions)
|
|
||||||
|
|
||||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up (container=self) | `GameActionPutItemInContainer.Handle` | `Inventory_PutItemInContainer*` | **parsed** — `InteractRequests.BuildPickUp` (InteractRequests.cs:97) | inv, paperdoll (un-wield) |
|
|
||||||
| `0x001A` | GetAndWieldItem | C→S | equip item from pack onto doll/equip slot | `GameActionGetAndWieldItem.Handle` (Actions/GameActionGetAndWieldItem.cs:7-14) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`uint ObjectId; EquipMask Slot`, generated.cs:14-42) | **MISSING** (no builder) | paperdoll, inv |
|
|
||||||
| `0x001B` | DropItem | C→S | drop item on the ground | `GameActionDropItem.Handle` (1×u32 guid) | — | **MISSING** (no builder) | inv |
|
|
||||||
| `0x0035` | UseWithTarget | C→S | use src item on target (toolbar target-mode / key→door) | (Interact) | — | **parsed** — `InteractRequests.BuildUseWithTarget` | toolbar, inv |
|
|
||||||
| `0x0036` | UseItem | C→S | use/activate a single item (toolbar slot activation) | `GameActionUseItem` | — | **parsed** — `InteractRequests.BuildUse` | toolbar, inv |
|
|
||||||
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` (from,to,amount) | — | **parsed** — `InventoryActions.BuildStackableMerge` | inv |
|
|
||||||
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` (stack,container,place,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitToContainer` | inv |
|
|
||||||
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` (stack,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitTo3D` | inv |
|
|
||||||
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (arrows) | `GameActionStackableSplitToWield` (stack,equipMask,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitToWield` | inv, paperdoll |
|
|
||||||
| `0x00CD` | GiveObjectRequest | C→S | give item/N-of-stack to NPC/player | `GameActionGiveObjectRequest.Handle` (target,item,amount) | — | **parsed** — `InventoryActions.BuildGiveObjectRequest` | inv |
|
|
||||||
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | `GameActionType` 0x0195 (containerGuid) | — | **MISSING** (no builder) | inv |
|
|
||||||
| `0x019C` | AddShortCut | C→S | pin item to toolbar slot (on drag-to-slot / add-selected) | `GameActionAddShortcut.Handle` → `Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** — `InventoryActions.BuildAddShortcut` *(fix field naming → Index/ObjectId/SpellId\|Layer)* | toolbar |
|
|
||||||
| `0x019D` | RemoveShortCut | C→S | unpin / evict / overwrite a toolbar slot | `GameActionRemoveShortcut.Handle` → `Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** — `InventoryActions.BuildRemoveShortcut` | toolbar |
|
|
||||||
|
|
||||||
### 3.2 Server → client (GameEvents / GameMessages)
|
|
||||||
|
|
||||||
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `0x0022` | InventoryPutObjInContainer | S→C | confirm item in container at slot | `GameEventItemServerSaysContainId` (itemGuid,containerGuid,placement,**containerType**) | — | **parsed INCOMPLETE** — `GameEvents.ParsePutObjInContainer` reads 3 fields, **drops containerType**; wired (GameEventWiring.cs:239) | inv |
|
|
||||||
| `0x0023` | WieldObject | S→C | confirm item equipped to slot | `GameEventWieldItem` (objectId, i32 equipMask) | — | **parsed + wired** — `GameEvents.ParseWieldObject`, GameEventWiring.cs:231 | paperdoll, inv |
|
|
||||||
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` (containerGuid) | — | **parsed UNWIRED** — `GameEvents.ParseCloseGroundContainer`, not in WireAll | inv |
|
|
||||||
| `0x00A0` | InventoryServerSaveFailed | S→C | reject speculative client move (roll back) | `GameEventInventoryServerSaveFailed` (itemGuid, weenieError) | — | **parsed UNWIRED INCOMPLETE** — reads guid only, **drops error**; not in WireAll | inv (+toolbar/paperdoll rollback) |
|
|
||||||
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (gates tooltip, not icon) | `GameEventIdentifyObjectResponse` (guid,flags,success,property tables) | — | **parsed + wired** — `AppraiseInfoParser` via GameEventWiring.cs:245 | inv, paperdoll, toolbar (tooltip) |
|
|
||||||
| `0x0196` | ViewContents | S→C | full contents list of an opened container | `GameEventViewContents` (container,count,{guid,containerType}×n) | — | **MISSING** (no parser) | inv |
|
|
||||||
| `0x0197` | SetStackSize | S→C | update a stack count+value after merge/split | `GameMessageSetStackSize` (seq,guid,stackSize,value) | — | **MISSING** (no parser) | inv, toolbar |
|
|
||||||
| `0x019A` | InventoryPutObjectIn3D | S→C | confirm item dropped to world | `GameEventItemServerSaysMoveItem` (objectGuid) | — | **parsed UNWIRED** — `GameEvents.ParsePutObjectIn3D`, not in WireAll | inv |
|
|
||||||
| `0xF625` | ObjDescEvent | S→C | wield/unwield → full new appearance broadcast → `RedressCreature` | `GameMessageObjDescEvent` → `SerializeUpdateModelData` (GameMessageObjDescEvent.cs:10-17) | (ModelData block) | **parsed** — `ObjDescEvent.cs:33-73` (`CreateObject.ReadModelData`) | paperdoll |
|
|
||||||
| `0xF745` | CreateObject | S→C | spawn a weenie incl. a pack item (IconId/WeenieClassId/StackSize/Value/capacities) | `GameMessageCreateObject` → `WorldObject.SerializeCreateObject` | `Item_CreateObject` | **parsed INCOMPLETE** — `CreateObject.TryParse` **discards IconId (cs:516), WeenieClassId (cs:515), StackSize, Value, ItemCapacity, ContainerCapacity** | all (icon + quantity source) |
|
|
||||||
| (UIQueue) | InventoryRemoveObject | S→C | remove item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` (guid) | — | **MISSING** (no parser) | inv, toolbar |
|
|
||||||
| `PlayerDescription` SHORTCUT block | persisted toolbar shortcut list | S→C | login (part of `0xF7B0`/0x0013 `PlayerDescription`) | `Player_Character.GetShortcuts()` | `ShortCutData` (Index,ObjectId,LayeredSpellId) | **parsed** — `PlayerDescriptionParser.cs:345-356` → `Parsed.Shortcuts` | toolbar |
|
|
||||||
| `PlayerDescription` equipped `InventoryPlacement` list | persisted equipped-gear list | S→C | login | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — equipped section NOT surfaced (PlayerDescriptionParser.cs:70-77) | paperdoll |
|
|
||||||
|
|
||||||
**Shared-message note:** `CreateObject (0xF745)` and `IdentifyObjectResponse (0x00C9)`
|
|
||||||
are used by all three panels and de-duplicated above. `GetAndWieldItem (0x001A)` is
|
|
||||||
shared by inventory (equip-from-grid) and paperdoll (drop-on-doll); `UseItem (0x0036)`/
|
|
||||||
`UseWithTarget (0x0035)` are shared by toolbar activation and inventory double-click.
|
|
||||||
|
|
||||||
### 3.3 acdream wire-gap TODO (the build session's concrete list)
|
|
||||||
|
|
||||||
- **Add C→S builders:** `GetAndWieldItem (0x001A)`, `DropItem (0x001B)`,
|
|
||||||
`NoLongerViewingContents (0x0195)`.
|
|
||||||
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize (0x0197)`,
|
|
||||||
`InventoryRemoveObject`.
|
|
||||||
- **Fix incomplete parsers:** `ParsePutObjInContainer` (read the 4th `containerType`
|
|
||||||
u32); `ParseInventoryServerSaveFailed` (read the `weenieError` u32).
|
|
||||||
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
|
|
||||||
`InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`,
|
|
||||||
`InventoryServerSaveFailed (0x00A0)`.
|
|
||||||
- **Extend `CreateObject.TryParse`** to capture `IconId`, `WeenieClassId`, `StackSize`,
|
|
||||||
`Value`, `ItemCapacity`, `ContainerCapacity` (cells need icon + quantity +
|
|
||||||
capacity bar). **Re-verified discarded at `CreateObject.cs:515-516`.**
|
|
||||||
- **Extend `PlayerDescriptionParser`** to surface the equipped `InventoryPlacement
|
|
||||||
{iid, loc, priority}` list (paperdoll slot icons at login).
|
|
||||||
- **Fix `InventoryActions.BuildAddShortcut` field naming** (currently
|
|
||||||
`slotIndex/objectType/targetId`; wire layout is correct for item shortcuts but
|
|
||||||
semantics should be `Index/ObjectId/SpellId|Layer`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Recommended build order
|
|
||||||
|
|
||||||
Ordered by dependency so the next session can go straight to brainstorm → spec → plan.
|
|
||||||
Each step states why it must come where it does.
|
|
||||||
|
|
||||||
**Step 0 — SPINE research (DONE — see the spine doc).**
|
|
||||||
The spine doc is complete:
|
|
||||||
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
|
|
||||||
It specs the item-cell widget, the 5-layer icon composite (`IconData::RenderIcons`
|
|
||||||
decomp 407524 — each layer a `0x06` RenderSurface decoded directly, blitted into one
|
|
||||||
private 32×32 surface: type-default underlay / custom underlay / base `_iconID` / custom
|
|
||||||
overlay+tint / effect overlay), `UIElement_UIItem::UIItem_Update`/`UIItem_SetIcon`
|
|
||||||
(decomp 230226+), the overlay-state machine, and the widget-level drag-drop hooks
|
|
||||||
(`UIElement_Field::MouseOverTop`/`CatchDroppedItem`). The `+0x5FC` field is resolved
|
|
||||||
(`UIElement_UIItem::itemID`). Steps 2-7 can now proceed against a finished port spec;
|
|
||||||
this is **no longer blocking**. (Note: WB `TextureHelpers.cs` / ACViewer `IndexToColor`
|
|
||||||
are for WORLD textures — item icons take NO subpalette overlay at composite time; see
|
|
||||||
the spine doc.)
|
|
||||||
|
|
||||||
**Step 1 — Window manager foundation.**
|
|
||||||
*Why first among code:* all three panels are pop-up windows that must open/close, stack
|
|
||||||
(z-order), persist position, and (faithfully) drag/resize via Dragbar (Type 2) +
|
|
||||||
Resizebar (Type 9). Vitals/chat shipped with whole-window drag (accepted IA-12
|
|
||||||
approximation); the panels need the real thing. Everything visible in Steps 5-7 mounts
|
|
||||||
inside a managed window, so the manager is the substrate. It is independent of the wire
|
|
||||||
work, so it can proceed in parallel with Step 0.
|
|
||||||
|
|
||||||
**Step 2 — `UiItemSlot` widget + icon pipeline (`UIElement_UIItem` 0x10000032).**
|
|
||||||
*Why here:* it is the atom of all three panels. Depends on Step 0 (icon render) and on
|
|
||||||
the §3.3 `CreateObject` extension (IconId/StackSize) for real data. Build the leaf
|
|
||||||
widget: icon composite, quantity text, capacity/structure Type-7 bars, cooldown ring,
|
|
||||||
and the selection/ghost/drag-accept/open-container overlay states.
|
|
||||||
|
|
||||||
**Step 3 — `UiItemList` / `UiItemGrid` widget (`UIElement_ItemList` 0x10000031).**
|
|
||||||
*Why here:* it composes `UiItemSlot`s and is used by every panel (1-cell and N-cell).
|
|
||||||
Depends on Step 2. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/
|
|
||||||
GetItem/OpenContainer/SetChildList/SetParentContainer`. Register as a behavioral leaf
|
|
||||||
(`ConsumesDatChildren=>true`).
|
|
||||||
|
|
||||||
**Step 4 — Wire wiring (builders/parsers/wireup from §3.3).**
|
|
||||||
*Why here:* the controllers in Steps 5-7 need the full send/receive surface, and this is
|
|
||||||
independent of the widget rendering — it can run in parallel with Steps 1-3. Add the
|
|
||||||
missing builders/parsers, fix the two incomplete parsers, register the unwired parsers,
|
|
||||||
extend `CreateObject` + `PlayerDescriptionParser`, fix `BuildAddShortcut` naming. Each
|
|
||||||
new deviation gets a divergence-register row in the same commit.
|
|
||||||
|
|
||||||
**Step 5 — `ToolbarController` + the action bar (simplest panel).**
|
|
||||||
*Why before the others:* the toolbar is the simplest consumer (18 single-cell lists, no
|
|
||||||
nested sub-windows, no viewport) and exercises the full spine + window manager + wire
|
|
||||||
path end-to-end. acdream already parses the SHORTCUT block and has both shortcut
|
|
||||||
builders, so it's the fastest path to a working, testable panel. Bind the 18 slots,
|
|
||||||
the hidden selected-object meters + stack slider, the panel-launcher buttons; restore
|
|
||||||
from `Parsed.Shortcuts`; wire `UseShortcut`/`AddShortcut`/`RemoveShortcut` +
|
|
||||||
`HandleDropRelease`.
|
|
||||||
|
|
||||||
**Step 6 — `InventoryController` + the inventory/backpack panels + sub-window mount.**
|
|
||||||
*Why here:* adds the N-cell grid (Step 3 at scale), the burden Meter (reuses Type-7
|
|
||||||
`SetLoadLevel`→fill 0x69), the dual-ItemList container model (own `+0x604` / other
|
|
||||||
`+0x608`), and the **sub-window mount** importer capability (frame nests paperdoll +
|
|
||||||
backpack + 3D-items). The hardest 2D panel; depends on Steps 1-4 and the new sub-window
|
|
||||||
mount.
|
|
||||||
|
|
||||||
**Step 7 — `UiViewport` + `PaperDollController` + the equipment doll (biggest new piece).**
|
|
||||||
*Why last:* it depends on everything above (window manager, equip-slot `UiItemList`
|
|
||||||
instances, `GetAndWieldItem` wire, `PlayerDescription` equipped list) AND introduces the
|
|
||||||
single largest new engineering item: the **UI↔3D render seam** (`IUiViewportRenderer`
|
|
||||||
Core interface, App impl, per Code-Structure Rule 2) that renders a re-dressed player
|
|
||||||
clone into a scissored UI rect. It reuses acdream's existing `EntitySpawnAdapter`/
|
|
||||||
`AnimatedEntityState` character path, but the rect-scissored single-entity pass is new.
|
|
||||||
Doing it last lets the 2D panels validate the spine first, so a 3D-render bug is
|
|
||||||
isolated.
|
|
||||||
|
|
||||||
**Parallelism summary:** Step 0 (spine research) + Step 1 (window manager) + Step 4
|
|
||||||
(wire) can all proceed independently; Steps 2→3→5→6→7 are the dependent spine→panels
|
|
||||||
chain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Open risks / UNVERIFIED — resolve BEFORE implementation
|
|
||||||
|
|
||||||
Collated from all four docs; each needs a decomp or cdb follow-up before the cited step.
|
|
||||||
|
|
||||||
1. **SPINE doc — RESOLVED (no longer blocking).** Written:
|
|
||||||
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
|
|
||||||
Icon-composite render (`IconData::RenderIcons` 407524, 5 layers) + the widget-level
|
|
||||||
drag-drop state machine (`UIElement_Field::MouseOverTop`/`CatchDroppedItem`, cell msgs
|
|
||||||
`0x21`/`0x3e`/`0x15`, `InqDropIconInfo` flags) are now specced with anchors.
|
|
||||||
2. **`UIElement_UIItem +0x5FC` bound-object-id field name — RESOLVED = `itemID`.**
|
|
||||||
`UIElement_UIItem::itemID`, anchored at `UIItem_Update` decomp 230230
|
|
||||||
(`uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`), corroborated 230422/233107
|
|
||||||
(companion field `spellID`). See the spine doc.
|
|
||||||
3. **`CreateObject` IconId for CONTAINED (non-3D-visible) pack items — LIKELY.**
|
|
||||||
Confirmed on the wire + currently discarded (`CreateObject.cs:516`), but not
|
|
||||||
byte-traced that ACE sets IconId on a *contained* item's CreateObject vs. relying on
|
|
||||||
PlayerDescription. Verify against a live capture before treating CreateObject as the
|
|
||||||
sole icon source.
|
|
||||||
4. **Use-item opcode `ItemHolder::UseObject` sends (0x0035 vs 0x0036) — UNVERIFIED.**
|
|
||||||
Throttle (0.2 s) + dispatch CONFIRMED (decomp 402923); the precise opcode branch
|
|
||||||
(`DetermineUseResult`) not traced to the send. Both opcodes exist in acdream
|
|
||||||
`InteractRequests.cs`; reconcile when wiring toolbar/inventory activation (Step 5/6).
|
|
||||||
5. **`UseShortcut` target-mode path — out of scope, file follow-up.**
|
|
||||||
`ClientUISystem::ExecuteTargetModeForItem` (use-item-on-target) depends on the cursor
|
|
||||||
target-mode subsystem; not part of the action-bar widget itself.
|
|
||||||
6. **`SetDelayedShortcutNum` deferral — needs a re-bind state machine.** When a slot's
|
|
||||||
weenie isn't loaded yet (`AddShortcut` decomp 196867), the slot must re-bind once
|
|
||||||
`CreateObject` for that guid arrives. Detail in the `ToolbarController` port (Step 5).
|
|
||||||
7. **Paperdoll `0x100001E0` = MissileAmmo `0x800000` — LIKELY only.** The decomp
|
|
||||||
immediate is corrupted to a string-ptr (line 173676); inferred from the EquipMask gap
|
|
||||||
+ neighbors. Re-decompile `0x004a388a` in Ghidra to recover the real value (Step 7).
|
|
||||||
8. **Paperdoll viewport camera/light float immediates — UNVERIFIED (not byte-decoded).**
|
|
||||||
Lines 175524-175526 / 174144-174146; the agent read the hex but did not convert all
|
|
||||||
floats (`0x3df5c28f≈0.12`, `0xc019999a≈−2.4`, `0xc0400000=−3.0`, `0xc059999a≈−3.4`,
|
|
||||||
`0x3f6147ae≈0.88`, `0x3f800000=1.0`). Decode precisely for faithful framing (Step 7).
|
|
||||||
9. **UI↔3D render seam — DESIGN-OPEN.** How a UI rect drives a scissored single-entity
|
|
||||||
3D pass (after the world pass vs. as a UI overlay), and the exact
|
|
||||||
`IUiViewportRenderer` Core-interface shape (Code-Structure Rule 2). Brainstorm before
|
|
||||||
Step 7.
|
|
||||||
10. **Does the doll clone the player `WorldEntity` or build a fresh one? — UNVERIFIED.**
|
|
||||||
Retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`,
|
|
||||||
line 173999); acdream has no player-as-renderable today (player = camera). LIKELY a
|
|
||||||
dedicated `WorldEntity` from the local player's Setup+ObjDesc fed to a private
|
|
||||||
viewport host. Settle in Step 7 brainstorm.
|
|
||||||
11. **Inventory side-pack column `0x100001CB` (16×252, base `0x2100003E`) — UNVERIFIED.**
|
|
||||||
Tabs (one per sub-bag) or a scrollbar gutter? Dump `0x2100003E` to settle (Step 6).
|
|
||||||
12. **`UIElement_ItemList` grid geometry (column count, cell pitch) — LIKELY.** Cell
|
|
||||||
template 36×36 (`0x100001C9`); `UIElement_UIItem` `0x21000037` is 32×32. Confirm the
|
|
||||||
fixed-column wrap by reading `UIElement_ItemList::ItemList_AddItem` (Step 3).
|
|
||||||
13. **Value/coin total NOT in the inventory window — UNVERIFIED home.** No value Meter/
|
|
||||||
text in `0x21000022`/`0x21000023`; the window shows BURDEN only. Do NOT invent a
|
|
||||||
value summary; find its real home before adding one.
|
|
||||||
14. **Identified-vs-unidentified does NOT swap the icon — CONFIRMED (negative).** The
|
|
||||||
spine doc confirms there is no appraise branch anywhere in the icon path
|
|
||||||
(`UIItem_SetIcon` → `IconData::RenderIcons`); appraise gates `UpdateTooltip` only.
|
|
||||||
15. **`InventoryActions.BuildAddShortcut` field-naming bug — CONFIRMED file contents,
|
|
||||||
LIKELY latent bug.** Wire layout is correct for item shortcuts; the param names
|
|
||||||
(`slotIndex/objectType/targetId`) are misleading. Fix to `Index/ObjectId/
|
|
||||||
SpellId|Layer` + register a divergence row at port time (Step 4/5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Proposed MEMORY.md index lines (for ALL 5 docs)
|
|
||||||
|
|
||||||
The parent will append these; I do NOT edit MEMORY.md.
|
|
||||||
|
|
||||||
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives. Big-three new widgets: `UiItemSlot`(`UIElement_UIItem` 0x10000032, shared leaf), `UiItemList/Grid`(`UIElement_ItemList` 0x10000031, shared leaf-to-importer), `UiViewport`(Type 0xD, paperdoll 3D doll), plus the shared **window manager** (Dragbar 2 + Resizebar 9) + sub-window-mount importer capability + per-panel controllers. De-duped cross-panel wire table; build order (window mgr → item-slot+icon → item-list → wire → toolbar → inventory → paperdoll; spine research DONE).
|
|
||||||
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, `+0x5FC` RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX: each layer is a `0x06` RenderSurface decoded DIRECTLY, but the icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524; NOT one texture, NOT appraise-gated). Drag-drop = `Field::MouseOverTop`/`CatchDroppedItem` + cell msgs 0x21/0x3e/0x15 + `InqDropIconInfo` flags; `UiRoot` already has the chain, `UiField` only stubs the hooks; gap = `CreateObject` discards IconId (cs:516).
|
|
||||||
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease`. New widgets: `UiItemSlot` + `UiItemList` + `ToolbarController`.
|
|
||||||
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.
|
|
||||||
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.
|
|
||||||
- [Action-bar/inventory/equipment research handoff](research/2026-06-16-action-bar-inventory-equipment-handoff.md) — the §3 question list (Q1-Q12) + agent assignment that drove the toolbar/inventory/paperdoll/spine deep-dives. (All 5 research docs delivered; the spine doc was completed in a follow-up pass after a transient agent failure.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. New toolkit widgets this introduces (recap)
|
|
||||||
|
|
||||||
| Widget | dat Type / class it registers at | leaf vs container | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `UiItemSlot` | class `0x10000032` (`UIElement_UIItem`) | leaf (`ConsumesDatChildren=>true`) | shared item-cell: icon + quantity + capacity/structure bars + overlay states + bound object id |
|
|
||||||
| `UiItemList` / `UiItemGrid` | class `0x10000031` (`UIElement_ItemList`) | leaf to importer; container of slots at runtime | shared 1-cell/N-cell grid of `UiItemSlot`s |
|
|
||||||
| `UiViewport` | numeric Type `0xD` (`UIElement_Viewport`) | leaf (`ConsumesDatChildren=>true`) | paperdoll 3D character doll via a scissored mini 3D pass; needs `IUiViewportRenderer` Core→App seam |
|
|
||||||
| Window manager | infra (drives Dragbar Type 2 + Resizebar Type 9) | n/a | open/close/z-order/persist + faithful grip/dragbar drag-resize for all pop-up panels |
|
|
||||||
| Sub-window mount | LayoutImporter capability (element whose Type is a high `0x10000xxx` class with a `BaseLayoutId`) | container | nest a `LayoutDesc` window inside a parent slot (inventory frame → paperdoll/backpack/3D-items) |
|
|
||||||
|
|
||||||
## 8. Open questions / UNVERIFIED (recap)
|
|
||||||
|
|
||||||
See §5 for the full collated list with anchors. The former blocking item — the spine
|
|
||||||
doc — is now written (icon-composite render path + widget-level drag-drop state machine
|
|
||||||
specced with anchors; `+0x5FC` = `itemID` resolved). Remaining items are per-step
|
|
||||||
follow-ups (decode the paperdoll camera floats, recover `0x100001E0`, dump
|
|
||||||
`0x2100003E`, byte-trace CreateObject IconId for contained items).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Single MEMORY.md index line for THIS doc:**
|
|
||||||
|
|
||||||
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives: shared `UiItemSlot`(0x10000032)+`UiItemList`(0x10000031) spine, `UiViewport`(Type 0xD) for the paperdoll 3D doll, window manager (Dragbar 2 + Resizebar 9) + sub-window-mount; de-duped cross-panel wire table; build order window-mgr→item-slot+icon→item-list→wire→toolbar→inventory→paperdoll (spine research DONE — see the spine deep-dive).
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
# Stateful item-icon system — RESEARCH RESOLVED (the build basis for D.5.2)
|
|
||||||
|
|
||||||
**Date:** 2026-06-17
|
|
||||||
**Supersedes the key hypotheses in** `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
|
|
||||||
**Method:** grep-named → cross-ref (ACE/ACViewer/Chorizite) → clean Ghidra decompile
|
|
||||||
(MCP, PDB-applied `patchmem.gpr`) → live-dat probe. Each decomp claim adversarially
|
|
||||||
verified against source.
|
|
||||||
|
|
||||||
This doc records the **definitive** answers. Two handoff hypotheses were **wrong**; both
|
|
||||||
are corrected here with evidence.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Data-availability — SETTLED (handoff's "DO THIS FIRST" question)
|
|
||||||
|
|
||||||
**The icon ids and the effect bitfield arrive ONLY on `CreateObject`. Appraise carries
|
|
||||||
NONE of them.** Definitive from the ACE oracle (the user's own server):
|
|
||||||
|
|
||||||
- `references/ACE/.../Enum/Properties/PropertyDataId.cs:5-7` (verbatim):
|
|
||||||
*"No properties are sent to the client unless they featured an attribute. … AssessmentProperty
|
|
||||||
gets sent in successful appraisal."*
|
|
||||||
- `Icon = 8`, `IconOverlay = 50`, `IconUnderlay = 52` — **no `[AssessmentProperty]`** → never in
|
|
||||||
appraise (nor `[SendOnLogin]` → never in PlayerDescription property tables).
|
|
||||||
- `PropertyInt.UiEffects = 18` — **no `[AssessmentProperty]`** (`PropertyInt.cs:34`; the
|
|
||||||
research-agent claim that it has the attribute was a **fabrication**, caught by the verifier).
|
|
||||||
- `AppraiseInfo.Write` serializes only the attributed `PropertiesInt/PropertiesDID/…` tables +
|
|
||||||
the profile blobs — **no icon / UiEffects field anywhere**.
|
|
||||||
|
|
||||||
Wire path for every icon input (all on the `CreateObject` weenie header, ACE
|
|
||||||
`WorldObject_Networking.cs` + `PublicWeenieDesc::Pack` decomp `442421/442489/442628/442631`):
|
|
||||||
|
|
||||||
| Field | weenie-flag gate | acdream status |
|
|
||||||
|---|---|---|
|
|
||||||
| `_iconID` | always | captured (D.5.1) |
|
|
||||||
| `_iconOverlayID` | weenieFlags `0x40000000` | captured (D.5.1) |
|
|
||||||
| `_iconUnderlayID` | weenieFlags2 `0x01` | captured (D.5.1) |
|
|
||||||
| `_effects` (UiEffects) | weenieFlags `0x80` | **read + DISCARDED** at `CreateObject.cs:669` |
|
|
||||||
|
|
||||||
**Consequence (corrects handoff §3.3/§3.4 + §5.4):** the pinned scroll shows no overlay because
|
|
||||||
acdream **discards `UiEffects`** and never builds the effect treatment — NOT because the data is
|
|
||||||
appraise-gated. **The handoff's "wire appraise → enrichment" item is a no-op**: appraise never
|
|
||||||
carries this data, and acdream never even *sends* an `AppraiseRequest` (`AppraiseRequest.Build`
|
|
||||||
exists but has zero call sites). The live "mana vs out-of-mana" re-trigger is a future
|
|
||||||
`PrivateUpdateInt(UiEffects=18)` (the `0x02CD` property-update block, inventory/M2 phase), feeding
|
|
||||||
the same re-composition contract — NOT appraise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer — SETTLED
|
|
||||||
|
|
||||||
Clean Ghidra decompile of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor`
|
|
||||||
(`0x00441530`) resolves the Binary-Ninja register/calling-convention artifacts the handoff and the
|
|
||||||
spine doc flagged UNVERIFIED.
|
|
||||||
|
|
||||||
**`SurfaceWindow::ReplaceColor(this, RGBAColor src, RGBAColor dest)`** = for each pixel `==
|
|
||||||
GetColor32(src)`, set it to `GetColor32(dest)`. A flat single-color → single-color replace.
|
|
||||||
|
|
||||||
**`RenderIcons` builds two surfaces (bottom→top):**
|
|
||||||
|
|
||||||
```
|
|
||||||
m_pDragIcon (32x32):
|
|
||||||
Blit base icon (m_idIcon) mode Blit_Normal (opaque)
|
|
||||||
Blit custom overlay (m_idOverlayID) mode Blit_4Alpha
|
|
||||||
if (effectTile != null): # effectTile = GetByEnum(0x10000005, …)
|
|
||||||
ReplaceColor(this, src = WHITE(1,1,1,1), dest = <color from effectTile>)
|
|
||||||
|
|
||||||
m_pIcon (32x32):
|
|
||||||
Blit type-default underlay (GetByEnum 0x10000004, lsb(itemType)+1, fb 0x21) Blit_Normal (opaque)
|
|
||||||
Blit custom underlay (m_idUnderlayID) Blit_3Alpha
|
|
||||||
Blit m_pDragIcon Blit_3Alpha
|
|
||||||
```
|
|
||||||
|
|
||||||
- The **effect tile is NEVER blitted** (it's the `ReplaceColor` `dest`-color source). The dat probe
|
|
||||||
confirms why: every `enum 0x10000005` entry is a **32×32 FULLY-OPAQUE** colored tile
|
|
||||||
(`opaque=1024, transp=0`) — blitting one on top would erase the icon.
|
|
||||||
- `src` color = `RGBAColor(1,1,1,1)` → `GetColor32` → `0xFFFFFFFF` (pure-white, full alpha). So
|
|
||||||
**only pure-white-opaque pixels recolor** — the effect is the recolor of the icon/overlay's white
|
|
||||||
highlights to the effect hue. Subtle, data-dependent.
|
|
||||||
- **Effect index:** `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj is null,
|
|
||||||
fallback index `0x21`. NOTE retail has **no** `lsb==-1 → 0x21` pre-check on the effect path (unlike
|
|
||||||
the type-underlay path), so `_effects==0` → index 0 → null → fallback `0x21` (the SOLID-BLACK tile).
|
|
||||||
- **UpdateIcons dirty-check** (`0x0058da…`, decomp `407962`): re-render on change of
|
|
||||||
`iconID / overlayID / underlayID / itemType / _effects`. acdream's per-tuple icon cache keyed on
|
|
||||||
exactly these IS the re-composition contract.
|
|
||||||
|
|
||||||
### The one residual ambiguity (decompiler-bounded)
|
|
||||||
The exact byte `ReplaceColor`'s `dest` color is read from is `effectTile + 0xac` (= the effect tile's
|
|
||||||
`SurfaceWindow` header) reinterpreted as `RGBAColor` — both BN and Ghidra leave this as a struct
|
|
||||||
read neither types cleanly. It is NOT pixel data and NOT a clean field either decompiler resolves.
|
|
||||||
**Faithful resolution:** the effect tiles are purpose-built per-effect colored tiles, so the effect
|
|
||||||
color = the tile's own representative (mean opaque) color. This is intent-faithful, not a guess about
|
|
||||||
an unknown constant. Flagged for cdb/visual confirmation. (Register row + visual gate.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `enum 0x10000005` effect submap — golden values (live dat, MasterMap `0x25000000` → submap `0x25000009`)
|
|
||||||
|
|
||||||
`index = LowestSetBit(UiEffects)+1`; submap has 14 entries (idx 0–12 + `0x21` fallback):
|
|
||||||
|
|
||||||
| UiEffects bit | name | idx | effect tile DID | tile mean RGB |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 0x0001 | Magical | 1 | `0x060011CA` | blue (53,70,212) |
|
|
||||||
| 0x0002 | Poisoned | 2 | `0x060011C6` | green (79,204,34) |
|
|
||||||
| 0x0004 | BoostHealth | 3 | `0x06001B05` | red (213,57,59) |
|
|
||||||
| 0x0008 | BoostMana | 4 | `0x060011CA` | blue |
|
|
||||||
| 0x0010 | BoostStamina | 5 | `0x06001B06` | yellow (223,206,21) |
|
|
||||||
| 0x0020 | Fire | 6 | `0x06001B2E` | orange |
|
|
||||||
| 0x0040 | Lightning | 7 | `0x06001B2D` | purple |
|
|
||||||
| 0x0080 | Frost | 8 | `0x06001B2F` | cyan-grey |
|
|
||||||
| 0x0100 | Acid | 9 | `0x06001B2C` | green |
|
|
||||||
| 0x0200 | Bludgeoning | 10 | `0x060033C3` | grey |
|
|
||||||
| 0x0400 | Slashing | 11 | `0x060033C2` | pink-grey |
|
|
||||||
| 0x0800 | Piercing | 12 | `0x060033C4` | tan |
|
|
||||||
| 0x1000 | Nether | 13 | *(absent)* → fallback | → `0x060011C5` |
|
|
||||||
| — | (`_effects==0`) | 0 | *(zero)* → fallback | → `0x060011C5` (SOLID black) |
|
|
||||||
| — | fallback | 0x21 | `0x060011C5` | SOLID 0xFF000000 |
|
|
||||||
|
|
||||||
(Cross-check, `enum 0x10000004` type-underlay, already shipped + golden-tested: Melee→`0x060011CB`,
|
|
||||||
Armor→`0x060011CF`, Clothing→`0x060011F3`, Jewelry→`0x060011D5`, fallback `0x21`→`0x060011D4`.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Build decisions (D.5.2)
|
|
||||||
|
|
||||||
1. **Capture `UiEffects`** from `CreateObject` → `ItemInstance.Effects`; thread through
|
|
||||||
`EntitySpawn` → `EnrichItem`.
|
|
||||||
2. **`IconComposer`: faithful 2-stage composite** (drag = base+overlay+recolor; slot =
|
|
||||||
typeUnderlay+customUnderlay+drag). New `ResolveEffectDid` mirrors the proven `ResolveUnderlayDid`.
|
|
||||||
`GetIcon` + cache key widened to include `effects`.
|
|
||||||
3. **Effect recolor** applied only when `_effects != 0` (the meaningful case). Retail nominally runs
|
|
||||||
the `_effects==0` black-fallback recolor too; we **skip** it — recoloring white→black on every
|
|
||||||
item is a likely visual no-op (few pure-white pixels) but a real regression risk; documented
|
|
||||||
divergence pending visual/cdb confirmation.
|
|
||||||
4. **DROP the appraise-enrichment item** (no-op — §1). The re-composition contract
|
|
||||||
(`ItemPropertiesUpdated` → widget re-resolve) is already wired; its future trigger is
|
|
||||||
`PrivateUpdateInt(UiEffects)`, filed for the property-update phase.
|
|
||||||
5. **Conformance**: golden `ResolveEffectDid` test (the §3 values) + a dat-free recolor test.
|
|
||||||
6. **Register**: retire `IA-16`; add rows for effect-as-recolor, the `_effects==0` skip, and the
|
|
||||||
representative-color approximation.
|
|
||||||
|
|
||||||
**MEMORY.md index line:**
|
|
||||||
- [Research: stateful icon RESOLVED (2026-06-17)](research/2026-06-17-stateful-icon-RESOLVED.md) — definitive basis for D.5.2. Appraise carries NO icon/UiEffects (ACE `[AssessmentProperty]` proof); all icon inputs are CreateObject-only (UiEffects weenieFlags 0x80, discarded at CreateObject.cs:669). Effect overlay (enum 0x10000005) is a `ReplaceColor(white→effectColor)` SOURCE, NOT a blit layer (Ghidra `RenderIcons`@0x0058d180 + `ReplaceColor`@0x00441530). Golden effect-submap values + the 2-stage composite. Corrects the handoff's appraise + blit-layer hypotheses.
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
# Handoff — the FULL stateful item-icon system (next session)
|
|
||||||
|
|
||||||
**Date:** 2026-06-17
|
|
||||||
**From:** the D.5.1 toolbar session (the action bar shipped; its icon compositor is **partial**).
|
|
||||||
**Purpose:** build the **complete, retail-faithful, stateful item-icon system** — the multi-layer icon composite that reflects an item's *current state* (charged/enchanted/etc.), driven by both `CreateObject` and `Appraise`. This is **shared infrastructure**: the inventory, equipment/paperdoll, vendor, and trade panels all render item icons, so it must be solved properly once, here, before those panels are built.
|
|
||||||
|
|
||||||
This doc is the entry point. The new-session prompt is at the bottom (§10).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. TL;DR
|
|
||||||
|
|
||||||
A retail item icon is **not one sprite** — it's a runtime composite of **up to 5 layers** (`IconData::RenderIcons`, decomp `acclient_2013_pseudo_c.txt:407524` / `0058d180`), and **which layers apply depends on the item's live state** (item type, magic underlay, overlay tint, and the `_effects` bitfield). The D.5.1 toolbar built layers 1–4 of the composite and the `CreateObject` parse for the base/overlay/underlay ids — but the **effect layer (5), the overlay tint, and the appraise-driven state updates are missing**, which is why the user's pinned scroll still shows no overlay. The user is correct: "an item *with* mana vs *out of* mana shows a different icon" — that's exactly the stateful layer system. Build it fully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The retail icon model (the oracle: `IconData::RenderIcons`)
|
|
||||||
|
|
||||||
`IconData::RenderIcons(IconData* this, ACCWeenieObject* obj)` — decomp `407524` (`0058d180`). It builds the on-screen icon by blitting layers **bottom → top** into one private 32×32 surface:
|
|
||||||
|
|
||||||
| # | Layer | Source | Blit | Driven by | Status |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 1 | **type-default underlay** (the opaque background tile) | `DBObj::GetByEnum(0x10000004, LowestSetBit(itemType)+1)`, fallback index `0x21` | `Blit_Normal` (opaque) | the item's `ItemType` | ✅ **built** (D.5.1) |
|
|
||||||
| 2 | **custom underlay** ("has magic") | `_iconUnderlayID` | `Blit_3Alpha` | item has an underlay id | ✅ parse+composite built |
|
|
||||||
| 3 | **base icon** | `_iconID` | `Blit_Normal` | always | ✅ built |
|
|
||||||
| 4 | **custom overlay** ("enchanted") | `_iconOverlayID` + `SurfaceWindow::ReplaceColor` **tint** | `Blit_3Alpha` | item has an overlay id | ⚠️ overlay sprite composited, **tint NOT applied** |
|
|
||||||
| 5 | **effect overlay** (the magic glow/state) | `DBObj::GetByEnum(0x10000005, LowestSetBit(_effects)+1)` | blit | the item's **`_effects`** bitfield (Magical/Enchanted/…) | ❌ **NOT built** |
|
|
||||||
|
|
||||||
Plus a special case at `407546` (`0058d1ee`): **`IsThePlayer`** → `m_idIcon = GetDIDByEnum(0x10000004, 7)`, `itemType = TYPE_CONTAINER (0x200)` — the player's own paperdoll icon. Out of scope for the toolbar; **needed for the paperdoll**.
|
|
||||||
|
|
||||||
### The enum-mapper resolve chain (already wired for 0x10000004)
|
|
||||||
`GetByEnum(enumId, index)` → `DBCache::GetDIDFromEnum` (`0x413940`): `master[enumId] → submapDID`; `submap[index] → the 0x06 RenderSurface DID`. DatReaderWriter exposes the mapper as **`EnumIDMap`** (`DB_TYPE_DID_MAPPER`); the master map DID is `_dats.Portal.Header.MasterMapId` (**= 0x25000000**, confirmed live). For the underlay: `master[0x10000004] = submap 0x25000008` (34 entries). **For the effect layer you need `master[0x10000005]`** (not yet read). `EnumIDMap.ClientEnumToID` is `IReadOnlyDictionary<uint,uint>`; each layer DID is a `0x06` RenderSurface decoded directly by `SurfaceDecoder.DecodeRenderSurface`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. What D.5.1 already built (read this code first)
|
|
||||||
|
|
||||||
- **`src/AcDream.App/UI/IconComposer.cs`** — the CPU compositor. `Compose(layers)` = alpha-over, sizes to layer 0. `GetIcon(ItemType, iconId, underlayId, overlayId)` resolves the **type-default underlay** (`ResolveUnderlayDid` + `EnsureUnderlaySubMap`, via `EnumIDMap` master→`0x10000004`→submap), prepends it as the opaque layer 0, then composites custom-underlay + base + custom-overlay, caches by the `(typeUnderlayDid, iconId, underlayId, overlayId)` tuple, uploads via `TextureCache.UploadRgba8`. **Layer order + the underlay are faithful** (golden test `ResolveUnderlayDid_goldenValues_matchDat` passes against the live dat).
|
|
||||||
- **`src/AcDream.Core.Net/Messages/CreateObject.cs`** — `TryParse` now walks the **full** weenie-header optional tail (in exact ACE order, verified against `references/ACE/.../WorldObject_Networking.cs`) and captures `IconId`, `IconOverlayId` (weenieFlags `0x40000000`), `IconUnderlayId` (weenieFlags2 `0x01`). It reads `UiEffects` (weenieFlags `0x80`) but **discards it** — capturing it is part of this next phase. RestrictionDB skip is length-aware + tested.
|
|
||||||
- **`src/AcDream.Core/Items/ItemInstance.cs`** — has `IconId`, `IconUnderlayId`, `IconOverlayId`, `Type`. **No `Effects`/`UiEffects` field yet.**
|
|
||||||
- **`src/AcDream.Core/Items/ItemRepository.cs`** — `EnrichItem(objectId, iconId, name, type, iconOverlayId=0, iconUnderlayId=0)` writes the typed icon ids onto an existing item + fires `ItemPropertiesUpdated`. Threaded from `WorldSession.EntitySpawned` → `GameWindow.OnLiveEntitySpawned`.
|
|
||||||
- **`src/AcDream.App/UI/Layout/ToolbarController.cs`** — calls `iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId)` per slot, re-runs `Populate()` on `ItemRepository.ItemAdded`/`ItemPropertiesUpdated` (so a late `CreateObject` re-binds the slot's icon).
|
|
||||||
- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied peace/war `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. What's MISSING (the next session's work)
|
|
||||||
|
|
||||||
1. **Layer 5 — the effect overlay (`_effects`).** Capture the item's `_effects`/`UiEffects` bitfield (CreateObject reads `UiEffects` at weenieFlags `0x80` but discards it — keep it; also it may be the appraise-only `PropertyInt.UiEffects`). Add an `Effects` field to `ItemInstance`. In `IconComposer`, resolve `GetByEnum(0x10000005, LowestSetBit(effects)+1)` (the second enum submap, `master[0x10000005]`) and composite it as the top layer. Widen `GetIcon` + the cache key to include effects. **This is the user's "mana vs out-of-mana" layer** and the most likely cause of the scroll's missing overlay (if its distinctive look is the effect glow, not a static `_iconOverlayID`).
|
|
||||||
2. **Layer 4 tint — `SurfaceWindow::ReplaceColor`.** The custom overlay is composited as a plain sprite; retail applies a per-pixel palette `ReplaceColor` tint (`407614`). Port the tint (it's a palette-index color replace — see `ACViewer TextureCache.IndexToColor` for the subpalette-overlay technique, though confirm it's the right op for icons).
|
|
||||||
3. **Appraise-driven enrichment + RE-COMPOSITION.** The icon must update when the item's icon-relevant properties change. `IdentifyObjectResponse` (`0x00C9`, `AppraiseInfoParser` / `GameEventWiring`) currently updates the `PropertyBundle` only — it does **not** update the typed `IconId/Overlay/Underlay/Effects`. Wire appraise → update those typed fields → `ItemPropertiesUpdated` → the bound widget re-resolves the icon (the cache key already changes when an id changes, so a new composite is produced). **This is the other likely cause of the scroll's blank overlay**: the overlay/effects ids may only arrive at appraise, not on the bare `CreateObject`.
|
|
||||||
4. **Settle the data-availability question (DO THIS FIRST — it's a 10-min capture).** Does ACE send `IconOverlay`/`UiEffects` on a *contained* (in-pack, un-appraised) item's `CreateObject`, or only at appraise? Capture the scroll's `0xF745 CreateObject` **and** its `0x00C9 IdentifyObjectResponse` with WireMCP (`mcp__wiremcp__*`, loopback `127.0.0.1:9000`) and log `CreateObject.Parsed.IconOverlayId/IconUnderlayId` at runtime. The answer decides whether the fix is "just build layer 5" (data already on CreateObject) or "build layer 5 + appraise enrichment" (data is appraise-gated). **Don't guess — capture.**
|
|
||||||
5. **The `IsThePlayer` container icon** (paperdoll) — `GetDIDByEnum(0x10000004, 7)` + `TYPE_CONTAINER`. Needed when the paperdoll renders the player's own icon.
|
|
||||||
6. **Identified-vs-unidentified does NOT swap the icon** (confirmed last session): appraise gates *tooltip* detail, not the base icon. So the icon layers come from the item's real props (sent on CreateObject and/or appraise), not an "identified" toggle. Don't add an appraise-gated icon variant.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The user's framing (their words are the spec)
|
|
||||||
|
|
||||||
> "the icon system in AC consists of several icons making up an icon. For example an item with mana has a different icon from the same item that is out of mana."
|
|
||||||
|
|
||||||
Correct, and it maps exactly onto the model above: the **`_effects` bitfield** (and the underlay/overlay ids) reflect the item's current state, and `RenderIcons` composites the corresponding layers. "With mana vs out of mana" = the effect/underlay layers present vs absent → **the icon must re-compose when that state changes** (§3.3). Build the system so the displayed icon is always a function of the item's *current* properties, updated on every relevant property change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Research questions for the next session
|
|
||||||
|
|
||||||
1. **`_effects` source + layout.** Is the icon effect bitfield the `CreateObject` `UiEffects` (weenieFlags `0x80`), the appraise `PropertyInt.UiEffects`, or both? What are its bit values (Magical/Enchanted/…)? (grep the decomp + ACE `PropertyInt`/`UiEffects` + `IconData::RenderIcons` `_effects` use at `407575`.)
|
|
||||||
2. **`master[0x10000005]` submap** — read it from the live dat (mirror the confirmed `0x10000004` resolve); enumerate its entries (index → effect-overlay `0x06` DID). Add a golden test like the underlay one.
|
|
||||||
3. **The `ReplaceColor` tint** — what color/palette does layer 4 tint with, and is it a straight palette-index replace? Cross-ref `SurfaceWindow::ReplaceColor` (decomp) + ACViewer.
|
|
||||||
4. **Appraise → icon fields** — exactly which `IdentifyObjectResponse` / `AppraiseInfo` fields carry `IconOverlay`/`IconUnderlay`/`UiEffects` (cross-ref ACE `AppraiseInfo` serialization + Chorizite). Wire them to update `ItemInstance` typed fields.
|
|
||||||
5. **Data-availability capture** (§3.4) — the WireMCP result for the scroll.
|
|
||||||
6. **Re-composition trigger** — confirm `ItemPropertiesUpdated` → widget re-resolve is sufficient (it is for the toolbar; verify the inventory/paperdoll widgets will subscribe the same way).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. References (cross-reference ≥2 per question)
|
|
||||||
|
|
||||||
- **Named decomp** `docs/research/named-retail/acclient_2013_pseudo_c.txt`: `IconData::RenderIcons` (407524), `ACCWeenieObject::GetIconData` (408224), `DBCache::GetDIDFromEnum` (0x413940), `EnumIDMap::EnumToDID` (0x415970), `SurfaceWindow::ReplaceColor` (~407614). Headers: `acclient.h` (IconData / ACCWeenieObject struct).
|
|
||||||
- **This session's research** (the icon facts are anchored here): `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` (the 5-layer composite, the RenderSurface-direct decode), the D.5.1 spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`.
|
|
||||||
- **ACE** `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` (CreateObject field order), `.../Network/Structure/AppraiseInfo*.cs` (appraise fields), `ACE.Entity/Enum/PropertyInt.cs` (UiEffects).
|
|
||||||
- **ACViewer** `references/ACViewer/ACViewer/Render/TextureCache.cs` (IndexToColor / subpalette overlay) — for the layer-4 tint + icon decode.
|
|
||||||
- **Chorizite.ACProtocol** `.../Messages/` — PublicWeenieDesc + appraise field order.
|
|
||||||
- **DatReaderWriter** (nuget): `EnumIDMap` (DB_TYPE_DID_MAPPER), `RenderSurface`, `DatHeader.MasterMapId`.
|
|
||||||
- **D.2b memory crib**: `claude-memory/project_d2b_retail_ui.md` (the toolkit + the RenderSurface-vs-Surface decode gotcha; START-HERE for UI work).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Files involved
|
|
||||||
|
|
||||||
- `src/AcDream.App/UI/IconComposer.cs` — add the effect layer (`0x10000005`), the overlay tint, widen `GetIcon`/cache for effects.
|
|
||||||
- `src/AcDream.Core/Items/ItemInstance.cs` — add `Effects` (+ any other state fields the icon needs).
|
|
||||||
- `src/AcDream.Core.Net/Messages/CreateObject.cs` — capture `UiEffects` (already read, currently discarded) onto `Parsed`.
|
|
||||||
- `src/AcDream.Core.Net/WorldSession.cs` (`EntitySpawn` record) + `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawned`) — thread `UiEffects` through.
|
|
||||||
- `src/AcDream.Core/Items/ItemRepository.cs` — `EnrichItem` carry effects; **appraise enrichment** path.
|
|
||||||
- The appraise handler — `src/AcDream.Core.Net/GameEventWiring.cs` / `AppraiseInfoParser` — update typed icon fields on `0x00C9`.
|
|
||||||
- `src/AcDream.App/UI/UiItemSlot.cs` / `ToolbarController.cs` — already re-resolve on `ItemPropertiesUpdated`; no change expected (verify).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. New toolkit/API shape this introduces
|
|
||||||
|
|
||||||
- **`IconComposer.GetIcon` becomes the single stateful icon entry point** — input is the item's full icon state `(ItemType, iconId, underlayId, overlayId, effects [, isPlayer])`; output is the composited GL texture; cache keyed by the full state tuple. Every item panel calls this.
|
|
||||||
- **`ItemInstance` carries the full icon state** (`IconId/Underlay/Overlay/Effects/Type`), updated from BOTH `CreateObject` and `Appraise`.
|
|
||||||
- **One re-composition contract**: any change to an item's icon state → `ItemRepository.ItemPropertiesUpdated` → bound `UiItemSlot` re-calls `GetIcon` (new state tuple → new composite). The toolbar already follows this; inventory/paperdoll reuse it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Related (separate) next toolbar work — NOT this handoff, but flagged
|
|
||||||
|
|
||||||
The toolbar still needs **interactivity** beyond click-to-use (tracked separately in `docs/ISSUES.md`):
|
|
||||||
- It is the **selected-object display** — the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line show the object currently **selected in the world** (wire the B.4 `WorldPicker`/selection state → those elements).
|
|
||||||
- Click-to-use ✅ and peace/war stance indicator + slot-number recolor ✅ are done.
|
|
||||||
This is a distinct feature from the icon system; do the icon system first (it's the shared dependency).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Build the **FULL stateful item-icon system** for acdream (shared by inventory/equipment/vendor/trade — needed before those panels). **Read the handoff first: `docs/research/2026-06-17-stateful-icon-system-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md` and `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
|
|
||||||
>
|
|
||||||
> The D.5.1 toolbar built layers 1–4 of the retail icon composite (`IconData::RenderIcons` @407524) + the `CreateObject` parse for base/overlay/underlay ids. **Missing:** the effect layer (`_effects` → `GetByEnum(0x10000005)`), the layer-4 `ReplaceColor` tint, and — critically — **appraise-driven enrichment + icon re-composition** (the overlay/effects ids likely arrive at `Appraise` (`0x00C9`), not on the bare `CreateObject`, which is why a pinned scroll shows no overlay). **First, settle the data-availability question with a WireMCP capture** of the scroll's CreateObject + IdentifyObjectResponse — don't guess. Then: capture `UiEffects` onto `ItemInstance`, read `master[0x10000005]` (mirror the working `0x10000004` underlay resolve), composite the effect layer + the overlay tint, and wire appraise → update the typed icon fields → re-compose. Follow the mandatory grep-named→cross-ref(ACE/ACViewer/Chorizite)→pseudocode→port workflow; conformance tests with golden dat values like the underlay test. The displayed icon must always be a function of the item's *current* state (the user's "item with mana vs out of mana" requirement).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**MEMORY.md index line:**
|
|
||||||
- [Handoff: stateful item-icon system (2026-06-17)](research/2026-06-17-stateful-icon-system-handoff.md) — the full retail icon composite (`IconData::RenderIcons` @407524, 5 layers). D.5.1 built layers 1–4 + CreateObject parse (IconId/Overlay/Underlay) + the EnumIDMap `0x10000004` underlay resolve; MISSING = effect layer (`_effects`→`GetByEnum 0x10000005`, the "mana vs out-of-mana" layer), the overlay `ReplaceColor` tint, and appraise-driven enrichment+re-composition (overlay/effects likely arrive at Appraise 0x00C9, not bare CreateObject — capture with WireMCP first). Shared by inventory/equipment/vendor.
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
# Handoff — finish the action bar + start the inventory/paperdoll window
|
|
||||||
|
|
||||||
**Date:** 2026-06-18
|
|
||||||
**From:** the D.5.4 object/item-model session (SHIPPED `b506f53..6eb0fbde`, 2672 tests green, visually
|
|
||||||
confirmed on Barris/Coldeve). The data model is now solid — every server object lives in
|
|
||||||
`ClientObjectTable`, resolvable by guid. This handoff frames the NEXT work on the D.2b retail-UI track.
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12` (kept, unmerged — carries D.5.2 + D.5.4).
|
|
||||||
**Line numbers below are as of HEAD `6eb0fbde` and WILL drift — grep the symbol, don't trust the line.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Scope (settled with the user)
|
|
||||||
|
|
||||||
Three work streams. **The spell bar is explicitly DEFERRED** (it is a separate feature — a dedicated
|
|
||||||
spell-casting bar — NOT the action-bar spell *shortcuts*; do not build spell-glyph rendering/casting here).
|
|
||||||
|
|
||||||
| Stream | What | Roadmap |
|
|
||||||
|---|---|---|
|
|
||||||
| **A. Selected-object meter** | The action bar's bottom strip: the player's currently-**selected** world object's Health/Mana meter + name (+ stack slider, deferred). Currently hidden. | D.5.3 (issue #140) |
|
|
||||||
| **B. Shortcut drag / add / reorder / remove** | Drag an item from the inventory window onto a hotbar slot; reorder slots; remove. The `AddShortcut`/`RemoveShortcut` wire. Item shortcuts already RENDER + click-to-use (D.5.1/D.5.4); this is the interactive management. | D.5.3 / D.5.5 |
|
|
||||||
| **C. Paperdoll + inventory window** | One combined window (`gmInventoryUI` nests paperdoll + backpack + 3D-items). It is the **drag SOURCE** that Stream B needs. | D.5.5 |
|
|
||||||
|
|
||||||
**Out of scope:** the spell bar; the stack-split UI (entry box `0x100001A3` + slider `0x100001A4`);
|
|
||||||
the faithful Dragbar/Resizebar window resize (the IA-12 whole-window-drag approximation stays for now).
|
|
||||||
|
|
||||||
**Dependency reality:** Stream B's drag-*from-inventory* needs Stream C (the inventory window) as the
|
|
||||||
drag source, and both B and C need the **drag-drop spine completed** (shared infra, §B.1). So this is
|
|
||||||
really 2-3 sub-phases — see the build order in §4. Each gets its own brainstorm → spec → plan.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Read first
|
|
||||||
|
|
||||||
- This doc.
|
|
||||||
- `docs/research/2026-06-16-ui-panels-synthesis.md` — **the build plan** for the core panels (build order, widget list, cross-panel wire table). Stream C follows it.
|
|
||||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — the drag-drop spine design (§5 pseudocode is the spec for Stream B's widget hooks).
|
|
||||||
- `docs/research/2026-06-16-inventory-deep-dive.md` + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the two panels' LayoutDesc maps + wire catalog.
|
|
||||||
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` — `gmToolbarUI` shortcut model + the `HandleDropRelease` drag flags.
|
|
||||||
- `claude-memory/project_object_item_model.md` (D.5.4) + `claude-memory/project_d2b_retail_ui.md` (D.2b/D.5.1/D.5.2 toolkit).
|
|
||||||
|
|
||||||
**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
|
|
||||||
`class::method` → cross-ref ACE/holtburger → pseudocode → port. Conformance tests throughout.
|
|
||||||
The named-decomp anchors for each stream are inline below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Stream A — selected-object meter (the smallest, mostly self-contained)
|
|
||||||
|
|
||||||
**Goal:** when the player selects a world object (LMB pick or Tab/Q combat-target), the action bar's
|
|
||||||
bottom strip shows that object's **Health meter** + **name**; **Mana meter** for owned items.
|
|
||||||
|
|
||||||
**Retail lifecycle** (the oracle): `gmToolbarUI::HandleSelectionChanged`
|
|
||||||
(`acclient_2013_pseudo_c.txt:198635`) — on selection it `SetVisible(1)`s the right meter and fires
|
|
||||||
`CM_Combat::Event_QueryHealth(guid)` (creatures/players) or `CM_Item::Event_QueryItemMana(guid)`
|
|
||||||
(owned items). The server replies `UpdateHealth (0x01C0)` / `UpdateItemMana`, and
|
|
||||||
`RecvNotice_UpdateObjectHealth` (`:196213`) / `RecvNotice_UpdateItemMana` (`:196188`) call
|
|
||||||
`SetAttribute_Float(meter, 0x69, pct)` — **property `0x69` is the fill ratio**. `UIElement_Meter`'s
|
|
||||||
fill element is child id `2` (`UIElement_Meter::Initialize :123328`; `OnSetAttribute :123712`).
|
|
||||||
Mana is gated on `IsOwnedByPlayer` (`:198763`).
|
|
||||||
|
|
||||||
**LayoutDesc elements** (toolbar `0x21000016`, `.layout-dumps/toolbar-0x21000016.txt:621-811`):
|
|
||||||
container `0x1000019E`; name text `0x1000019F` (Type 0) + state overlay `0x100001A0`
|
|
||||||
(states `ObjectSelected 0x06001937` / `StackedItemSelected 0x06004CF4`); **health meter `0x100001A1`**
|
|
||||||
(Type 7); **mana meter `0x100001A2`** (Type 7); stack entry `0x100001A3`; stack slider `0x100001A4`
|
|
||||||
(Type 11). All currently in `ToolbarController` `HiddenIds` (~`ToolbarController.cs:41`),
|
|
||||||
`SetVisible(false)` at Bind (~`:100`).
|
|
||||||
|
|
||||||
**Work items:**
|
|
||||||
1. **Fix the meter render bug** (the launch-log `meter 0x100001A1/A2: 1 Type-3 slice container
|
|
||||||
(expected 2)` warning). `DatWidgetFactory.BuildMeter` (~`DatWidgetFactory.cs:135-154`) assumes 2
|
|
||||||
Type-3 slice containers (back + fill). The toolbar meters have **1** container (the fill, child
|
|
||||||
id `0x00000002`); the **back-track sprite is on the meter element's own DirectState**
|
|
||||||
(e.g. health `0x0600193E`). Fix `BuildMeter` to detect the 1-container case and read the back
|
|
||||||
track from the element's `StateMedia[""]`, fill from the child. (Vitals meters `0x2100006C` have 2
|
|
||||||
containers and work — use them as the contrast.)
|
|
||||||
2. **`SelectedObjectController`** (analogue of `VitalsController` — see the working bind pattern at
|
|
||||||
`VitalsController.cs:61-97`): on selection-change, `SetVisible(true)` on `0x100001A1`(/`A2` for owned
|
|
||||||
items), bind `UiMeter.Fill` to `() => combat.GetHealthPercent(selGuid)`, bind the name text
|
|
||||||
`0x1000019F` to `ClientObjectTable.Get(selGuid)?.Name`, set the `0x100001A0` overlay state; on
|
|
||||||
deselect `SetVisible(false)`.
|
|
||||||
3. **Selection notification:** there is no `SelectionChanged` event today — `_selectedGuid` is a raw
|
|
||||||
`uint?` on `GameWindow` (~`GameWindow.cs:844`), written by `PickAndStoreSelection` (LMB) and
|
|
||||||
`SelectClosestCombatTarget` (Tab/Q), cleared on despawn. Either add an event or poll-and-diff a
|
|
||||||
`Func<uint?>` (the `TargetIndicatorPanel` pattern). **Brainstorm: event vs poll.**
|
|
||||||
4. **Health is ready:** `CombatState.GetHealthPercent(guid)` + `CombatState.HealthChanged`
|
|
||||||
(`CombatState.cs:92,45`), wired from `UpdateHealth 0x01C0` (`GameEventWiring.cs:155`).
|
|
||||||
To force a fresh value on selection, retail sends `QueryHealth` — `SocialActions.BuildQueryHealth`
|
|
||||||
(0x01BF) already exists (`SocialActions.cs:49`). **Brainstorm: send QueryHealth on select, or rely
|
|
||||||
on server broadcasts for now?**
|
|
||||||
5. **Mana is NOT ready** (the harder half): no remote-target mana anywhere (`CombatState` is
|
|
||||||
health-only; `LocalPlayerState.ManaPercent` is self-only). `QueryItemManaResponse (0x0264)` is
|
|
||||||
*parsed* (`GameEvents.cs:416`) but **unregistered** in `GameEventWiring`, and there is **no
|
|
||||||
outbound `QueryItemMana` builder** (its C→S opcode is unknown — `0x0264` is the reply).
|
|
||||||
**Brainstorm/decide: defer mana entirely for D.5.3 (health-only, matching that mana is owned-item-only
|
|
||||||
anyway), or do the full mana path?** Recommend deferring mana → ship health-meter + name first.
|
|
||||||
6. **Stack slider/entry (`0x100001A3/A4`):** deferred (stack-split UI).
|
|
||||||
|
|
||||||
**Why A is mostly standalone:** it doesn't need the drag-drop spine, the window manager, or the
|
|
||||||
inventory window. It's the quickest win and finishes the bar's *display*. Good first chunk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Stream B — shortcut drag / add / reorder / remove
|
|
||||||
|
|
||||||
**Item shortcuts already render + click-to-use** (D.5.1 + D.5.4). This stream is the interactive
|
|
||||||
management: drag an item from inventory onto a slot, reorder, remove.
|
|
||||||
|
|
||||||
### B.1 — the drag-drop spine (SHARED infra, also needed by Stream C)
|
|
||||||
`UiRoot` has the **complete** retail drag state machine, LIVE-wired to Silk.NET input:
|
|
||||||
`BeginDrag`/`UpdateDragHover`/`FinishDrag` firing `DragBegin 0x15`/`DragEnter 0x21`/`DragOver 0x1C`/
|
|
||||||
`DropReleased 0x3E` (`UiRoot.cs:450-496`), promoted on >3px move, bridged via `UiHost.WireMouse`
|
|
||||||
(`UiHost.cs:78-88`, called at `GameWindow.cs:1769`). **But:**
|
|
||||||
- `BeginDrag` always passes `payload: null` (`UiRoot.cs:188`); `DragPayload` has a private setter
|
|
||||||
(`UiRoot.cs:73`) → needs a `SetDragPayload(object)` escape hatch (or a source-payload callback).
|
|
||||||
- `UiItemSlot.OnEvent` handles only `MouseDown→Clicked` (`UiItemSlot.cs:101-105`) — **no
|
|
||||||
DragBegin/DragEnter/DragOver/DropReleased cases**. (`UiItemSlot.ItemId` `:19` is the payload source.)
|
|
||||||
- `UiField`'s `CatchDroppedItem`/`MouseOverTop` are **doc-comment only** (`UiField.cs:10-11`) — the
|
|
||||||
bodies belong on `UiItemSlot`, per the spine doc §5.6.
|
|
||||||
- No `IItemListDragHandler` interface exists; no drag ghost renderer; no `InqDropIconInfo` helper.
|
|
||||||
|
|
||||||
**Build (spine doc §5.7 is the spec):** (1) payload injection in `UiItemSlot` on DragBegin
|
|
||||||
(`{objId=ItemId, srcContainer, srcSlot}`); (2) a cursor-following **drag ghost** (the icon is already
|
|
||||||
in `UiItemSlot.IconTexture`); (3) drop-target hooks on `UiItemSlot` (DragEnter/Over→accept/reject
|
|
||||||
overlay `0x10000041`/`0x10000040`/`0x1000003f`; DropReleased→`HandleDropRelease`); (4)
|
|
||||||
`IItemListDragHandler { bool OnDragOver(...); void HandleDropRelease(...) }` that panels implement +
|
|
||||||
register on their `UiItemList`.
|
|
||||||
|
|
||||||
### B.2 — the shortcut model + wire
|
|
||||||
- **Mutable store missing.** Shortcuts are a **read-only** `IReadOnlyList<ShortcutEntry>`
|
|
||||||
(`GameWindow.Shortcuts ~:600`, set once from PlayerDescription via `onShortcuts` at
|
|
||||||
`GameEventWiring.cs:415`). Port retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`) as a
|
|
||||||
small mutable `ShortcutStore` (18 slots; `Load`/`AddOrReplace(slot,guid)→displaced`/`Remove(slot)`).
|
|
||||||
- **Wire builders exist with a naming bug.** `InventoryActions.BuildAddShortcut` (0x019C,
|
|
||||||
`InventoryActions.cs:99`) — param `objectType` should be `objectGuid`; the trailing field is packed
|
|
||||||
`spellId(u16)|layer(u16)` (0 for items). Byte layout is already correct for item-only callers; **fix
|
|
||||||
the names before wiring.** Field order confirmed by ACE `Shortcut.cs:33`, holtburger
|
|
||||||
`shortcuts.rs:37`, retail `ShortCutData` `acclient.h:36484`. `BuildRemoveShortcut` (0x019D) is fine.
|
|
||||||
- **No `SendAddShortcut`/`SendRemoveShortcut` on `WorldSession`** — wrap the builders (pattern =
|
|
||||||
`SendChangeCombatMode`: `NextGameActionSequence()` + `Build*()` + `SendGameAction()`, `:1064`).
|
|
||||||
- **Drop flow** (retail `gmToolbarUI::HandleDropRelease :197971`): `InqDropIconInfo` flags
|
|
||||||
`&0xE==0` = fresh-from-inventory (place), `&4` = reorder. On drop: remove target if occupied (0x019D)
|
|
||||||
→ update store → add (0x019C) → `Populate()`. Reorder also puts the displaced item back in the source
|
|
||||||
slot. `ToolbarController` implements `IItemListDragHandler` + gets `Action`s for the two sends.
|
|
||||||
|
|
||||||
**Reorder-within-bar needs no inventory; drag-from-inventory needs Stream C.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Stream C — paperdoll + inventory window (one window)
|
|
||||||
|
|
||||||
**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the
|
|
||||||
**current-code readiness** + what's missing. Don't re-derive the design.
|
|
||||||
|
|
||||||
**READY (post-D.5.1/D.5.4):** `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`),
|
|
||||||
`DatWidgetFactory` registers `0x10000031→UiItemList` (`:70`); the data path is
|
|
||||||
`ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full icon fields
|
|
||||||
(`ClientObjectTable.cs:273,188`). The toolkit + data model are in place.
|
|
||||||
|
|
||||||
**MISSING (the build, in synthesis order):**
|
|
||||||
1. **Window manager** (deferred Plan-2): open/close/z-order/persist. Today every window is **always-on
|
|
||||||
at a hardcoded position** (`ACDREAM_RETAIL_UI=1`, `GameWindow.cs:1906`); `UiHost` has no
|
|
||||||
open/close API (`UiHost.cs:37`). Needs at minimum an **`I`-key toggle** to open/close the inventory
|
|
||||||
window. (Faithful Dragbar/Resizebar resize stays deferred — IA-12 whole-window-drag is fine.)
|
|
||||||
2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs:12`, only sizes
|
|
||||||
`_cells[0]`); `Flush`/`AddItem` skeleton exists but no column-count/pitch/wrap (LIKELY 6 cols × 36px;
|
|
||||||
confirm from `UIElement_ItemList::ItemList_AddItem`).
|
|
||||||
3. **Sub-window mount in `LayoutImporter`** — `gmInventoryUI` (`0x21000023`) nests paperdoll
|
|
||||||
(`0x21000024`), backpack (`0x21000022`), 3D-items (`0x21000021`) as child elements whose class id
|
|
||||||
has its own `BaseLayoutId`. The importer only does TEMPLATE inheritance today
|
|
||||||
(`LayoutImporter.cs:196-228`) — it has never instantiated a nested `gm*UI` window. New capability.
|
|
||||||
4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
|
|
||||||
`NoLongerViewingContents 0x0195` (all absent); parsers `ViewContents 0x0196`, `SetStackSize 0x0197`,
|
|
||||||
`InventoryRemoveObject` (all absent); fix `ParsePutObjInContainer` (drops the 4th `containerType`,
|
|
||||||
`GameEvents.cs:352`) + `ParseInventoryServerSaveFailed` (drops `weenieError`, `:377`); register
|
|
||||||
`ViewContents`/`0x019A`/`0x0052`/`0x00A0` in `GameEventWiring`.
|
|
||||||
5. **`UiViewport` (Type 0xD)** for the paperdoll 3D doll — **the single biggest new piece.** No widget,
|
|
||||||
no factory registration, no renderer. Needs an `IUiViewportRenderer` **Core→App seam** (Rule 2) for a
|
|
||||||
scissored single-entity GL pass. The doll is the local player's ObjDesc-dressed entity in a fixed
|
|
||||||
viewport. **Heavy — brainstorm separately (see §5 open questions).**
|
|
||||||
6. **`InventoryController` + `PaperDollController`** (the `gm*UI::PostInit` find-by-id pattern):
|
|
||||||
backpack burden Meter (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, the
|
|
||||||
element-id→`EquipMask` map for paperdoll slots, `ObjDescEvent 0xF625` → re-dress.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Recommended build order + the dependency graph
|
|
||||||
|
|
||||||
This spans **2-3 sub-phases**. Suggested sequence (each its own brainstorm → spec → plan):
|
|
||||||
|
|
||||||
1. **D.5.3a — selected-object meter** (Stream A). Standalone, quickest, finishes the bar's display.
|
|
||||||
No spine/window-manager dependency. Recommend health-meter + name first; defer mana.
|
|
||||||
2. **Drag-drop spine completion** (§B.1) — shared infra for B and C. Build once.
|
|
||||||
3. **Window manager (open/close)** (§C.1) — enough to toggle the inventory window open.
|
|
||||||
4. **D.5.5 — inventory window** (§C, grid + sub-window mount + wire gaps + `InventoryController`).
|
|
||||||
This gives the drag **source**.
|
|
||||||
5. **D.5.3b — shortcut drag-to-add/reorder/remove** (Stream B) — now that the spine + inventory source
|
|
||||||
+ `ShortcutStore` + the `BuildAddShortcut` fix are in place. (Reorder-within-bar could land earlier
|
|
||||||
with just steps 2 + the store.)
|
|
||||||
6. **Paperdoll** (`UiViewport` + `PaperDollController`, §C.5/6) — the 3D doll, the heaviest piece.
|
|
||||||
|
|
||||||
**Critical-path note:** the drag-drop spine (step 2) is the lynchpin — both shortcut drag and inventory
|
|
||||||
drag depend on it. Do it early and well (it has its own spine deep-dive as the spec).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Open questions for the brainstorm(s)
|
|
||||||
|
|
||||||
- **A:** SelectionChanged event vs poll-and-diff? Send `QueryHealth (0x01BF)` on select, or rely on
|
|
||||||
server broadcasts? Defer mana (health-only) for D.5.3 — confirm. The meter render-bug fix:
|
|
||||||
back-track from the element's own DirectState — verify the sprite ids (`0x0600193E` health) against the
|
|
||||||
dump.
|
|
||||||
- **B:** `DragPayload` shape (a `record ItemDragPayload(objId, srcContainer, srcSlot, flags)` vs the
|
|
||||||
slot itself)? Where does the drag ghost render (UiRoot.OnDraw vs UiItemSlot overlay)? Is `UiItemList`
|
|
||||||
or `UiItemSlot` the drop-target unit? Fire-and-forget vs optimistic-then-confirm for the shortcut wire?
|
|
||||||
- **C:** Sub-window mount — recursive `Import()` in `LayoutImporter`, or external stitch by the
|
|
||||||
controller? Inventory grid column count (confirm 6 from decomp)? Does the paperdoll doll clone the
|
|
||||||
player `WorldEntity` or build a fresh ObjDesc-dressed `AnimatedEntityState` (player = camera, so there's
|
|
||||||
no player-as-renderable today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Open the
|
|
||||||
inventory by `I`-key only, or also the toolbar's inventory button?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. ⚠ Corrections to the grounding research (verify against source)
|
|
||||||
|
|
||||||
- **`_liveEntityInfoByGuid` is GONE** (retired in D.5.4 Task 10, `a9d40ad`). A research agent's notes
|
|
||||||
reference it as the selected-object name source at `GameWindow.cs:835/2559/12129` — **stale.**
|
|
||||||
Post-D.5.4 the name resolves via `ClientObjectTable.Get(guid)?.Name`, or the `GameWindow.LiveName(guid)`
|
|
||||||
/ `DescribeLiveEntity(guid)` helpers (which now read the table). Likewise "`ClientObjectTable` does not
|
|
||||||
exist yet" is wrong — it shipped in D.5.4. Trust the table, not the dict.
|
|
||||||
- **Line numbers throughout drift** (D.5.4 removed ~75 lines from `GameWindow`). Grep the symbol.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives it references. Three work streams (spell bar DEFERRED — it is a separate feature, not the action-bar spell shortcuts): **(A)** the action bar's selected-object meter (Health + name; mana deferred — issue #140); **(B)** shortcut drag/add/reorder/remove (the `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire + the drag-drop spine completion; item shortcuts already render+click); **(C)** start the paperdoll+inventory window (one window — `gmInventoryUI` nests paperdoll/backpack/3D-items). The drag-drop spine (UiRoot has the machine; UiItemSlot lacks the hooks) is shared infra for B and C — build it early. Suggested order: A (standalone quick win) → drag-drop spine → window manager (open/close) → inventory window → shortcut drag → paperdoll (UiViewport). Use the full brainstorm → spec → plan → subagent-driven flow per stream; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Data model is solid post-D.5.4: resolve every object via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Branch `claude/hopeful-maxwell-214a12` (kept, unmerged).
|
|
||||||
|
|
||||||
**MEMORY.md index line:**
|
|
||||||
- [Handoff: finish the bar + inventory/paperdoll window (2026-06-18)](research/2026-06-18-d53-bar-finish-and-inventory-handoff.md) — next D.2b-UI work after D.5.4. 3 streams (spell bar DEFERRED): (A) selected-object meter (health+name, mana deferred; fix DatWidgetFactory 1-slice-container meter bug; SelectedObjectController like VitalsController), (B) shortcut drag/add/reorder/remove (UiRoot has the drag machine, UiItemSlot lacks hooks; mutable ShortcutStore missing; BuildAddShortcut naming bug), (C) inventory+paperdoll window (needs window-manager open/close + UiItemList grid mode + sub-window mount + wire gaps + UiViewport). Build order + per-stream anchors + brainstorm questions inside. ⚠ _liveEntityInfoByGuid is GONE (D.5.4) — name via ClientObjectTable.Get.
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
# Handoff — the client object/item data model (next phase, post-D.5.2)
|
|
||||||
|
|
||||||
**Date:** 2026-06-18
|
|
||||||
**From:** the D.5.2 stateful-icon session (icon system SHIPPED + visually confirmed on a
|
|
||||||
live Coldeve server). This handoff frames the NEXT phase: the real item/object data model.
|
|
||||||
**Status of this work:** branch `claude/hopeful-maxwell-214a12` (kept, not merged). D.5.2 is
|
|
||||||
complete: `52306d9..fb288ad`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Why this phase exists (the root cause we uncovered)
|
|
||||||
|
|
||||||
Visual-verifying D.5.2 on a live server (character **Barris** on Coldeve) showed **4 of 6
|
|
||||||
hotbar items render no icon**. The diagnostic (`icon-dump.txt`, since removed) proved the
|
|
||||||
cause: those items are **`NOT-ENRICHED`** — `ItemRepository.GetItem(guid)` returns null
|
|
||||||
because their `CreateObject` was **dropped**.
|
|
||||||
|
|
||||||
The mechanism is acdream's **scaffold item model**:
|
|
||||||
- `EnrichItem` is **enrich-existing-only**: it updates an item ONLY if it was already seeded
|
|
||||||
as a stub (from `PlayerDescription` at login). A `CreateObject` for an item with no
|
|
||||||
pre-existing stub is silently discarded (the toolbar handoff called this out:
|
|
||||||
*"new-item ingestion is the inventory phase"*).
|
|
||||||
- So only items in the login seed set get icons; everything else (most pack contents) falls
|
|
||||||
on the floor. The 2 that showed (Energy Crystal, Revenant's Scythe) are wielded items the
|
|
||||||
server announces up front.
|
|
||||||
|
|
||||||
This is **NOT a D.5.2 bug** (the icon composite is correct for every item that reaches it —
|
|
||||||
confirmed: Energy Crystal's Magical gradient tint + the no-mana scroll's black edges both
|
|
||||||
match retail). It is the **item/object data model** being a placeholder.
|
|
||||||
|
|
||||||
## 1. The retail model to port (the oracle)
|
|
||||||
|
|
||||||
Retail has **one master object table** — `ClientObjMaintSystem` — and **`CreateObject` is the
|
|
||||||
canonical create/update for every object** (item, creature, player). The UI never owns item
|
|
||||||
data: a hotbar slot, an inventory cell, a paperdoll slot, a vendor cell all hold a `guid` and
|
|
||||||
resolve it live via `ClientObjMaintSystem::GetWeenieObject(guid)`. (Confirmed in our spine
|
|
||||||
research: *"the cell never holds item data — it holds an itemID and resolves it live."*)
|
|
||||||
|
|
||||||
acdream **inverted** this: login snapshot = source of truth, `CreateObject` = second-class
|
|
||||||
enrich. The fix is to flip it: **`CreateObject` is the authoritative ingestion**;
|
|
||||||
`PlayerDescription` / `ViewContents (0x0196)` / shortcuts become **references + supplementary
|
|
||||||
data**, not the primary seed. Every object the server tells us about is tracked; the UI
|
|
||||||
resolves by guid.
|
|
||||||
|
|
||||||
## 2. THE crux design question (settle this FIRST in the brainstorm)
|
|
||||||
|
|
||||||
acdream currently has **two object tracks**:
|
|
||||||
- the **WorldEntity** system (3D creatures / players / world items, fed by `CreateObject` →
|
|
||||||
`GameWindow.OnLiveEntitySpawned` → `WorldEntity`), and
|
|
||||||
- the **ItemRepository** (inventory items, `src/AcDream.Core/Items/`).
|
|
||||||
|
|
||||||
Retail unifies these under one `ClientObjMaintSystem` (every object is an `ACCWeenieObject`).
|
|
||||||
**Decision to make:** unify acdream into ONE object table (retail shape), or keep the two
|
|
||||||
tracks with a shared ingestion seam? This choice drives everything downstream (inventory,
|
|
||||||
equipment/paperdoll, vendor, trade all resolve items from whatever table wins). Think it
|
|
||||||
through up front — don't discover it halfway in.
|
|
||||||
|
|
||||||
## 3. Sources that feed the model (the ingestion surface to design around)
|
|
||||||
|
|
||||||
| Wire message | Role |
|
|
||||||
|---|---|
|
|
||||||
| `CreateObject (0xF745)` | **canonical** object create/update (full weenie: icon/name/type/stack/container/wielder/effects/…) |
|
|
||||||
| `DeleteObject (0xF747)` | remove |
|
|
||||||
| `PlayerDescription (0x0013)` | login snapshot: inventory + equipped + shortcuts (references; some props) |
|
|
||||||
| `ViewContents (0x0196)` | a container's `{guid, slot}` list when opened |
|
|
||||||
| move events `0x0019/1A/1B`, `0x0022/23`, `0x019A` | re-parent (container/wield/3D) |
|
|
||||||
| `Public/PrivateUpdateProperty* (0x02CD–0x02DA)` | per-property live updates (D.5.2 wired `0x02CE` UiEffects → icon) |
|
|
||||||
| `InventoryServerSaveFailed (0x00A0)` | speculative-move rollback |
|
|
||||||
|
|
||||||
## 4. Grounding research (already written — read before the brainstorm)
|
|
||||||
|
|
||||||
- `docs/research/2026-06-16-inventory-deep-dive.md` — inventory panel + the wire catalog
|
|
||||||
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — `ClientObjMaintSystem`,
|
|
||||||
`ServerSaysMoveItem`, the resolve-by-guid model
|
|
||||||
- `docs/research/deepdives/r06-items-inventory.md` — the item/container property model
|
|
||||||
- `docs/research/2026-06-16-ui-panels-synthesis.md` — core-panels build order (item-model is
|
|
||||||
the prerequisite for the inventory panel)
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` — D.2b/D.5.1/D.5.2 state
|
|
||||||
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies, not dat-baked
|
|
||||||
|
|
||||||
## 5. Recommended approach
|
|
||||||
|
|
||||||
Full process (the user values it): **brainstorm → spec → plan → subagent implementation.**
|
|
||||||
Open the brainstorm on **the unify-vs-separate question (§2) first**, then the ingestion
|
|
||||||
lifecycle (§3), then how the UI (toolbar/inventory/paperdoll) binds by guid. This is the
|
|
||||||
foundation the remaining D.5 core panels sit on — get it solid.
|
|
||||||
|
|
||||||
NOTE the user's standing constraint for this phase: *"No quick fixes — needs to be
|
|
||||||
architecturally solid and thought through."* Do not band-aid `EnrichItem` to add new items;
|
|
||||||
design the model properly.
|
|
||||||
|
|
||||||
## 6. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Design and build acdream's **client object/item data model** — the foundation under the D.5
|
|
||||||
> core panels (inventory, equipment/paperdoll, vendor, trade). This is roadmap **D.5.4** and it
|
|
||||||
> blocks D.5.5+. **Read this handoff first: `docs/research/2026-06-18-item-object-model-handoff.md`**,
|
|
||||||
> then `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and
|
|
||||||
> `docs/research/2026-06-16-inventory-deep-dive.md`.
|
|
||||||
>
|
|
||||||
> The problem (confirmed live on Coldeve, character Barris): acdream's item model is
|
|
||||||
> **enrich-existing-only** — `ItemRepository.EnrichItem` only fills items pre-seeded as stubs
|
|
||||||
> from `PlayerDescription`, and DROPS `CreateObject`s for anything else, so most hotbar/pack
|
|
||||||
> items render no icon (4 of 6 hotbar slots were blank). Port retail's `ClientObjMaintSystem`:
|
|
||||||
> **`CreateObject` is the canonical object create/update**, `PlayerDescription`/`ViewContents`/
|
|
||||||
> shortcuts become references, and the UI resolves items by guid. This is NOT a D.5.2 icon bug
|
|
||||||
> (the composite is correct for every item that reaches it).
|
|
||||||
>
|
|
||||||
> **Do this as a proper design — the user's standing constraint is "architecturally solid, no
|
|
||||||
> quick fixes" (do NOT band-aid `EnrichItem` to add new items).** Use the full
|
|
||||||
> brainstorm → spec → plan → subagent-driven-development flow. **Open the brainstorm by settling
|
|
||||||
> the crux FIRST (§2): unify acdream's two object tracks — the `WorldEntity` 3D system (fed by
|
|
||||||
> `GameWindow.OnLiveEntitySpawned`) and `ItemRepository` — into ONE object table like retail, or
|
|
||||||
> keep them separate with a shared ingestion seam?** Then the ingestion lifecycle (§3 wire
|
|
||||||
> surface) and how the toolbar/inventory/paperdoll bind by guid. Follow the mandatory
|
|
||||||
> grep-named→cross-ref→pseudocode→port workflow for any AC-specific wire format; conformance
|
|
||||||
> tests throughout. Work continues on branch `claude/hopeful-maxwell-214a12` (kept, unmerged;
|
|
||||||
> D.5.2 = `52306d9..fb288ad`).
|
|
||||||
|
|
||||||
**MEMORY.md index line:**
|
|
||||||
- [Handoff: client object/item data model (2026-06-18)](research/2026-06-18-item-object-model-handoff.md) — next phase after D.5.2. Root cause of the live-Coldeve "4/6 hotbar items missing": acdream's item model is enrich-existing-only (drops CreateObjects without a pre-seeded stub). Fix = port retail's `ClientObjMaintSystem` (CreateObject = canonical ingestion; UI resolves by guid). CRUX to settle first: unify the WorldEntity + ItemRepository tracks into one object table, or keep separate w/ shared ingestion? Grounding research + ingestion surface listed. User constraint: architecturally solid, no quick fixes.
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
|
|
||||||
|
|
||||||
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
|
|
||||||
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
|
|
||||||
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
|
|
||||||
|
|
||||||
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
|
|
||||||
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
|
|
||||||
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
|
|
||||||
**deliberately NOT implemented** because the math contradicts the observed result —
|
|
||||||
one more capture is needed first.
|
|
||||||
|
|
||||||
## SHIPPED this session (all on `main`)
|
|
||||||
|
|
||||||
| Fix | Commit | What | Result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
|
|
||||||
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
|
|
||||||
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
|
|
||||||
|
|
||||||
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
|
|
||||||
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
|
|
||||||
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
|
|
||||||
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
|
|
||||||
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
|
|
||||||
matrices.
|
|
||||||
|
|
||||||
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
|
|
||||||
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
|
|
||||||
`LScape::set_sky_position`'s world transform. cdb read retail's actual
|
|
||||||
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
|
|
||||||
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
|
|
||||||
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
|
|
||||||
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
|
|
||||||
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
|
|
||||||
|
|
||||||
## KEY LESSON: the "too purple" was NEVER a bug
|
|
||||||
|
|
||||||
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
|
|
||||||
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
|
|
||||||
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
|
|
||||||
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
|
|
||||||
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
|
|
||||||
was wrong (Fix C). Don't re-investigate the purple.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
|
|
||||||
|
|
||||||
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
|
|
||||||
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
|
|
||||||
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
|
|
||||||
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
|
|
||||||
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
|
|
||||||
not because it'd be too bright, but because it's the wrong path entirely.
|
|
||||||
|
|
||||||
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
|
|
||||||
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
|
|
||||||
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
|
|
||||||
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
|
|
||||||
colours. AC town buildings are EnvCell structures, so their walls take this path.
|
|
||||||
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light` → `insert_light` (0x0054D1B0)
|
|
||||||
→ `config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
|
|
||||||
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
|
|
||||||
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
|
|
||||||
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
|
|
||||||
carries DYNAMIC lights for cells, not the wall torches.
|
|
||||||
|
|
||||||
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
|
|
||||||
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
|
|
||||||
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1−d/range)·intensity·(wrap/norm)`;
|
|
||||||
then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds
|
|
||||||
**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from
|
|
||||||
**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the
|
|
||||||
sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range
|
|
||||||
lights stacking past 1.0; a hall has a handful, each warm-capped.
|
|
||||||
|
|
||||||
### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`)
|
|
||||||
`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104,
|
|
||||||
`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights`
|
|
||||||
@ +0x3588. Captured standing in Holtburg:
|
|
||||||
- **num_static_lights = 38**, **num_dynamic_lights = 2.**
|
|
||||||
- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 1–2): viewer light `intensity=2.25 falloff=10
|
|
||||||
color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA.
|
|
||||||
→ **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.**
|
|
||||||
- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange
|
|
||||||
`(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 3–5
|
|
||||||
(→ bake range ~3.9–6.5 m). Torches DO carry intensity=100, but the per-channel cap
|
|
||||||
pins each to its warm colour ⇒ retail walls go warm, not white.
|
|
||||||
|
|
||||||
### acdream's actual bug — TWO real causes (both verified in source)
|
|
||||||
- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.**
|
|
||||||
`mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS
|
|
||||||
sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is
|
|
||||||
one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light
|
|
||||||
cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches
|
|
||||||
into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the
|
|
||||||
torch sum on its OWN (from black); ambient/sun are a separate term.
|
|
||||||
- **D-2 (state, compounding): EnvCell shell SSBO binding leak.**
|
|
||||||
`EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER
|
|
||||||
4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at
|
|
||||||
:204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a
|
|
||||||
cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound —
|
|
||||||
a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls.
|
|
||||||
- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is
|
|
||||||
the in-shader version missing the clamp shape.
|
|
||||||
|
|
||||||
### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule)
|
|
||||||
- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE
|
|
||||||
adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the
|
|
||||||
point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files:
|
|
||||||
`mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the
|
|
||||||
single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch
|
|
||||||
bakes warm-but-≤[0,1], NOT white.
|
|
||||||
- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set)
|
|
||||||
for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet`
|
|
||||||
(LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP
|
|
||||||
(needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal.
|
|
||||||
- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the
|
|
||||||
point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A
|
|
||||||
(`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap.
|
|
||||||
- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's
|
|
||||||
color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the
|
|
||||||
baked walls.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb)
|
|
||||||
- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]`
|
|
||||||
- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group`
|
|
||||||
- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770
|
|
||||||
- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color`
|
|
||||||
- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0
|
|
||||||
- Pattern: `.formats poi(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
|
|
||||||
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
|
|
||||||
|
|
||||||
## Build / run
|
|
||||||
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
|
|
||||||
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
|
|
||||||
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
|
|
||||||
|
|
||||||
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
|
|
||||||
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
|
|
||||||
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
|
|
||||||
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
|
|
||||||
|
|
||||||
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
|
|
||||||
"shorten torch reach" — it is "outdoor objects receive NO torches."**
|
|
||||||
|
|
||||||
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
|
|
||||||
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
|
|
||||||
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
|
|
||||||
this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
|
|
||||||
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
|
|
||||||
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
|
|
||||||
inflated. So "acdream 6 vs retail 4" was a red herring.
|
|
||||||
|
|
||||||
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
|
|
||||||
`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is
|
|
||||||
**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`.
|
|
||||||
The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
|
|
||||||
before `LScape::draw`), so when the building EXTERIOR shell is drawn
|
|
||||||
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
|
|
||||||
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
|
|
||||||
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
|
|
||||||
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
|
|
||||||
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
|
|
||||||
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
|
|
||||||
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
|
|
||||||
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
|
|
||||||
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
|
|
||||||
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
|
|
||||||
enables it outdoors.)
|
|
||||||
|
|
||||||
**The three retail lighting regimes (now all mapped):**
|
|
||||||
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
|
|
||||||
→ acdream mode 1 (EnvCell). ✓ already correct.
|
|
||||||
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
|
|
||||||
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
|
|
||||||
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
|
|
||||||
|
|
||||||
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object
|
|
||||||
torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`)
|
|
||||||
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
|
|
||||||
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
|
|
||||||
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
|
|
||||||
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
|
|
||||||
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
|
|
||||||
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
|
|
||||||
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
|
|
||||||
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
|
|
||||||
|
|
||||||
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
|
|
||||||
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
|
|
||||||
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
|
|
||||||
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
|
|
||||||
reach is the bug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR — what the visible bug actually is (and is NOT)
|
|
||||||
|
|
||||||
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
|
|
||||||
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
|
|
||||||
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
|
|
||||||
|
|
||||||
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
|
|
||||||
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
|
|
||||||
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
|
|
||||||
retail ("looks much better", user 2026-06-19).
|
|
||||||
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
|
|
||||||
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
|
|
||||||
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
|
|
||||||
|
|
||||||
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
|
|
||||||
|
|
||||||
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
|
|
||||||
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
|
|
||||||
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
|
|
||||||
**D-4 (EnvCell walls get no sun) never touched it**.
|
|
||||||
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
|
|
||||||
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
|
|
||||||
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
|
|
||||||
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
|
|
||||||
from the dat EnvCell list — never `info.Buildings`, never characters.
|
|
||||||
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
|
|
||||||
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
|
|
||||||
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
|
|
||||||
shared uniform; each renderer re-sets it before its draws.
|
|
||||||
|
|
||||||
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
|
|
||||||
|
|
||||||
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
|
|
||||||
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
|
|
||||||
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
|
|
||||||
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
|
|
||||||
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
|
|
||||||
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
|
|
||||||
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
|
|
||||||
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
|
|
||||||
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
|
|
||||||
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
|
|
||||||
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
|
|
||||||
|
|
||||||
## The OPEN question to resolve FIRST on resume (don't guess)
|
|
||||||
|
|
||||||
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
|
|
||||||
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
|
|
||||||
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
|
|
||||||
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
|
|
||||||
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
|
|
||||||
for the same one. Then:
|
|
||||||
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
|
|
||||||
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
|
|
||||||
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
|
|
||||||
the issue; tighten how building shells receive torches (the per-vertex range gate already
|
|
||||||
localises, so this is unlikely — favour the Falloff hypothesis).
|
|
||||||
|
|
||||||
## Proposed fix (after the falloff is confirmed)
|
|
||||||
|
|
||||||
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
|
|
||||||
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
|
|
||||||
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
|
|
||||||
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
|
|
||||||
object point lights OFF == retail-match).
|
|
||||||
|
|
||||||
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
|
|
||||||
|
|
||||||
| Commit | What | Verdict |
|
|
||||||
|---|---|---|
|
|
||||||
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
|
|
||||||
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
|
|
||||||
| `cf62793` | D-1 shader clamp | keep |
|
|
||||||
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
|
|
||||||
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
|
|
||||||
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
|
|
||||||
|
|
||||||
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
|
|
||||||
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
|
|
||||||
|
|
||||||
## DO-NOT-RETRY (cost a lot this session)
|
|
||||||
|
|
||||||
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
|
|
||||||
building is a mode-0 OBJECT, none of that path lights it.
|
|
||||||
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
|
|
||||||
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
|
|
||||||
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# Handoff — indoor lighting DONE; NEXT: #145 portal re-use, then #144 dungeon dimness
|
|
||||||
|
|
||||||
**Date:** 2026-06-20
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` — **committed, NOT merged to main, NOT pushed** (user's call)
|
|
||||||
**Milestone:** M1.5 "Indoor world feels right"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What shipped this session (on the branch, not pushed)
|
|
||||||
|
|
||||||
The entire indoor-lighting saga. **#142 + #143 CLOSED, user-confirmed.**
|
|
||||||
|
|
||||||
| Commit | What |
|
|
||||||
|---|---|
|
|
||||||
| `ef5049f` | #142 per-instance sun gate (indoor objects skip the sun, per-draw like retail) |
|
|
||||||
| `0d8b827` | **THE root-cause fix** — EnvCellRenderer landblock-key bug + viewer light + weenie fixture lights |
|
|
||||||
| `57c2ab7` | #143 dynamic-light D3D `1/d` attenuation (portal + viewer spread softly) |
|
|
||||||
| `653e7f3` | docs: close #142/#143, file #144 |
|
|
||||||
| (this) | docs: file #145 + this handoff |
|
|
||||||
|
|
||||||
**One-line story:** interiors were dark NOT because of the sun/ambient (those were
|
|
||||||
already retail-faithful — the 0.2-flat/sky split == `CellManager::ChangePosition`
|
|
||||||
0x004559B0). The real bug was a landblock-key lookup in
|
|
||||||
`EnvCellRenderer.GetCellLightSet`: it keyed `_landblocks` by `cellId & 0xFFFF0000`
|
|
||||||
(0xXXYY**0000**) instead of the streaming key 0xXXYY**FFFF**, so the lookup missed
|
|
||||||
for EVERY cell → `SelectForObject` never ran → every interior wall got ZERO point
|
|
||||||
lights, always. Fix: `(cellId & 0xFFFF0000) | 0xFFFF`. Found by a `[cell-light]`
|
|
||||||
probe (inBounds=False selected=0 → True selected=3-4) after 3+ speculative fixes.
|
|
||||||
|
|
||||||
Retail mechanisms ported alongside it (all faithful, decomp-cited):
|
|
||||||
- **Viewer light** — `SmartBox::set_viewer` 0x00452c40: white fill pinned to the
|
|
||||||
player every frame; `1/d` att, range 15. `LightManager.UpdateViewerLight`.
|
|
||||||
- **Weenie fixture lights** (AP-44) — server-object `Setup.Lights` registered on
|
|
||||||
spawn (`OnLiveEntitySpawnedLocked`); the meeting-hall portal's magenta light
|
|
||||||
rides this → #143 lit "for free."
|
|
||||||
- **Dynamic vs static attenuation** — `config_hardware_light` 0x0059ad30:
|
|
||||||
dynamic = `1/d` + range×1.5; static = `1/d³` + range×1.3. Per-light `IsDynamic`
|
|
||||||
flag packed into `GlobalLight.coneAngleEtc.y`.
|
|
||||||
|
|
||||||
Register: AP-43 (#142 sun gate), AP-44 (weenie lights), AP-47 (acdream's always-lit
|
|
||||||
interiors vs retail's 40-light pop-in — INTENTIONAL). Full lighting detail:
|
|
||||||
memory `reference_retail_ambient_values.md` (2026-06-20 resolution note).
|
|
||||||
|
|
||||||
**Suites:** Core 1505 / App 476 green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## NEXT SESSION — in this order
|
|
||||||
|
|
||||||
### 1. FIRST — #145: portals only work once per session (HIGH)
|
|
||||||
|
|
||||||
User wants: **run into a portal, run out, re-enter — repeatedly.** Currently only the
|
|
||||||
FIRST teleport works; the second portal use does nothing.
|
|
||||||
|
|
||||||
- **Machinery:** `WorldSession.cs:999` (0xF751 PlayerTeleport) → `PlayerMovementController`
|
|
||||||
`PlayerState.PortalSpace` (`:75`/`:840`) → `TeleportArrivalController` (`Phase`
|
|
||||||
Idle/Holding; the placement delegate flips PortalSpace back to InWorld) →
|
|
||||||
`GameWindow.cs:4903/5332/5362` (PortalSpace observer + arrival placement) →
|
|
||||||
streaming collapse↔expand (#133/#135 machinery).
|
|
||||||
- **First step (don't guess):** instrument a SECOND teleport and find WHERE it stalls —
|
|
||||||
does the 2nd 0xF751 arrive? does PortalSpace toggle back to InWorld? does
|
|
||||||
`TeleportArrivalController.Tick` place via `PlaceTeleportArrival`? does the streaming
|
|
||||||
window re-expand? One probe across those four points pins it.
|
|
||||||
- **Likely overlaps #138** (teleport OUT of a dungeon loads the outdoor world
|
|
||||||
incompletely) — may be the same root or a sibling; check together.
|
|
||||||
|
|
||||||
### 2. THEN — #144: dungeon interiors still too dim vs retail (MEDIUM)
|
|
||||||
|
|
||||||
Dungeons improved (torch cells light up now — the landblock-key fix + viewer fill), but
|
|
||||||
torch-sparse stretches + overall brightness still trail retail (user: "better but not
|
|
||||||
good enough").
|
|
||||||
|
|
||||||
- **Approach:** side-by-side **cdb capture of retail's dungeon** — `world_lights`
|
|
||||||
active set + `world_lights.ambient_color` + the viewer-light intensity at the SAME
|
|
||||||
spot — vs acdream's `[light]`/`[light-detail]` there. Do NOT guess.
|
|
||||||
- **Candidates:** per-vertex Gouraud bake under-lights low-poly dungeon walls (AP-35
|
|
||||||
residual); the sealed-dungeon flat 0.2 ambient too dark; retail leans harder on the
|
|
||||||
viewer light / carries more dynamics.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Launch / probes
|
|
||||||
|
|
||||||
`testaccount2` / `testpassword2` (character Horan, spawns near Holtburg; reaches the
|
|
||||||
0x0007 Town Network dungeon). Canonical launch + `ACDREAM_PROBE_LIGHT=1` +
|
|
||||||
`ACDREAM_PROBE_CELL=1` → `[light]` / `[light-detail]` / `[cell-transit]`. The
|
|
||||||
`[cell-light]` per-cell probe was STRIPPED (the bug it found is fixed) — re-add a
|
|
||||||
per-cell light probe in `EnvCellRenderer.GetCellLightSet` if #144 needs it.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
- memory `reference_retail_ambient_values.md` — lighting resolution + the cdb toolchain.
|
|
||||||
- `docs/architecture/retail-divergence-register.md` — AP-43 / AP-44 / AP-47.
|
|
||||||
- `docs/ISSUES.md` — #144, #145, #138 (related), #137 (dungeon collision).
|
|
||||||
- cdb toolchain: CLAUDE.md "Retail debugger toolchain" (binary matches `refs/acclient.pdb`).
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
# Indoor lighting regime — HANDOFF (#142 windowed-interior regime, #143 portal dynamic light)
|
|
||||||
|
|
||||||
**Date:** 2026-06-20 **Base:** `main` @ `31d7ffd` (A7 #140 + all D.5 work; pushed to both remotes)
|
|
||||||
**Milestone:** M1.5 "Indoor world feels right" **Start with: #142 (issue #1).**
|
|
||||||
**Predecessor:** `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md`
|
|
||||||
(RESOLVED banner — the #140 outdoor fix). Companion: `claude-memory/reference_retail_ambient_values.md`.
|
|
||||||
|
|
||||||
## Where we are
|
|
||||||
|
|
||||||
`#140` (outdoor building over-bright near torches) is **SHIPPED + user-confirmed + merged + pushed.**
|
|
||||||
Real cause: retail lights outdoor objects with SUN + ambient only, never torches (the `useSunlight`
|
|
||||||
gate); fix = gate per-object torch selection on the object being indoor (`IndoorObjectReceivesTorches`,
|
|
||||||
`WbDrawDispatcher.cs`). Register row **AP-43**.
|
|
||||||
|
|
||||||
At the #140 visual gate the user spotted two INDOOR-lighting gaps (the opposite problem — interiors
|
|
||||||
too DARK / "like outdoors"). Both are this handoff. **Neither is a regression from #140** — that fix
|
|
||||||
only *subtracts* torch light from *outdoor* objects.
|
|
||||||
|
|
||||||
## The unifying insight (read this first)
|
|
||||||
|
|
||||||
acdream's lighting **REGIME** (sun on/off + which ambient) is a **per-FRAME global** keyed on whether
|
|
||||||
the PLAYER is in a sealed cell. Retail's is **per-DRAW-STAGE**: the outdoor stage runs with the sun
|
|
||||||
on, the interior-cell stage runs with the sun off + torches on. `#140` fixed the **torch** half of
|
|
||||||
this mismatch *per-object* (AP-43). **#142 is the SUN + AMBIENT half — i.e. the AP-43 residual, now
|
|
||||||
surfaced as a visible bug.** Finishing #142 lets us delete/narrow AP-43.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# #142 (issue #1) — windowed-building interiors read "like outdoors" [PRIMARY]
|
|
||||||
|
|
||||||
### Symptom (user, 2026-06-19, at the #140 gate)
|
|
||||||
> "Agent of Arcanum house — in retail it is much brighter indoors; when looking into the house it is
|
|
||||||
> lit, same light when you walk in. In acdream it is NOT lit — looking in and when inside it feels the
|
|
||||||
> same like it is outdoors."
|
|
||||||
|
|
||||||
The **meeting hall** (a more sealed interior) looked OK — the user only flagged its portal (#143),
|
|
||||||
not its walls. That contrast is the key clue (see "the three gaps").
|
|
||||||
|
|
||||||
### Retail mechanism (VERIFIED — read verbatim this session)
|
|
||||||
`PView::DrawCells` (0x005a4840) draws a frame in two ordered stages:
|
|
||||||
1. **Outside stage:** `useSunlightSet(1)` (0x005a485a) → `LScape::draw` → outdoor terrain/buildings/
|
|
||||||
objects, **sun on, torches skipped** (the #140 mechanism).
|
|
||||||
2. **Interior stage:** `useSunlightSet(0)` (0x005a49f3) → `restore_all_lighting` → loop over **every**
|
|
||||||
EnvCell in `cell_draw_list` → `DrawEnvCell` (0x0059f170): walls baked
|
|
||||||
(`SetStaticLightingVertexColors` 0x0059cfe0), objects torch-lit (`minimize_object_lighting`
|
|
||||||
0x0054d480, enabled because `useSunlight==0` per `DrawMeshInternal` 0x0059f398), **NO sun.**
|
|
||||||
3. `useSunlightSet(1)` (0x005a4b5d) restores outdoor mode at the very end.
|
|
||||||
|
|
||||||
`useSunlightSet(arg)` (0x0054d450): sets `useSunlight=arg`; `arg==1` enables the SUN as the active
|
|
||||||
hardware light, `arg==0` enables none (sun off).
|
|
||||||
|
|
||||||
**KEY FACT:** `cell_draw_list` holds ALL visible EnvCells — windowed (`SeenOutside`) **and** sealed.
|
|
||||||
Retail draws every interior in the `useSunlight==0` stage. The regime is **per-stage, never per-
|
|
||||||
building / per-SeenOutside.** So retail torch-lights *every* building interior, including windowed
|
|
||||||
ones and look-ins viewed from outside.
|
|
||||||
|
|
||||||
### acdream current state (per-FRAME global) — current line refs (@31d7ffd)
|
|
||||||
- `GameWindow.cs:8061` `playerSeenOutside = playerRoot?.SeenOutside ?? true` — the PLAYER cell's flag.
|
|
||||||
- `GameWindow.cs:8107` `playerInsideCell = playerRoot is not null && !playerSeenOutside`.
|
|
||||||
- `GameWindow.cs:8122` `UpdateSunFromSky(kf, playerInsideCell)` → (`:10786`) sets the **global** sun +
|
|
||||||
ambient: inside → sun `Intensity=0` + flat `(0.2,0.2,0.2)` ambient; outside → keyframe sun + outdoor
|
|
||||||
ambient.
|
|
||||||
- That ambient is uploaded ONCE per frame to the SceneLighting UBO (`CurrentAmbient.AmbientColor`,
|
|
||||||
`:8171`) and read by BOTH mode-0 (objects) and mode-1 (EnvCell shells) in `mesh_modern.vert`.
|
|
||||||
- **Torches are ALREADY per-cell** (AP-43: `IndoorObjectReceivesTorches` `WbDrawDispatcher.cs:2076`,
|
|
||||||
used at `:2057`; plus `EnvCellRenderer` `SelectForObject`) — independent of `playerInsideCell`. So
|
|
||||||
the torch half is fine; **only the SUN + AMBIENT are still per-frame-global.**
|
|
||||||
|
|
||||||
### The three gaps (all one root: per-frame-global vs per-stage)
|
|
||||||
1. **Player OUTSIDE, looking INTO any building (look-in):** `playerSeenOutside=true` → outdoor regime
|
|
||||||
→ the look-in interior gets sun + outdoor ambient. Retail draws look-in cells in the `useSunlight=0`
|
|
||||||
stage (torch-lit). → "when looking in, not lit."
|
|
||||||
2. **Player INSIDE a WINDOWED building** (`SeenOutside=true` cells, e.g. Agent of Arcanum):
|
|
||||||
`playerInsideCell=false` → outdoor regime → interior gets sun + outdoor ambient. Retail:
|
|
||||||
`useSunlight=0`, torch-lit. → "when inside, feels like outdoors."
|
|
||||||
3. **Player INSIDE a SEALED building / dungeon** (`SeenOutside=false`): `playerInsideCell=true` →
|
|
||||||
indoor regime → MATCHES retail. ✓ (the meeting hall + dungeons — why they looked right.)
|
|
||||||
|
|
||||||
### Cheap validation FIRST (before any code)
|
|
||||||
- **Confirm the windowed-vs-sealed split is the discriminator.** Verify the Agent of Arcanum is a
|
|
||||||
WINDOWED building (its EnvCells' `SeenOutside=true`) and the meeting hall is sealed. Dat flag:
|
|
||||||
`EnvCellFlags.SeenOutside` (hydrated to `ObjCell.SeenOutside`; see `EnvCell.cs` / `PhysicsDataCache.cs`).
|
|
||||||
We did NOT pin the Agent of Arcanum's landblock this session — either have the user point at it in
|
|
||||||
game (`[B.4b] pick` line names clicked objects), or extend `HoltburgTorchFalloffProbeTests` to dump
|
|
||||||
`SeenOutside` per EnvCell across the Holtburg landblocks and find the windowed buildings.
|
|
||||||
- **`ACDREAM_PROBE_LIGHT=1`** ([light] line logs `insideCell` / ambient / sun) while standing inside
|
|
||||||
the Agent of Arcanum vs the meeting hall — confirms each gets the regime predicted above.
|
|
||||||
|
|
||||||
### Fix direction (BRAINSTORM this — it is a design fork, not a mechanical port)
|
|
||||||
Make the SUN + AMBIENT **per-draw-context**, mirroring AP-43's per-object torch decision. The renderer
|
|
||||||
is batched bindless-MDI, so a per-stage global won't work across mixed batches — per-object is the
|
|
||||||
natural fit (exact same reasoning that put AP-43 per-object; see the #140 explanation). An object/cell
|
|
||||||
is "indoor" iff its `ParentCellId` is an EnvCell (reuse `IndoorObjectReceivesTorches`). Then:
|
|
||||||
- **Indoor draws** (mode-1 EnvCell shells; mode-0 objects with EnvCell `ParentCellId`): SKIP the sun +
|
|
||||||
use the **indoor** ambient (flat `(0.2,0.2,0.2)` / retail indoor). (mode-1 already skips the sun;
|
|
||||||
it just needs the indoor ambient. mode-0 indoor objects currently ADD the sun — gate it off.)
|
|
||||||
- **Outdoor draws:** sun + outdoor ambient (as today).
|
|
||||||
|
|
||||||
Open design questions for the brainstorm:
|
|
||||||
- The shader needs BOTH ambients (indoor + outdoor) + a per-instance "indoor" selector. Options:
|
|
||||||
(a) add an `indoorAmbient` to the SceneLighting UBO + a per-instance indoor bit (a tiny SSBO like
|
|
||||||
the light-set, or pack into an existing per-instance field); (b) add a third `uLightingMode` (e.g.
|
|
||||||
`2 = indoor object`: no sun, indoor ambient, torches); (c) compute both and select.
|
|
||||||
- `UpdateSunFromSky` must stop branching on `playerInsideCell` and instead provide BOTH regimes every
|
|
||||||
frame (outdoor sun + outdoor ambient AND the indoor flat ambient), so the shader picks per object.
|
|
||||||
- **Verify retail's indoor ambient** (the `restore_all_lighting` path + the per-EnvCell ambient): is it
|
|
||||||
the flat `(0.2,0.2,0.2)` we use, or the cell's own authored ambient? Cross-check before locking it.
|
|
||||||
|
|
||||||
**This work RESOLVES the AP-43 residual** (regime becomes per-draw → no doorway/look-in mismatch).
|
|
||||||
Update/delete AP-43 in the same commit.
|
|
||||||
|
|
||||||
### Files
|
|
||||||
- `GameWindow.cs`: `:8061`/`:8107` (`playerInsideCell`), `:8122` + `:10786` `UpdateSunFromSky` (the
|
|
||||||
regime source), `:8171` (ambient → UBO).
|
|
||||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`: `accumulateLights` (sun loop under
|
|
||||||
`if (uLightingMode==0)` ~`:193`; ambient `uCellAmbient.xyz` ~`:188`). The sun gate + ambient
|
|
||||||
selection live here.
|
|
||||||
- `WbDrawDispatcher.cs`: `IndoorObjectReceivesTorches` (`:2076`) — the indoor predicate to reuse;
|
|
||||||
`ComputeEntityLightSet` (`:2057`).
|
|
||||||
- `EnvCellRenderer.cs`: mode-1 draws (`uLightingMode=1`) — need the indoor ambient.
|
|
||||||
- `LightManager` / the SceneLighting UBO layout (`GlobalLightPacker` is the binding-4 helper) — where a
|
|
||||||
second ambient + the indoor selector would go.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# #143 (issue #2) — portal swirl doesn't light the room [SECONDARY]
|
|
||||||
|
|
||||||
### Symptom
|
|
||||||
Inside the meeting hall, retail's portal swirl visibly tints/lights the room; acdream's portal lights
|
|
||||||
nothing.
|
|
||||||
|
|
||||||
### Retail mechanism
|
|
||||||
The portal swirl is a **DYNAMIC** light. `add_dynamic_light` (0x0054d420) → `insert_light`
|
|
||||||
(0x0054d1b0) → `world_lights.dynamic_lights`. `minimize_envcell_lighting` (0x0054c170) enables the
|
|
||||||
cell's DYNAMIC subset (class 2) as hardware lights → tints the EnvCell walls; `minimize_object_lighting`
|
|
||||||
(0x0054d480) enables dynamics for objects in the cell too. **Captured params** (predecessor cdb,
|
|
||||||
`tools/cdb/a7-fixd-*.cdb`): the Holtburg portal dynamic light = `intensity=100, falloff=6,
|
|
||||||
color=(0.784, 0, 0.784)` (magenta/purple).
|
|
||||||
|
|
||||||
### acdream gap
|
|
||||||
acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~`:6404` `RegisterOwnedLight`). It
|
|
||||||
registers **no dynamic lights** — the portal entity casts no light. (`GpuWorldState.cs:101` even
|
|
||||||
mentions "unregistering dynamic lights" but none are ever registered.)
|
|
||||||
|
|
||||||
### Fix approach
|
|
||||||
Register a dynamic `LightSource` for portal-swirl entities at their world position with the retail
|
|
||||||
params (or read the portal model's own dat `Setup.Lights` if it carries one — check the portal GfxObj/
|
|
||||||
Setup first). It then flows through the existing point-light path (`LightManager.PointSnapshot` →
|
|
||||||
`SelectForObject` → shader), lighting nearby EnvCell walls + indoor objects. It is a POINT light, lives
|
|
||||||
INSIDE a cell → it must light via the indoor path (the EnvCell bake `SelectForObject` already picks any
|
|
||||||
registered point light near a cell, so registering it may "just work" once it has a `LightSource`).
|
|
||||||
Find where portal swirls spawn in acdream (the particle/portal emitter spawn path) and attach the light
|
|
||||||
there; unregister on despawn (`UnregisterByOwner`). Keep it OUT of the AP-43 outdoor-object gate (it's
|
|
||||||
indoor). Decomp anchors: `add_dynamic_light` 0x0054d420, `minimize_envcell_lighting` 0x0054c170,
|
|
||||||
`insert_light` 0x0054d1b0.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decomp anchors (quick reference)
|
|
||||||
`useSunlightSet` 0x0054d450 · `useSunlight` gate `DrawMeshInternal` 0x0059f398 · `PView::DrawCells`
|
|
||||||
0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3 / `useSunlightSet(1)`
|
|
||||||
0x005a4b5d) · `DrawEnvCell` 0x0059f170 · `SetStaticLightingVertexColors` 0x0059cfe0 · `calc_point_light`
|
|
||||||
0x0059c8b0 (range = falloff × `static_light_factor` 1.3 @ 0x00820e24) · `minimize_object_lighting`
|
|
||||||
0x0054d480 · `minimize_envcell_lighting` 0x0054c170 · `add_dynamic_light` 0x0054d420 · `insert_light`
|
|
||||||
0x0054d1b0 · `config_hardware_light` 0x0059ad30 (`rangeAdjust` 1.5 @ 0x00820cc4 — the dynamic/object
|
|
||||||
hardware path).
|
|
||||||
|
|
||||||
## DO-NOT-RETRY / gotchas
|
|
||||||
- The OUTDOOR torch gate (#140 / AP-43) is correct + user-confirmed — don't touch it.
|
|
||||||
- Don't shorten `Falloff × 1.3` — acdream reads the dat falloffs faithfully (the reach is correct).
|
|
||||||
- The regime is a per-FRAME global; the fix is to make sun+ambient **per-DRAW** (per-object/cell),
|
|
||||||
mirroring AP-43's torch decision — **NOT** to split into separate render passes (fights the batched
|
|
||||||
MDI; the per-object route is why AP-43 exists).
|
|
||||||
- Line numbers above are @`31d7ffd` and WILL drift — re-grep `playerInsideCell` / `UpdateSunFromSky` /
|
|
||||||
`IndoorObjectReceivesTorches` before editing.
|
|
||||||
|
|
||||||
## Verification (the acceptance gate)
|
|
||||||
Visual side-by-side vs retail at the **Agent of Arcanum** (looking IN from outside + walking IN) and
|
|
||||||
the **meeting-hall portal**. Expected after #142: interiors are torch-lit/warm both looking-in and
|
|
||||||
inside; windowed buildings no longer "feel like outdoors." After #143: the portal swirl tints the room.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
- Register: **AP-43** (`docs/architecture/retail-divergence-register.md`) — the residual this work
|
|
||||||
resolves.
|
|
||||||
- `claude-memory/reference_retail_ambient_values.md` — cdb values incl. the portal dynamic-light
|
|
||||||
capture + the indoor/outdoor ambient numbers.
|
|
||||||
- `claude-memory/project_render_pipeline_digest.md` — per-cell light + look-in (#124) + flap context.
|
|
||||||
- #140 CHECKPOINT (above) — the full outdoor-torch story + the verified `useSunlight` decomp.
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# A7 indoor torch/lantern lighting — why acdream interiors read dark (investigation)
|
|
||||||
|
|
||||||
**Date:** 2026-06-20 **Mode:** report-only investigation (no code changed)
|
|
||||||
**Trigger:** after #142 sun-gate (`ef5049f`) the Agent of Arcanum interior
|
|
||||||
(`0xA9B40171/0172`) still reads dark; user: "retail uses torches **and lanterns**
|
|
||||||
for lighting indoors."
|
|
||||||
|
|
||||||
## Retail oracle — the static-light bake (decomp, confirmed)
|
|
||||||
|
|
||||||
`SetStaticLightingVertexColors` (0x0059cfe0), per EnvCell mesh, per vertex:
|
|
||||||
|
|
||||||
```
|
|
||||||
for each vertex V:
|
|
||||||
acc = (0,0,0) // from BLACK; no ambient/sun here
|
|
||||||
for i in 0 .. world_lights.num_static_lights: // ALL lights — NO per-vertex/cell cap
|
|
||||||
L = sorted_static_lights[i]
|
|
||||||
if L.type==0: calc_point_light(V, acc, L) // 0x0059c8b0
|
|
||||||
else: acc += dot(N,dir)*scale*L.color
|
|
||||||
acc = clamp(acc, 0, 1) // per-channel, AFTER summing all
|
|
||||||
V.diffuse = encode(acc)
|
|
||||||
```
|
|
||||||
|
|
||||||
`calc_point_light` (0x0059c8b0): `range = falloff × static_light_factor(1.3)`;
|
|
||||||
back-face/range gated; `angular = dot_n/dist` (<1 m) else `dot_n/(dist²·dist)`;
|
|
||||||
`scale = angular·(1−dist/range)·intensity`; per-channel cap `min(scale·c, c)`.
|
|
||||||
|
|
||||||
**Global cap:** `max_static_lights = 0x28 = 40` (`insert_light` 0x0054d1b0 keeps a
|
|
||||||
distance-sorted, replace-farthest list). So the bake sees up to **40 nearest static
|
|
||||||
lights — and sums ALL of them per vertex.** There is no 8-light cap on the static
|
|
||||||
wall bake. (The 8-light cap is the D3D *hardware* path for *dynamic objects*,
|
|
||||||
`minimize_object_lighting` 0x0054d480 — a different path.)
|
|
||||||
|
|
||||||
## acdream's torch path — three real divergences
|
|
||||||
|
|
||||||
| # | Divergence | Site | Effect |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Bug A | **Weenie fixture lights never registered.** `RegisterOwnedLight` is called ONLY from `ApplyLoadedTerrainLocked` for dat-baked landblock + EnvCell statics (Setup `0x02xxxxxx`, `Lights.Count>0`). Server `CreateObject` weenies (`OnLiveEntitySpawnedLocked`) never register lights. | `GameWindow.cs:6733-6758` (registers); `GameWindow.cs:2696/3275` (weenie path — no register) | If a lantern/torch is a server weenie, its light is **entirely absent**. |
|
|
||||||
| Bug B | **Per-cell 8-light cap.** `GetCellLightSet`→`SelectForObject` keeps the **8 nearest-to-cell-center** lights (`MaxLightsPerObject=8`); 9th+ dropped. Retail sums up to 40. | `EnvCellRenderer.cs:1044`; `LightManager.cs:234-278` | Dense cells (dungeon corridors, fixture clusters) lose lights. **Not binding** for the Arcanum (only ~3 in range). |
|
|
||||||
| Bug C | **128 global camera pre-filter.** `BuildPointLightSnapshot` keeps the 128 nearest-**to-camera** lights (`MaxGlobalLights=128`), drops the rest before per-cell selection. Retail has no global per-frame cap on statics. | `LightManager.cs:179,208-211` | `registeredLights=129` ⇒ already at the cap; drops farthest-from-camera. Hurts far lights, not the near interior. |
|
|
||||||
|
|
||||||
Bug A·partial: the per-vertex bake is also approximated as **one 8-light set per
|
|
||||||
whole cell** (uniform across all the cell's vertices), vs retail's true per-vertex
|
|
||||||
bake — a fidelity gap (bright cluster smears across the cell), secondary to the
|
|
||||||
source-count gaps above.
|
|
||||||
|
|
||||||
The per-light shader formula (`pointContribution`) + the `min(pointAcc,1.0)` torch
|
|
||||||
clamp **match** retail's `calc_point_light` + final saturate — magnitude per light
|
|
||||||
is faithful. The gap is **missing / capped light SOURCES**, not per-light math.
|
|
||||||
|
|
||||||
## Ranked hypotheses (for the Agent of Arcanum specifically)
|
|
||||||
|
|
||||||
1. **H1 — the lanterns are server weenies ⇒ Bug A drops their light** (best fit
|
|
||||||
for the "lanterns" hint). The cell shows only ~3 *dat* torches; lanterns absent.
|
|
||||||
*Falsify:* pick a lantern in-game — guid `0x80…`/`0x7…` = weenie ⇒ confirmed.
|
|
||||||
2. **H4 — per-cell (not per-vertex) bake / magnitude** — if the Arcanum has no
|
|
||||||
weenie fixtures, the 3 dat torches are all there is and the per-cell-uniform bake
|
|
||||||
under-fills vs retail's per-vertex bake.
|
|
||||||
3. **H2/H3 (8-cap / 128-cap)** — real retail divergences, but **not binding** for
|
|
||||||
this building (≤3 lights in range, near camera). They matter for dense dungeons.
|
|
||||||
|
|
||||||
## Fix plan (faithful, per hypothesis)
|
|
||||||
|
|
||||||
- **H1 (likely):** register `Setup.Lights` for light-bearing weenies in
|
|
||||||
`OnLiveEntitySpawnedLocked`, mirroring `ApplyLoadedTerrainLocked:6742-6758`
|
|
||||||
(guard `setup.Lights.Count>0`); unregister on despawn (`UnregisterByOwner`).
|
|
||||||
Faithful: retail registers object lights regardless of static/dynamic origin.
|
|
||||||
- **H2:** raise the per-cell static bake cap from 8 toward retail's 40 (the cap is
|
|
||||||
retail-correct only for the *dynamic-object hardware* path, not the wall bake).
|
|
||||||
- **H3:** make the per-cell selection independent of the 128 camera pre-filter for
|
|
||||||
statics (retail's bake has no global count cap).
|
|
||||||
- **H4:** move the cell bake toward per-vertex (longer-horizon A7 LightBake work).
|
|
||||||
|
|
||||||
## What this is NOT
|
|
||||||
NOT a #142 sun-gate regression and NOT an ambient bug (ambient matches retail;
|
|
||||||
it's just dim rainy-twilight). It is missing / capped light **sources**.
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
# Handoff — window manager → inventory window → paperdoll (the next D.2b core-panels arc)
|
|
||||||
|
|
||||||
**Date:** 2026-06-20
|
|
||||||
**From:** the D.5.3 session that shipped the drag-drop spine (B.1), toolbar shortcut reorder/remove +
|
|
||||||
wire (B.2), and toolbar collapse-to-one-row — all **merged to `main`** (merge commit `abbd97b`;
|
|
||||||
branch `claude/hopeful-maxwell-214a12`, tip `14443e5`). Full suite green (2752) at merge.
|
|
||||||
**Line numbers below WILL drift — grep the symbol, don't trust the line.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. What just shipped (the foundation the next work builds on)
|
|
||||||
|
|
||||||
This is now on `main` and visually confirmed:
|
|
||||||
|
|
||||||
- **Drag-drop spine (B.1)** — the shared widget-level drag machine. `UiRoot` holds the device-level
|
|
||||||
drag (BeginDrag/UpdateDragHover/FinishDrag, ghost snapshotted at begin, drop delivered only on a real
|
|
||||||
hit). `UiElement` has `GetDragPayload()`/`GetDragGhost()`/`IsDragSource` virtuals (UiRoot stays
|
|
||||||
item-agnostic). `UiItemSlot` is drag source + drop target + accept/reject overlay; `UiItemList` owns
|
|
||||||
a registered `IItemListDragHandler { OnDragLift; OnDragOver; HandleDropRelease }`. Payload =
|
|
||||||
`ItemDragPayload(ObjId, SourceKind, SourceSlot, SourceCell)`. Spec/plan:
|
|
||||||
`docs/superpowers/{specs,plans}/2026-06-20-d2b-drag-drop-spine*.md`.
|
|
||||||
- **Toolbar shortcut drag (B.2)** — retail **remove-on-lift / place-on-drop / no-restore**.
|
|
||||||
`ToolbarController : IItemListDragHandler` drives a `ShortcutStore` (`AcDream.Core.Items`, 18 slots)
|
|
||||||
+ the wire: `InventoryActions.BuildAddShortcut(seq,index,objectGuid,spellId,layer)` /
|
|
||||||
`BuildRemoveShortcut`, sent via `WorldSession.SendAddShortcut`/`SendRemoveShortcut`. Green-cross
|
|
||||||
accept sprite `0x060011FA`. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-20-d2b-toolbar-shortcut-drag*.md`.
|
|
||||||
- **Toolbar collapse** — `UiCollapsibleFrame` bottom-edge snap resize (1-row ↔ 2-row). Spec:
|
|
||||||
`docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md`.
|
|
||||||
|
|
||||||
**The one B-stream piece still owed:** *drag FROM inventory onto the bar.* B.2 did the within-bar
|
|
||||||
reorder/remove (`SourceKind == ShortcutBar`). The fresh-from-inventory branch (retail `flags & 0xE == 0`
|
|
||||||
→ `CreateShortcutToItem`) needs the inventory window as a drag SOURCE — so it lands WITH Stream C
|
|
||||||
below, not before it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Read first
|
|
||||||
|
|
||||||
- This doc.
|
|
||||||
- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 — **the build plan** for the core panels
|
|
||||||
(build order, widget list, cross-panel wire table). Stream C follows it.
|
|
||||||
- `docs/research/2026-06-16-inventory-deep-dive.md` — `gmInventoryUI 0x21000023` nests paperdoll
|
|
||||||
(`0x21000024`) + backpack (`0x21000022`) + 3D-items (`0x21000021`); backpack burden Meter
|
|
||||||
(`SetLoadLevel`→fill `0x69`); full inventory wire catalog + acdream parse-status.
|
|
||||||
- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the doll = `UIElement_Viewport`
|
|
||||||
(Type `0xD`) hosting a re-dressed player clone; ~25 equip slots; wield = `GetAndWieldItem 0x001A`
|
|
||||||
(builder MISSING); needs a Core→App `IUiViewportRenderer` seam.
|
|
||||||
- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §C — the predecessor's
|
|
||||||
current-code readiness for the inventory window (most still accurate; deltas noted below).
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` (the toolkit, now incl. B.1/B.2/collapse) +
|
|
||||||
`claude-memory/project_object_item_model.md` (the D.5.4 `ClientObjectTable`).
|
|
||||||
|
|
||||||
**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
|
|
||||||
`class::method` → cross-ref ACE/holtburger → pseudocode → port; conformance tests throughout. Each
|
|
||||||
sub-phase gets its own brainstorm → spec → plan → subagent-driven (the flow this arc has been using).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Scope — three sub-phases, in order
|
|
||||||
|
|
||||||
| # | Sub-phase | One-liner |
|
|
||||||
|---|---|---|
|
|
||||||
| **A** | **Window manager (open/close + `I`-key)** | The small shared infra to open/close a window + a keybind to toggle the inventory window. Prerequisite for everything below. |
|
|
||||||
| **B** | **Inventory window (Stream C / D.5.5)** | `gmInventoryUI` nesting paperdoll + backpack + 3D-items. The drag SOURCE that completes "drag-from-inventory." |
|
|
||||||
| **C** | **Paperdoll** (`UiViewport`) | The 3D dressed-doll viewport — the heaviest piece, its own Core→App seam. Last. |
|
|
||||||
|
|
||||||
**Out of scope:** the spell bar (a separate spell-casting feature, NOT the action-bar spell shortcuts);
|
|
||||||
the faithful dragbar/resizebar window resize (IA-12 whole-window-drag + the new bottom-edge collapse
|
|
||||||
are the accepted approximations).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Sub-phase A — window manager (open/close + `I`-key) — DO THIS FIRST
|
|
||||||
|
|
||||||
**Current state:** every retail-UI window is **always-on at a hardcoded position** — the vitals,
|
|
||||||
chat, and toolbar are added to `_uiHost.Root` unconditionally at startup under `ACDREAM_RETAIL_UI=1`
|
|
||||||
(grep `_uiHost.Root.AddChild` in `GameWindow.cs`; the mounts are in the `_options.RetailUi` block).
|
|
||||||
`UiHost` has **no open/close API** (`src/AcDream.App/UI/UiHost.cs` — it exposes `Root`, `Tick`,
|
|
||||||
`Draw`, `WireMouse/Keyboard` only). Input flows through `InputDispatcher` (Phase K) —
|
|
||||||
`keybinds.json` + `KeyBindings`; the `I` key needs an action + binding.
|
|
||||||
|
|
||||||
**Minimum deliverable:** open/close a top-level window + toggle the inventory window with `I`.
|
|
||||||
Concretely:
|
|
||||||
- A small open/close concept on `UiHost`/`UiRoot` (e.g. register a named window + Show/Hide/Toggle that
|
|
||||||
flips `Visible` and brings it to front via `ZOrder`). Today windows are always `Visible=true`; the
|
|
||||||
manager makes a window default-hidden and toggleable. (z-order/persist can be minimal — `Visible` +
|
|
||||||
a top-most `ZOrder` bump on open.)
|
|
||||||
- An `InputAction` (e.g. `ToggleInventory`) + the retail default `I` keybind (cross-check
|
|
||||||
`docs/research/named-retail/retail-default.keymap.txt`), wired through the existing
|
|
||||||
`InputDispatcher` (see `claude-memory/project_input_pipeline.md`), gated by `WantsKeyboard` so it
|
|
||||||
doesn't fire while a chat input is focused.
|
|
||||||
|
|
||||||
**Brainstorm questions (A):** Is the window manager a property on `UiRoot` (a registry of named
|
|
||||||
top-level windows + Show/Hide/Toggle), or a thin `UiHost` API? How is z-order handled on open (bring
|
|
||||||
to front)? Does `I` toggle (open↔close) or only open (Esc/close-button to close)? Persist open-state
|
|
||||||
across the session (in-memory) only — no disk persistence yet (that's the deferred Plan-2)?
|
|
||||||
|
|
||||||
Faithful retail note: retail windows are opened by the radar/menu buttons + hotkeys and managed by
|
|
||||||
keystone.dll (no decomp) — so this is a toolkit-defined manager (IA-12/IA-15 umbrella), kept minimal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Sub-phase B — inventory window (Stream C / D.5.5) — follow the synthesis §4
|
|
||||||
|
|
||||||
**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the
|
|
||||||
**current-code readiness** + what's missing. Don't re-derive the design.
|
|
||||||
|
|
||||||
**READY (and stronger now, post-B.1/B.2):**
|
|
||||||
- `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`) — the shared item-cell spine,
|
|
||||||
now with the full drag-drop machine (B.1). An inventory grid is `UiItemList` cells + an
|
|
||||||
`IItemListDragHandler` on the controller.
|
|
||||||
- `DatWidgetFactory` registers `0x10000031 → UiItemList` (grep `0x10000031` in
|
|
||||||
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`).
|
|
||||||
- The data path: `ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full
|
|
||||||
icon fields (`src/AcDream.Core/Items/ClientObjectTable.cs`). The object/container model shipped in
|
|
||||||
D.5.4 (`project_object_item_model.md`).
|
|
||||||
- The shortcut wire + `ShortcutStore` (B.2) — so **drag-from-inventory-onto-the-bar** is now a small
|
|
||||||
addition: the inventory cell is a drag SOURCE (its handler's `OnDragLift` does NOT remove — an
|
|
||||||
inventory→bar drag creates a shortcut REFERENCE; the item stays in the pack), and `ToolbarController.
|
|
||||||
HandleDropRelease` gains the `SourceKind == Inventory` branch (place a new shortcut at the target via
|
|
||||||
`SendAddShortcut`, no source-bump) — retail's `flags & 0xE == 0` `CreateShortcutToItem` path.
|
|
||||||
|
|
||||||
**MISSING (the build, in synthesis order — see the 2026-06-18 handoff §C.1-6 for the detailed list):**
|
|
||||||
1. **Window manager** — §3 above (do first).
|
|
||||||
2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs`; `Flush`/`AddItem`
|
|
||||||
skeleton exists, no column-count/pitch/wrap). LIKELY ~6 cols; confirm from
|
|
||||||
`UIElement_ItemList::ItemList_AddItem`.
|
|
||||||
3. **Sub-window mount in `LayoutImporter`** — `gmInventoryUI 0x21000023` nests `gm*UI` children with
|
|
||||||
their own `BaseLayoutId`; the importer only does TEMPLATE inheritance today
|
|
||||||
(`src/AcDream.App/UI/Layout/LayoutImporter.cs`) — instantiating a nested `gm*UI` window is new.
|
|
||||||
4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
|
|
||||||
`NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize 0x0197`,
|
|
||||||
`InventoryRemoveObject`; fix `ParsePutObjInContainer` (drops the 4th `containerType`) +
|
|
||||||
`ParseInventoryServerSaveFailed` (drops `weenieError`); register `ViewContents`/`0x019A`/`0x0052`/
|
|
||||||
`0x00A0` in `GameEventWiring`. (Grep these symbols — none landed this session.)
|
|
||||||
5. **`InventoryController`** (`gm*UI::PostInit` find-by-id pattern): backpack burden Meter
|
|
||||||
(`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, `ObjDescEvent 0xF625` → re-dress.
|
|
||||||
|
|
||||||
**Brainstorm questions (B):** Sub-window mount — recursive `Import()` in `LayoutImporter`, or an
|
|
||||||
external stitch by the controller? Grid column count (confirm 6 from decomp)? Open by `I`-key only, or
|
|
||||||
also a toolbar inventory button? The inventory cell's drag semantics (lift does NOT remove from the
|
|
||||||
pack — it's a copy-to-shortcut for the bar, but a real MOVE between packs DOES relocate via
|
|
||||||
`PutItemInContainer 0x0019`) — pin the SourceKind→action matrix (deep-dive §5.7 opcode table).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Sub-phase C — paperdoll (`UiViewport`, Type 0xD) — heaviest, last
|
|
||||||
|
|
||||||
**The single biggest new piece.** No widget, no factory registration, no renderer today. Needs an
|
|
||||||
`IUiViewportRenderer` **Core→App seam** (structure Rule 2) for a scissored single-entity GL pass — the
|
|
||||||
doll is the local player's ObjDesc-dressed entity in a fixed viewport. ~25 equip slots; the
|
|
||||||
element-id→`EquipMask` map; wield = `GetAndWieldItem 0x001A` (builder still MISSING). Brainstorm
|
|
||||||
separately (it's substantial). Follow `2026-06-16-equipment-paperdoll-deep-dive.md`.
|
|
||||||
|
|
||||||
**Brainstorm questions (C):** Does the doll clone the player `WorldEntity` or build a fresh
|
|
||||||
ObjDesc-dressed `AnimatedEntityState` (the player is the camera, so there's no player-as-renderable
|
|
||||||
today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Scissor infra (the toolkit has no
|
|
||||||
GL scissor yet — see the collapse spec's clipping discussion).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Build order + dependency graph
|
|
||||||
|
|
||||||
```
|
|
||||||
A. window manager (open/close + I-key) ← do first; unblocks B
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
B. inventory window (grid + sub-window mount + wire gaps + InventoryController)
|
|
||||||
│ └─ also completes B.2's drag-from-inventory (inventory cell = drag source;
|
|
||||||
│ ToolbarController.HandleDropRelease gains the SourceKind==Inventory branch)
|
|
||||||
▼
|
|
||||||
C. paperdoll (UiViewport + IUiViewportRenderer seam + PaperDollController) ← heaviest, last
|
|
||||||
```
|
|
||||||
|
|
||||||
Critical-path note: A is small and unblocks B; the inventory window (B) is the big one and the drag
|
|
||||||
*source* that closes the B-stream; the paperdoll (C) is the heaviest and independent enough to come
|
|
||||||
last.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. ⚠ State notes for the fresh session
|
|
||||||
|
|
||||||
- **Start from `main`** (merge `abbd97b`) — it has all of D.5.2 + D.5.4 + this session's D.5.3 B.1/B.2
|
|
||||||
+ the collapse, plus the indoor-lighting handoff (`f7f3e08`, issues `#142`/`#143` are LIGHTING, not
|
|
||||||
UI). Create a new worktree off `main`.
|
|
||||||
- **ISSUES:** `#144` is the (LOW, latent) empty-item-slot click note from B.1; `#141` is the toolbar
|
|
||||||
selected-object display (D.5.3a done; mana/stack deferred). `#142`/`#143` are the lighting issues.
|
|
||||||
- The drag spine's `IItemListDragHandler` + `ItemDragPayload` + `ShortcutStore` + the shortcut wire all
|
|
||||||
exist now — reuse them; don't rebuild.
|
|
||||||
- Don't re-port what WorldBuilder/the toolkit already gives (read `worldbuilder-inventory.md` first for
|
|
||||||
any dat/render work).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-20-window-manager-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives + synthesis §4 it references. The drag-drop spine (B.1), toolbar shortcut reorder/remove + wire (B.2), and toolbar collapse all shipped + merged to `main` (`abbd97b`). Next, in order: **(A)** a minimal **window manager** — open/close + an `I`-key toggle for the inventory window (today every retail-UI window is always-on at a hardcoded position; `UiHost` has no open/close API); **(B)** the **inventory window** (`gmInventoryUI` nesting paperdoll/backpack/3D-items — `UiItemList` N-cell grid + sub-window mount in `LayoutImporter` + the inventory wire gaps + `InventoryController`; it's the drag SOURCE that completes B.2's drag-from-inventory via a `SourceKind==Inventory` branch in `ToolbarController.HandleDropRelease`); **(C)** the **paperdoll** `UiViewport` (Type 0xD) 3D doll, with a Core→App `IUiViewportRenderer` seam — heaviest, last. Spell bar DEFERRED. Use the full brainstorm → spec → plan → subagent-driven flow per sub-phase; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Reuse the shipped spine (`IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut/RemoveShortcut wire) — don't rebuild it. Resolve objects via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Start a new worktree off `main`.
|
|
||||||
|
|
||||||
**MEMORY.md index line:**
|
|
||||||
- [Handoff: window manager → inventory → paperdoll (2026-06-20)](research/2026-06-20-window-manager-inventory-handoff.md) — next D.2b-UI arc after B.1/B.2/collapse (all merged to main `abbd97b`). 3 sub-phases: (A) window manager (open/close + I-key — UiHost has no open/close API today; windows are always-on); (B) inventory window (Stream C, synthesis §4 — UiItemList grid + sub-window mount + wire gaps DropItem 0x1B/GetAndWieldItem 0x1A/ViewContents 0x196 + InventoryController; the drag SOURCE that closes B.2's drag-from-inventory via a SourceKind==Inventory branch); (C) paperdoll UiViewport Type 0xD + Core→App IUiViewportRenderer seam (heaviest). Reuse the shipped drag spine + ShortcutStore + shortcut wire. Spell bar DEFERRED.
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
# Handoff — #138: server objects + own avatar don't come back after a teleport (entity RE-DELIVERY)
|
|
||||||
|
|
||||||
**Date:** 2026-06-21
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main, NOT pushed** (user's call)
|
|
||||||
**Milestone:** M1.5 "Indoor world feels right"
|
|
||||||
**HEAD:** `c0b2cf2` (docs) on top of `a15bd3b` (the #145 fix). Tree is clean.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **✅ RESOLVED 2026-06-21 (`bf66fb4`+`0a5f91b`+`aa4a04d`, branch `claude/thirsty-goldberg-51bb9b`;
|
|
||||||
> pending user visual gate).** The re-delivery diagnosis below was CONFIRMED by cross-reference
|
|
||||||
> (ACE never clears `KnownObjects` on a teleport so it won't re-send known objects; retail/holtburger
|
|
||||||
> keep the client object table and re-render from it). **BUT this handoff's recommended fix source
|
|
||||||
> was WRONG:** `ClientObjectTable` is the INVENTORY model (`ClientObject` has no world
|
|
||||||
> position/cell/Setup — see `src/AcDream.Core/Items/ClientObject.cs`) and cannot rebuild a render
|
|
||||||
> entity. The real retained world-object table is **`GameWindow._lastSpawnByGuid`** (parsed
|
|
||||||
> `WorldSession.EntitySpawn` records with position + Setup + appearance); it survives the collapse
|
|
||||||
> and is what we re-project from. Shipped fix: `LandblockEntityRehydrator` + `StreamingController.
|
|
||||||
> onLandblockLoaded` + `GameWindow.RehydrateServerEntitiesForLandblock`, plus a pending-bucket
|
|
||||||
> persistent-entity rescue for the player-vanish. See `docs/ISSUES.md` #138 + register AP-48. The
|
|
||||||
> rest of this doc is the (accurate) investigation trail; ignore its `ClientObjectTable` fix advice.
|
|
||||||
|
|
||||||
## What shipped this session
|
|
||||||
|
|
||||||
| Commit | What |
|
|
||||||
|---|---|
|
|
||||||
| `a15bd3b` | **#145 FIXED + user-verified** — portals work repeatedly (run in / out / re-enter). Server-authoritative teleport placement. |
|
|
||||||
| `c0b2cf2` | docs: #138 re-scoped to entity re-delivery + this handoff. |
|
|
||||||
|
|
||||||
**#145 one-liner (context, DONE):** teleport-OUT of a dungeon mis-rooted the player into the
|
|
||||||
SOURCE dungeon's coordinate frame (stale physics-landblock world-offset overlap after the
|
|
||||||
streaming recenter), so acdream sent dungeon-framed positions and ACE rejected every move
|
|
||||||
("failed transition"). Fix = (1) drop the stale source center landblock from physics at the
|
|
||||||
recenter so `Resolve` falls through to the server position (NO-LANDBLOCK verbatim), (2) place
|
|
||||||
outdoor teleports immediately (streaming can't progress during a PortalSpace hold), (3) clear a
|
|
||||||
dangling `CellGraph.CurrCell` when its landblock is removed (else the dungeon-streaming gate
|
|
||||||
keeps streaming collapsed → only skybox). Detail: ISSUES `#145`, digest
|
|
||||||
`claude-memory/project_physics_collision_digest.md` (2026-06-20 entry).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## THE NEXT WORK — #138: entity re-delivery across a teleport
|
|
||||||
|
|
||||||
**Symptom (user):** after a portal OUT of the 0x0007 dungeon back to Holtburg, the
|
|
||||||
server-spawned objects (doors, NPCs, portals, chests) don't appear; and after a couple of
|
|
||||||
round-trips the player's OWN avatar stops rendering too. **Other clients (retail) DO see the
|
|
||||||
acdream player** — so the server has correct player state; acdream's LOCAL world is incomplete.
|
|
||||||
|
|
||||||
### ✅ ROOT CAUSE (confirmed this session): RE-DELIVERY, not render, not cache
|
|
||||||
|
|
||||||
acdream **unloads** the landblock's server-spawned objects on teleport-IN (the dungeon collapse
|
|
||||||
unloads neighbours, including Holtburg → `GpuWorldState.RemoveLandblock`). On the way back,
|
|
||||||
**nothing restores them**, and ACE does **not reliably re-broadcast** them. So they are simply
|
|
||||||
absent from acdream's world.
|
|
||||||
|
|
||||||
Decisive run (`notan`/`+Je`, walked around 20 s after returning): after teleport-OUT,
|
|
||||||
`live: spawn` doors = **0**, `[ent] +` appends = **0**, `[ent-flat] server=1` (only the player),
|
|
||||||
`[dyn] dyn=1`. The server delivered **zero** Holtburg objects on return. (An earlier
|
|
||||||
`testaccount2` run got ~15 re-sent — but the user confirmed they *still* stayed missing — so
|
|
||||||
re-delivery is **partial AND unreliable** across accounts/sessions, possibly worsened by the
|
|
||||||
session's rapid-relaunch churn confusing ACE's per-player known-set.)
|
|
||||||
|
|
||||||
### 🚫 DO-NOT-RETRY (eliminated this session — don't re-investigate)
|
|
||||||
|
|
||||||
| Hypothesis | Why it's dead |
|
|
||||||
|---|---|
|
|
||||||
| Tier-1 classification cache (`EntityClassificationCache`) | Re-created live entities get a **fresh monotonic `Id = _liveEntityIdCounter++`** (`GameWindow` ~:3251). The cache is keyed on `Id`, so it's **always a miss** for them. `ACDREAM_DISABLE_TIER1_CACHE=1` is irrelevant. |
|
|
||||||
| Render-side cull (WALK / `EntityPassesVisibleCellGate` / `DrawDynamicsLast`) | The render path is **fine when entities are present**: at login `[dyn] rootOutdoor=True dyn=54 drawn=33`. The dynamics partition + `DrawDynamicsLast` draw every `ServerGuid!=0` entity (with `MeshRefs>0`). After teleport `dyn=1` only because the flat view itself has only 1 server entity (the player). |
|
|
||||||
| Storage drop in `GpuWorldState` (AddLandblock record-replace, pending-merge) | The append/pending/flat-view path is correct; when objects ARE received they reach the flat view fine. The gap is that they're **never received** after the teleport. |
|
|
||||||
|
|
||||||
### 🔧 FIX DIRECTION (recommended)
|
|
||||||
|
|
||||||
**Re-hydrate `GpuWorldState` from acdream's OWN retained object table on landblock reload**,
|
|
||||||
instead of depending on an ACE re-broadcast that doesn't reliably arrive.
|
|
||||||
|
|
||||||
- `ClientObjectTable` (`src/AcDream.Core/Items/ClientObjectTable.cs`, class :41; accessed as
|
|
||||||
`GameWindow.Objects` :598; populated via `Ingest` on every CreateObject) is the retail
|
|
||||||
`weenie_object_table` equivalent — it keeps **ALL** objects with their positions/appearance
|
|
||||||
(see memory `project_object_item_model`: "CreateObject = canonical merge-upsert into
|
|
||||||
ClientObjectTable"). It is NOT cleared on landblock unload.
|
|
||||||
- So when `GpuWorldState.AddLandblock(lb)` runs (landblock streams in / reloads), walk
|
|
||||||
`ClientObjectTable` for objects whose position falls in `lb` and re-build their render
|
|
||||||
entities (the same `OnLiveEntitySpawnedLocked` mesh-ref build at `GameWindow` ~:3161-3215),
|
|
||||||
appending via `AppendLiveEntity`. That makes re-delivery independent of ACE.
|
|
||||||
- **Alternative** (simpler but heavier on memory): treat all in-range server objects as
|
|
||||||
PERSISTENT across the collapse (like the player — `GpuWorldState.MarkPersistent` :242 +
|
|
||||||
the rescue/re-inject at `GameWindow` ~:7419-7427), so they survive the unload entirely. The
|
|
||||||
collapse exists for FPS (don't keep the ~129 ocean-grid neighbour dungeons' objects), so this
|
|
||||||
must be scoped to the CURRENT landblock's objects only.
|
|
||||||
- **Cross-check first** (don't guess the protocol): `references/holtburger`
|
|
||||||
(`client/messages.rs`, the post-teleport handlers + object visibility) and `references/ACE`
|
|
||||||
(`Source/ACE.Server/...` ObjMaint / landblock visibility / what triggers a re-CreateObject on
|
|
||||||
a landblock change). Question to answer: **does retail's server release + re-send objects on a
|
|
||||||
teleport landblock change, and does the client need to signal anything?** If ACE *should*
|
|
||||||
re-send and isn't, the bug may be acdream not triggering ACE's awareness update — but the
|
|
||||||
client-side re-hydrate above is robust regardless and is the retail-faithful "client keeps its
|
|
||||||
object table" model.
|
|
||||||
|
|
||||||
### Key file:line pointers
|
|
||||||
|
|
||||||
- **Entity storage / lifecycle:** `src/AcDream.App/Streaming/GpuWorldState.cs` —
|
|
||||||
`AppendLiveEntity` :413 (loaded vs `_pendingByLandblock`), `AddLandblock` :199 (pending-merge
|
|
||||||
:205-215 — **the re-hydrate hook goes here**), `RemoveLandblock` :285 (unload; rescue of
|
|
||||||
`_persistentGuids` :294-302), `RemoveEntitiesFromLandblock` :464 (near→far demote;
|
|
||||||
`_onLandblockUnloaded` :495), `DrainRescued` :331, `RelocateEntity` :254, flat view
|
|
||||||
`RebuildFlatView` :550 / `Entities` :93, `LandblockEntries` :139.
|
|
||||||
- **Live spawn → WorldEntity build:** `src/AcDream.App/Rendering/GameWindow.cs`
|
|
||||||
`OnLiveEntitySpawnedLocked` :2696 (dedup `RemoveLiveEntityByServerGuid` :2711; MeshRef build
|
|
||||||
:3161-3215 — drops the entity if 0 mesh refs :3208; `Id = _liveEntityIdCounter++` :3251;
|
|
||||||
`AppendLiveEntity` call :3304), rescued re-inject :7419-7427.
|
|
||||||
- **Object table (the fix's source):** `src/AcDream.Core/Items/ClientObjectTable.cs`,
|
|
||||||
`src/AcDream.Core/Items/ClientObject.cs` (`Ingest` merge-upsert).
|
|
||||||
- **Render path (CONFIRMED FINE — for reference only):** `RetailPViewRenderer.DrawInside` :59 →
|
|
||||||
`InteriorEntityPartition.Partition` (`src/AcDream.App/Rendering/InteriorEntityPartition.cs`
|
|
||||||
:40 — `ServerGuid!=0 && MeshRefs>0` → `Dynamics` :51-58) → `RetailPViewRenderer.DrawDynamicsLast`
|
|
||||||
:680 → `WbDrawDispatcher` (`WalkEntitiesInto` :657, `EntityPassesVisibleCellGate` :2227).
|
|
||||||
`EntitySpawnAdapter` (`OnCreate` :100 / `OnRemove` :176), `ObjectMeshManager` refcount/LRU
|
|
||||||
(`IncrementRefCount` :333, `DecrementRefCount` :368 — moves to LRU, doesn't free).
|
|
||||||
|
|
||||||
### Side-finding (separate minor bug, NOT #138 — fix later WITH verification)
|
|
||||||
|
|
||||||
The Tier-1 cache has a **demote-vs-unload invalidation asymmetry**:
|
|
||||||
`RemoveEntitiesFromLandblock` fires `_onLandblockUnloaded` (`GpuWorldState` :495) but
|
|
||||||
`RemoveLandblock` does NOT — violating the cache's documented "demote OR unload" intent
|
|
||||||
(`EntityClassificationCache.cs` docstring + the `GpuWorldState._onLandblockUnloaded` comment).
|
|
||||||
For LIVE entities it's harmless (fresh Ids → always a miss), but for DAT-hydrated scenery
|
|
||||||
(deterministic Ids that can recur on landblock reload) it could serve stale batches. One-line
|
|
||||||
fix (add the `_onLandblockUnloaded?.Invoke((lbId & 0xFFFF0000)|0xFFFF)` to `RemoveLandblock`)
|
|
||||||
but needs its own repro + verification; was tried this session and reverted because it does NOT
|
|
||||||
fix #138.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Launch / probes / accounts
|
|
||||||
|
|
||||||
Canonical launch (PowerShell — the apostrophe in "Asheron's Call" breaks bash):
|
|
||||||
```powershell
|
|
||||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
|
||||||
$env:ACDREAM_LIVE = "1"
|
|
||||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
|
|
||||||
$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!'
|
|
||||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
|
|
||||||
```
|
|
||||||
|
|
||||||
**Accounts / characters used (all spawn near Holtburg, reach the 0x0007 Town Network dungeon):**
|
|
||||||
- `notan` / `MittSnus81!` → **`+Je`** (guid `0x50000001`). Freshest; what the user prefers now.
|
|
||||||
- `testaccount` / `testpassword` → `+Acdream` (guid `0x5000000A`).
|
|
||||||
- `testaccount2` / `testpassword2` → `Horan` (guid `0x5000000B`).
|
|
||||||
|
|
||||||
**Existing diagnostic env vars (already in the build):**
|
|
||||||
- `ACDREAM_DUMP_LIVE_SPAWNS=1` → `live: spawn guid=… name="Door"/"Chest"/… @0xLLLLcccc` per
|
|
||||||
CreateObject **received**. The smoking gun for "did the server re-deliver?" (0 lines after a
|
|
||||||
teleport = not re-delivered).
|
|
||||||
- `ACDREAM_DISABLE_TIER1_CACHE=1` (A/B; irrelevant to #138 per above).
|
|
||||||
- `ACDREAM_DUMP_ENTITY=<hex,hex>` → `[dump-entity] WALK-REJECT/DRAW` — matches `entity.Id`
|
|
||||||
(the **counter**, not the guid — hard to target for re-created entities).
|
|
||||||
|
|
||||||
**Throwaway probes used this session (re-add if needed — were reverted):**
|
|
||||||
- `[ent-flat] server=N total=M player=Y/n lbs=K` — in `GpuWorldState.RebuildFlatView`: count of
|
|
||||||
`ServerGuid!=0` in the rendered flat view, logged on change. (Player = `_persistentGuids`.)
|
|
||||||
- `[ent] +0xGUID -> 0xLB loaded|PENDING` / `[ent] rmlb …` — in `AppendLiveEntity` /
|
|
||||||
`RemoveLandblock`, for `ServerGuid!=0`.
|
|
||||||
- `[dyn] rootOutdoor=B dyn=N skip-outdoor=K cone-cull=C drawn=D` — in
|
|
||||||
`RetailPViewRenderer.DrawDynamicsLast` (throttled 1/60 frames).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ GOTCHAS (cost real time this session)
|
|
||||||
|
|
||||||
1. **Re-broadcast latency masks the layer.** ACE re-sends objects over **several seconds** after
|
|
||||||
a teleport (if at all). Closing the window ~1 s after returning shows "no objects" REGARDLESS
|
|
||||||
of the bug. Always **wait/walk ~15-20 s** after returning before judging. This caused ~4
|
|
||||||
inconclusive runs. The reliable signal is the `live: spawn` count, not eyeballing.
|
|
||||||
2. **Stale-session login failures.** Rapid relaunches leave ACE holding the prior session →
|
|
||||||
`live: session failed: CharacterList not received` (exit 29) on the next launch. No admin kick
|
|
||||||
available; wait it out (~minutes) or switch accounts. A *lingering client process* also holds
|
|
||||||
the slot.
|
|
||||||
3. **The user does NOT want clients killed/closed by the agent.** Do not `Stop-Process` /
|
|
||||||
`CloseMainWindow` acdream. Just `dotnet run` when asked; read logs (reading ≠ killing).
|
|
||||||
4. **`entity.Id != entity.ServerGuid`.** `Id` is a per-session monotonic counter
|
|
||||||
(`_liveEntityIdCounter++`); `ServerGuid` is the wire guid (e.g. a door `0x7A9B40xx`). Filter
|
|
||||||
probes by `ServerGuid`.
|
|
||||||
5. **Visual confirmation requires the user** — they run the in-world actions; the agent reads the
|
|
||||||
logs. This is the one genuine stop point.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
- ISSUES `#138` (full re-scope writeup + acceptance), `#145` (DONE), `#137` (dungeon collision, OPEN).
|
|
||||||
- memory `project_object_item_model` (ClientObjectTable model), `project_render_pipeline_digest.md`
|
|
||||||
(render SSOT — confirms render is fine), `project_physics_collision_digest.md` (#145 detail).
|
|
||||||
- references: `holtburger` (client behaviour — what a real client sends/expects post-teleport),
|
|
||||||
`ACE` (server expectations — object visibility on landblock change).
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# Handoff — #145-residual: port the physics frame to CELL-RELATIVE (retail-faithful), retire the streaming center from physics
|
|
||||||
|
|
||||||
**Date:** 2026-06-21
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main** (user's call: "we can merge later")
|
|
||||||
**Milestone:** M1.5 "Indoor world feels right" (this is the last blocker for far-town/dungeon round-trips)
|
|
||||||
**HEAD at handoff:** `a4f0b51` (the #145 reopen) + an uncommitted ISSUES.md #145 root-cause update (committed alongside this doc).
|
|
||||||
**DECISION (user, 2026-06-21):** Go with **Option B — the architectural fix.** Make the physics position
|
|
||||||
**cell-relative like retail** and retire the streaming `_liveCenterX/_liveCenterY` from the physics path
|
|
||||||
(render-only). The user explicitly rejected the targeted patch (Option A) in favor of "done right, never
|
|
||||||
again." Quote: *"It should be agnostic."* **Start with a brainstorming gate** (this is a multi-commit
|
|
||||||
physics-frame phase touching the physics↔streaming seam broadly).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What shipped this session (context — already committed)
|
|
||||||
|
|
||||||
| Commit | What |
|
|
||||||
|---|---|
|
|
||||||
| `bf66fb4` | **#138 FIXED + user-verified** — server objects re-hydrate from the retained spawn table (`_lastSpawnByGuid`) on landblock reload; ACE won't re-send known objects, so we re-project. Register AP-48. |
|
|
||||||
| `0a5f91b` | #138 secondary — rescue persistent entities from the pending bucket on unload (player-vanish candidate). |
|
|
||||||
| `aa4a04d`/`b07825c` | #138 docs + handoff correction (the prior handoff's `ClientObjectTable` advice was WRONG — it's inventory-only; `_lastSpawnByGuid` is the world-object table). |
|
|
||||||
| `a4f0b51` | **#145 REOPENED** — far-town teleport resolver runaway (residual of the 2026-06-20 source-drop fix). |
|
|
||||||
|
|
||||||
**#138 is DONE and independent of this work** — the re-hydrate (objects come back) is user-confirmed and
|
|
||||||
the capture proved it is NOT involved in the runaway. It's mergeable as-is.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## THE BUG (verified — multi-agent workflow `wf_87607d15-c43` + 3 adversarial verifiers, all `holdsUp=true`, confidence HIGH)
|
|
||||||
|
|
||||||
**Full report:** `…/tasks/w8wm5fln4.output` (synthesis + 3 verdicts) and the workflow transcript dir.
|
|
||||||
Distilled:
|
|
||||||
|
|
||||||
**It is a cell-membership LABEL cascade, NOT a real free-fall.** Teleport to a FAR town (e.g.
|
|
||||||
(201,91)=`0xC95B`) places the player CORRECTLY at `0xC95B0001` local `(14.8, 0.3, 12)` (the #145 verbatim
|
|
||||||
path). Then the per-frame resolve **marches the cell id one landblock south per physics quantum (~33 ms)**
|
|
||||||
while the **physics body barely moves** (capture: max|Y|≈86 m, Z falls to ≈−135 then walk-back; settles at
|
|
||||||
the TRUE rest `(-6.124, -30.186, 12.000)`, og=True). The cell-id `lbY` byte counts `0x5B`(91)→`0x00`. The
|
|
||||||
**`17410`** ACE rejects is a **wire-conversion artifact** — once `lbY` marched to 0, the outbound
|
|
||||||
`localY = Position.Y − (lbY − _liveCenterY)·192` back-adds 91×192 = 17472. The body never went there.
|
|
||||||
|
|
||||||
**Proximate cause (single source line):** `src/AcDream.Core/Physics/CellTransit.cs:736`
|
|
||||||
```csharp
|
|
||||||
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin); // bool DISCARDED
|
|
||||||
```
|
|
||||||
For a far town the player is placed at the southern landblock edge (world-Y≈0.3) with **stale southward
|
|
||||||
running velocity** (the teleport never idled the motion state). The first tick crosses Y=0 into landblock
|
|
||||||
`0xC95A`, which **has not streamed in yet** → `TryGetTerrainOrigin` returns **`false` with
|
|
||||||
`origin=Vector3.Zero`** (`CellGraph.cs:47-56`; the comment at `CellTransit.cs:732-735` literally
|
|
||||||
self-documents this "legacy anchor-frame assumption (world frame == block-local frame)"). `(0,0)` flows into
|
|
||||||
`AddAllOutsideCells` (`:758`) → `GetOutsideLcoord` (`LandDefs.cs:110-111`) does
|
|
||||||
`ly += floor(worldY/24)` = `floor(-0.088/24) = -1` → cell marches one block south. The CORRECT origin for
|
|
||||||
`0xC95A` is `(0,-192)`, which would give `floor(191.9/24)=+7` (right region). **The missing −192 rebase is
|
|
||||||
the bug.** Every next neighbor is also unstreamed → the `(0,0)` cascade self-perpetuates 91×.
|
|
||||||
|
|
||||||
**Why FAR-TOWN only:** Holtburg is the streaming startup center *only because +Je spawns there* (the center
|
|
||||||
recenters to the login landblock at `GameWindow ~:2879` and on every teleport at `~:5447` — it is NOT
|
|
||||||
hardcoded to Holtburg). Holtburg works because its neighbors are already streamed (registered), so the
|
|
||||||
`(0,0)` fallback never fires, and its spawns are mid-block (no Y=0 crossing to arm the cascade). The bug
|
|
||||||
fires at the EDGE of the streamed region for ANY fresh teleport. **This is the non-agnostic assumption the
|
|
||||||
user flagged: `(0,0)` = "this landblock is home" — only true at the center.**
|
|
||||||
|
|
||||||
**#138 re-hydrate EXONERATED:** it fired only for Holtburg + the dungeon (log lines 412/498/710), never for
|
|
||||||
the far town during the runaway (746+); it adds only RENDER entities, never the physics `_landblocks` the
|
|
||||||
resolver iterates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## THE FIX — Option B: cell-relative physics `Position` (port retail)
|
|
||||||
|
|
||||||
**The retail design (decomp-verified, addresses re-confirmed against live Ghidra patchmem :8081):**
|
|
||||||
retail stores EVERY physics position **cell-relative** and re-bases the stored origin **in place** on every
|
|
||||||
placement and every boundary crossing, so the (cell, local) pair can never drift apart. There is **no
|
|
||||||
streaming "center" anywhere in retail's physics.** Make acdream's physics match this; `_liveCenterX/Y`
|
|
||||||
becomes a **render-only** concern (the `(lb − _liveCenter)·192` math is correct for placing meshes around
|
|
||||||
the camera, but must NOT be the physics authority).
|
|
||||||
|
|
||||||
### Retail functions to port (named decomp + Ghidra — verified addresses)
|
|
||||||
|
|
||||||
| Retail symbol | Address | Role |
|
|
||||||
|---|---|---|
|
|
||||||
| `struct Position { uint objcell_id; Frame frame }` | `acclient.h:30659` | The position IS the (cell, local) pair. `Frame.m_fOrigin` (`acclient.h:30654`) is LOCAL to the cell, bounded `[0,192)` for outdoor. **No global/world position field exists.** |
|
|
||||||
| `Position::adjust_to_outside` | `0x00504A40` | Rebases the ACTUAL `this->frame.m_fOrigin` + `this->objcell_id` in place (not a temp). |
|
|
||||||
| `LandDefs::adjust_to_outside` | `0x005A9BC0` | Recomputes global landcell via `get_outside_lcoord`, rebuilds cell id via `lcoord_to_gid`, **wraps origin into [0,192)** (`origin -= floor(origin/192)*192`). The canonicalizer. |
|
|
||||||
| `LandDefs::get_outside_lcoord` | `0x005A9B00` | `gX = blockLcoordX + floor(localX/24)`. (acdream `LandDefs.cs:105-112` is a faithful port — but the per-frame pick feeds it a RAW world-frame Y via the `(0,0)` fallback instead of a wrapped local.) |
|
|
||||||
| `LandDefs::get_block_offset` | `0x0043E630` | The ONLY cross-cell translation: `(dX−sX)·24, (dY−sY)·24` from the two cell ids' landblock bytes; **returns ZeroVector when both cells share a landblock**. A delta of two NAMED cells — never an accumulation vs a moving center. |
|
|
||||||
| `LandDefs::blockid_to_lcoord` / `lcoord_to_gid` | `0x0043D680` / `0x004A19A0` | Absolute global landcell grid (lcoord 0..0x7f8). No `_liveCenter` subtraction anywhere — the grid origin is the world origin, fixed. |
|
|
||||||
| `CTransition::validate_transition` | `0x0050AA70` | On each accepted step adopts `curr_pos.objcell_id = check_pos.objcell_id` AND `curr_pos.frame = check_pos.frame` **together** (lockstep), then `cache_global_curr_center`. The local frame is re-expressed into the destination cell during the sweep. |
|
|
||||||
| `CPhysicsObj::AdjustPosition` / `SetPositionInternal` | `0x00511D80` / `0x00515BD0` | Every placement/teleport runs `adjust_to_outside` → an inconsistent (lbY=0, localY=17410) pair cannot persist. |
|
|
||||||
| `SPHEREPATH::cache_global_curr_center` | `0x0050C740` | Builds the transient working "global" sphere center from `frame.m_fl2gv·local + frame.m_fOrigin` — cell-local only, NO landblock-world offset, rebuilt from `curr_pos` each step. |
|
|
||||||
|
|
||||||
acdream **already ports** `AdjustToOutside`/`GetOutsideLcoord`/`LcoordToGid`/`BlockidToLcoord` in
|
|
||||||
`src/AcDream.Core/Physics/LandDefs.cs` (from the #106 work) — WITH the [0,192) wrap (`LandDefs.cs:130-145`).
|
|
||||||
The gap is that the per-frame outdoor resolve (`CellTransit.BuildCellSetAndPickContaining`) does NOT run the
|
|
||||||
wrap+rebase; it re-derives `lbPrefix` from a world-XY scan against a `(0,0)`-or-baked-offset origin.
|
|
||||||
|
|
||||||
### acdream divergence sites (the streaming-relative physics frame — what to change)
|
|
||||||
|
|
||||||
- **The frame itself:** physics position is a streaming-relative world `Vector3`
|
|
||||||
(`worldXY = (lb − _liveCenter)·192 + local`). Sites: `PhysicsEngine.cs:603-614` (the `foreach(_landblocks)`
|
|
||||||
outdoor membership scan), `:624`, `:858-860` (returns `lbPrefix | (targetCellId & 0xFFFF)`).
|
|
||||||
- **Baked per-landblock WorldOffset** computed against the MOVING center at load: `GameWindow.cs` origin
|
|
||||||
computation `~:4992` (and the WorldOffset inverse used outbound at `~:2930-2931` / `~:4992-4993` — note the
|
|
||||||
synthesis cited `:7763-7765` for the outbound `localY` conversion but a verifier could not locate that
|
|
||||||
exact line; **re-locate the outbound (cell, local) builder** — it's where `localY = Position.Y − (lbY −
|
|
||||||
_liveCenterY)·192` happens).
|
|
||||||
- **The `(0,0)` fallback** (proximate trigger): `CellTransit.cs:736` (discarded bool) → consumed at the seed
|
|
||||||
`AddAllOutsideCells` (`:758` → `:242`), the containing-pick (`:845`), and the other seed (`:484`).
|
|
||||||
`CellGraph.cs:47-56` (`TryGetTerrainOrigin` returns `(Zero,false)` for unregistered terrain).
|
|
||||||
- **The teleport that leaves running velocity live:** `src/AcDream.App/Input/PlayerMovementController.cs`
|
|
||||||
(NOTE: App/Input, not Core/Physics) — `SetPosition` zeros velocity `~:803` but the same-frame motion path
|
|
||||||
re-applies the running vector `~:943-980`. A teleport arrival should call the motion-idle path.
|
|
||||||
- `TransitionTypes.cs ~:2315` (`SetCheckPos` commits the marched cell id without moving position).
|
|
||||||
- **The recenter:** `GameWindow.OnLivePositionUpdated ~:5447` (drop-source + `_liveCenterX/Y=lbX/lbY`) and the
|
|
||||||
login recenter `~:2879-2909`. These STAY (render frame), but physics must stop depending on them.
|
|
||||||
|
|
||||||
### Shape of the port (brainstorm this — do not just start coding)
|
|
||||||
|
|
||||||
The end state: a physics `Position { uint ObjCellId; Frame{ Vector3 LocalOrigin∈[0,192) for outdoor; Quaternion } }`,
|
|
||||||
carried by the player/physics body instead of a streaming-relative world `Vector3`. Cross-cell offsets come
|
|
||||||
ONLY from `get_block_offset(cellA, cellB)` (port `0x0043E630`). Every `SetPosition`/teleport/cross-landblock
|
|
||||||
move runs `AdjustToOutside` to re-wrap. `_liveCenterX/Y` is used by the RENDERER (and the outbound wire
|
|
||||||
conversion derives the wire (cell, local) directly from the cell-relative Position — no center math needed,
|
|
||||||
which kills the 17410 leak at the source). The streaming `WorldOffset` baked per-landblock can stay for the
|
|
||||||
RENDER mesh placement but the physics resolve must not read it.
|
|
||||||
|
|
||||||
**This is a large, seam-crossing change.** It touches `PhysicsEngine`, `CellTransit`, `TransitionTypes`,
|
|
||||||
`CellGraph`, the player controller, the outbound wire builder, and every test that constructs a physics
|
|
||||||
position. Scope it via `superpowers:brainstorming` FIRST, then write a plan, then execute in reviewable
|
|
||||||
slices (likely: (1) introduce cell-relative `Position` alongside the world frame; (2) port `get_block_offset`
|
|
||||||
+ route the resolve through it; (3) make `AdjustToOutside` run on the per-frame path; (4) flip the player
|
|
||||||
body to cell-relative; (5) make the outbound wire derive from it; (6) make `_liveCenter` render-only; (7)
|
|
||||||
delete the baked-WorldOffset physics reads). Conformance: replay the captured runaway + the existing
|
|
||||||
`CellarUp`/`DoorBug`/membership harnesses must stay green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Apparatus (use this — do NOT rebuild)
|
|
||||||
|
|
||||||
- **The captured runaway:** `desync-capture.jsonl` (worktree root, 72,401 `ResolveWithTransition` records:
|
|
||||||
`input{currentPos,targetPos,cellId,sphere*,stepUp/Down,isOnGround,moverFlags,movingEntityId}`,
|
|
||||||
`bodyBefore/After{position,orientation,velocity}`, `result`). Runaway records: cellId `0xC95B0001`→`0xC9000008`
|
|
||||||
(decimal 3372220424). Velocity at first far-town record = `(3.637,−11.276,0)` (the stale run).
|
|
||||||
- **Probes (env vars, no rebuild):** `ACDREAM_PROBE_CELL=1` → `[cell-transit] old->new pos=(x,y,z) reason=`;
|
|
||||||
`ACDREAM_CAPTURE_RESOLVE=<path>` → the JSON capture; `ACDREAM_PROBE_RESOLVE=1` → per-resolve `[resolve]`
|
|
||||||
lines; `ACDREAM_DUMP_LIVE_SPAWNS=1` → spawn + re-hydrate + recenter lines.
|
|
||||||
- **The session log of the repro:** `…/tasks/bu932mwj3.output` (cell-transit trail + `[snap]` + teleport
|
|
||||||
arrivals + the healthy Holtburg cell-transits for comparison).
|
|
||||||
- **Test harness template:** build `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` from the
|
|
||||||
capture (apparatus = `PhysicsResolveCapture` + the `CellarUpTrajectoryReplayTests` `LiveCompare_*` pattern).
|
|
||||||
Assert: with `0xC95A` unregistered, the cell-relative resolve keeps cell+local consistent (no march). Include
|
|
||||||
a non-south case (east-edge spawn) — the cascade is direction-agnostic.
|
|
||||||
|
|
||||||
## Open items / risks / DO-NOT-RETRY
|
|
||||||
|
|
||||||
- **Verifier corrections to the synthesis prose (use the corrected facts):** march rate is ~1 landblock per
|
|
||||||
physics QUANTUM (~33 ms), not per render frame (~40 capture records per crossing); `max|Position.Y|≈85.67`
|
|
||||||
(not 61.9); the true settle is `(-6.124,-30.186,12.000)` og=True (not `(21.87,-21.62,-6.50)` — that's a
|
|
||||||
mid-fall entry); `PlayerMovementController.cs` is under `src/AcDream.App/Input/`. The MECHANISM is unchanged.
|
|
||||||
- **DO-NOT-RETRY:** the physics digest DO-NOT-RETRY table has NO conflict (it covers cellar-up/door/lip
|
|
||||||
wedges — different code path). But **do NOT attempt a membership "stickiness"/hysteresis fix** — the table
|
|
||||||
explicitly disproves stickiness for the lip path; the correct shape is cell-relative storage, not hysteresis.
|
|
||||||
Also the digest already names this seam: `TryGetTerrainOrigin` "Zero fallback = legacy anchor-frame" + the
|
|
||||||
[[feedback_latent_bug_masked_by_fallback]] lesson ("a fallback masked a production bug") — this is that
|
|
||||||
pattern recurring. Read the physics digest first.
|
|
||||||
- **Map-edge towns:** confirm a destination at the world edge (a needed neighbor landblock genuinely doesn't
|
|
||||||
exist) resolves sanely under the cell-relative frame (retail handles this via the absolute lcoord grid —
|
|
||||||
`get_outside_lcoord` clamps; verify the acdream port matches).
|
|
||||||
- **Outbound wire:** after the port, the wire (cell, local) must derive from the cell-relative Position
|
|
||||||
directly. Re-locate the current outbound builder (the synthesis's `:7763-7765` was unverified).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Launch / probes / accounts (unchanged)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
|
||||||
$env:ACDREAM_LIVE = "1"
|
|
||||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
|
|
||||||
$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!'
|
|
||||||
$env:ACDREAM_PROBE_CELL = "1"
|
|
||||||
$env:ACDREAM_CAPTURE_RESOLVE = "<worktree>\desync-capture2.jsonl"
|
|
||||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
|
|
||||||
```
|
|
||||||
Account `notan`/`MittSnus81!` → `+Je` (guid `0x50000001`), spawns Holtburg, reaches the 0x0007 Town Network
|
|
||||||
hub → portals to far towns (the repro: hub → any far town → the runaway arms when you arrive still running).
|
|
||||||
**The user drives the client lifecycle** — do NOT kill/close clients; `dotnet run` when asked, read logs.
|
|
||||||
**Visual confirmation requires the user** (the one genuine stop point).
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
- ISSUES `#145` (reopened — full verified root cause + the A-vs-B decision), `#138` (DONE).
|
|
||||||
- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md` — read FIRST.
|
|
||||||
- Workflow output (full synthesis + 3 verdicts): `…/tasks/w8wm5fln4.output`; transcripts under
|
|
||||||
`…/subagents/workflows/wf_87607d15-c43/` (the retail-oracle agent's report is the decomp goldmine).
|
|
||||||
- Register: AP-36 (the streaming-center gate) + AP-48 (#138 re-hydrate) — the B port will retire/rewrite the
|
|
||||||
physics half of the streaming-relative-frame divergence; add/update register rows in the porting commits.
|
|
||||||
- References: `named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + Ghidra patchmem :8081 (the oracle);
|
|
||||||
`references/ACE` + `references/holtburger` (the wire/client frame cross-check).
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
# Handoff — D.2b inventory: B-Controller shipped + visually confirmed; B-Wire next
|
|
||||||
|
|
||||||
**Date:** 2026-06-21 (later than the B-Grid handoff of the same date)
|
|
||||||
**From:** the session that shipped **B-Controller** (inventory population + burden meter +
|
|
||||||
captions) and root-caused/fixed the **backdrop wash-out** (a #145 continuation) at the visual gate.
|
|
||||||
**Predecessor:** `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md`
|
|
||||||
(window manager + B-Grid). This doc supersedes it for "what's next."
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12`, tip **`c38f098`**. `main` is a clean
|
|
||||||
fast-forward ancestor — ff `main` + branch fresh, or continue this branch.
|
|
||||||
**Line numbers drift — grep the symbol.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. What shipped this session (all committed, build + tests green, VISUALLY CONFIRMED)
|
|
||||||
|
|
||||||
**B-Controller** — `InventoryController` (the `gm*UI::PostInit` analogue) binds the imported
|
|
||||||
`gmInventoryUI 0x21000023` tree by id and populates it from `ClientObjectTable`. F12 (with
|
|
||||||
`ACDREAM_RETAIL_UI=1`) now shows your live inventory on the dark backdrop: the "Contents of
|
|
||||||
Backpack" grid, the pack-selector strip, the vertical burden bar ("Burden 17%"), and the captions.
|
|
||||||
|
|
||||||
Commits (after the input handoff's `4e23a7b`):
|
|
||||||
- `5875ac8` `BurdenMath` encumbrance ports (Core)
|
|
||||||
- `fb050ae` `ClientObjectTable.SumCarriedBurden` (Core)
|
|
||||||
- `5e75d2a` `UiMeter` vertical fill (`Vertical`/`FillFromBottom`/`DrawVBar`)
|
|
||||||
- `89c640a` `InventoryController` bind + populate + burden + captions
|
|
||||||
- `383e8b7` follow-up (Populate uses `GetContents` only — dropped a subagent's index-fallback workaround)
|
|
||||||
- `03fbf44` GameWindow wiring (the `InventoryController.Bind` in the inventory-init block)
|
|
||||||
- `7bb4bd3` divergence rows AP-48..51 + ISSUES/roadmap
|
|
||||||
- `1ccf07b` Opus phase-boundary review fixes (burden % saturation + equipped-item filter)
|
|
||||||
- `417b137` **render fixes** (backdrop wash-out [#145 continuation] + captions) — the visual-gate fix
|
|
||||||
- `c38f098` docs (ISSUES/roadmap: visually confirmed + #145 continuation + overflow follow-up)
|
|
||||||
|
|
||||||
Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md`.
|
|
||||||
Plan: `docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md`.
|
|
||||||
Tests: **App 532 / Core 1526 green.** `InventoryFrameImportProbe` is a real-dat smoke test
|
|
||||||
(gated on `ACDREAM_DAT_DIR`) that **locks the ZLevel fix** (asserts each mounted panel's
|
|
||||||
ZOrder > the backdrop's).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Read first
|
|
||||||
|
|
||||||
- This doc.
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` — the B-Controller SHIPPED entry + DO-NOT-RETRY (updated this session).
|
|
||||||
- `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` — the B-Controller spec.
|
|
||||||
- `docs/research/2026-06-16-inventory-deep-dive.md` §4 — the inventory wire catalog (for **B-Wire**).
|
|
||||||
- `docs/research/2026-06-16-ui-panels-synthesis.md` §3.3 — the de-duped wire-gap TODO.
|
|
||||||
- `docs/ISSUES.md` — the **#145** entry (now closed via the continuation), the **D.2b-B SHIPPED** entry, and the new **contents-grid overflow** issue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY)
|
|
||||||
|
|
||||||
1. **THE BIG ONE — a mounted sub-window slot must keep its OWN frame ZLevel, NOT inherit the
|
|
||||||
base layout root's.** The gm*UI sub-window roots (`0x100001C8` backpack, `0x100001C4`
|
|
||||||
3D-items) carry **ZLevel 1000**. `ElementReader.Merge`'s zero-wins-base rule (`ElementReader.cs:161`,
|
|
||||||
`ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel`) made the mounted slots
|
|
||||||
(own ZLevel 0) inherit that 1000, and the #145 ZOrder fold (`ReadOrder − ZLevel·10000`)
|
|
||||||
turned 1000 into ZOrder ≈ −10,000,000 — *behind* the frame's Alphablend backdrop
|
|
||||||
(`0x100001D0`, ZLevel 100 → ≈ −1,000,000). The backdrop then **overpainted/washed out** the
|
|
||||||
panels' captions + burden meter + item cells. The **paperdoll** root happens to be ZLevel 0,
|
|
||||||
so it survived — which is why the B-Grid session believed #145 was "done." Fix
|
|
||||||
(`417b137`): `LayoutImporter.Resolve` sets `result.ZLevel = self.ZLevel` inside the
|
|
||||||
`ShouldMountBaseChildren` block. **Root-caused via a one-shot `TextRenderer` sprite-segment-order
|
|
||||||
dump** (the backdrop's segment was drawing AFTER the panel content) + a live ZLevel probe — when
|
|
||||||
"drawn but invisible," dump the actual paint order, don't keep theorizing z-math.
|
|
||||||
|
|
||||||
2. **Burden formula (ported into `Core.Items.BurdenMath`):**
|
|
||||||
`capacity = Str × (150 + clamp(aug×30, 0, 150))` (`EncumbranceSystem::EncumbranceCapacity`
|
|
||||||
`0x004fcc00`); `load = burden / capacity` (`EncumbranceSystem::Load` `0x004fcc40` — the
|
|
||||||
decompiler MANGLED the FP divide to `return burden`; cross-checked vs the existing `ComputeMax`
|
|
||||||
+ the r06 doc); `gmBackpackUI::SetLoadLevel` (`0x004a6ea0`) → fill = `clamp(load/3, 0, 1)`,
|
|
||||||
%text = `floor(load × 100)` **SATURATING at 300%** (computed from the CLAMPED fill, not
|
|
||||||
unclamped — caught in review). `CACQualities::InqLoad` `0x0058f130`. **acdream has no wire
|
|
||||||
`EncumbranceVal` yet** → client-side `SumCarriedBurden` fallback (divergence **AP-48**;
|
|
||||||
B-Wire pins the real wire value).
|
|
||||||
|
|
||||||
3. **`DatWidgetFactory.BuildMeter`'s single-image path is ALREADY correct for the burden meter**
|
|
||||||
(meter-own DirectState `0x0600121D` = back/track; its one Type-3 child `0x0600121C` = fill)
|
|
||||||
per `UIElement_Meter::DrawChildren` `0x0046fbd0` (the child = `m_pcChildImage = GetChildRecursive(this, 2)`).
|
|
||||||
The inventory deep-dive's "0x0600121C = back" label was backwards re: draw role. The only
|
|
||||||
meter change was a **vertical** fill path (`UiMeter.Vertical`/`FillFromBottom`/`DrawVBar`).
|
|
||||||
A near-empty fill at low load is CORRECT (Str 290 → 17% → ~3px of a 58px bar), not a bug.
|
|
||||||
|
|
||||||
4. **Captions: drive the HOST `UiText` directly.** The caption elements (`0x100001D7` "Burden",
|
|
||||||
`0x100001D8` %, `0x100001C5` "Contents of Backpack") resolve to `UiText`. A nested *child*
|
|
||||||
`UiText` does NOT paint — `AttachCaption` now sets the host UiText's `LinesProvider`/`Centered`/`DatFont`.
|
|
||||||
|
|
||||||
5. **Equipped items excluded** from the pack grid + selector (`CurrentlyEquippedLocation != None`)
|
|
||||||
— a mid-session self-wield routes them through `MoveItem(item, WielderGuid=player)` into
|
|
||||||
`GetContents(player)`; retail's gm3DItemsUI shows pack contents only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`)
|
|
||||||
|
|
||||||
Renders correctly: dark backdrop BEHIND; paperdoll equip slots (generic blue border); backpack
|
|
||||||
strip ("Burden" + % + vertical bar + side-bag cell + main-pack cell); 3D-items "Contents of
|
|
||||||
Backpack" grid populated (6-col). **Expected gaps (all are the follow-ups below):**
|
|
||||||
- Contents grid OVERFLOWS the 96px panel (no scroll) — "part hanging off the window."
|
|
||||||
- Paperdoll equip slots show a generic blue `UiDatElement` border, not per-slot silhouettes → Sub-phase C.
|
|
||||||
- The inventory window starts HIDDEN; F12 toggles it (the auto-open debug hack was reverted).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. What's next (build order; each gets its own brainstorm → spec → plan → subagent flow)
|
|
||||||
|
|
||||||
**(a) B-Wire — RECOMMENDED NEXT.** Parse the wire `EncumbranceVal` (ACE `PropertyInt 5`) for the
|
|
||||||
player so the burden bar reads the server value (retires **AP-48**, the client-side sum). Plus the
|
|
||||||
other inventory wire gaps catalogued in `2026-06-16-inventory-deep-dive.md §4` + the de-duped TODO
|
|
||||||
in `2026-06-16-ui-panels-synthesis.md §3.3`: builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
|
|
||||||
`NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize`, `InventoryRemoveObject`;
|
|
||||||
fix the dropped 4th field on `0x0022` + the dropped error on `0x00A0`; register the unwired parsers;
|
|
||||||
extend `CreateObject` if any icon/stack fields are still discarded. **Mandatory grep-named →
|
|
||||||
cross-ref → pseudocode → port** for each wire format; ACE handlers + holtburger fixtures are the oracles.
|
|
||||||
|
|
||||||
**(b) Contents-grid scroll polish.** The 6-col grid shows ALL loose items, overflowing the 96px
|
|
||||||
panel. Wire the gutter `UiScrollbar` (`0x100001C7`) to the grid + clip the grid to the panel +
|
|
||||||
scroll the overflow. Filed as an OPEN issue in `docs/ISSUES.md`; was out-of-scope for B-Controller
|
|
||||||
(spec §10). Small, self-contained.
|
|
||||||
|
|
||||||
**(c) B-Drag.** Inventory cell as a drag SOURCE (reuse the shipped spine — `UiItemSlot` is already a
|
|
||||||
full drag source/target) + the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`.
|
|
||||||
|
|
||||||
**(d) Sub-phase C — the biggest piece.** Paperdoll: register `0x10000032` (`UiItemSlot`) so equip
|
|
||||||
slots draw per-slot silhouettes (fixes the generic-blue-slot art) + the `UiViewport` (Type `0xD`)
|
|
||||||
3D character doll, which needs a new Core→App `IUiViewportRenderer` seam (Code-Structure Rule 2).
|
|
||||||
Research: `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`.
|
|
||||||
|
|
||||||
(Then D.5.3-remaining: the toolbar mana meter + stack slider + spell shortcuts; vendor/trade/spellbook.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Caveats / open items
|
|
||||||
|
|
||||||
- **Chat + toolbar #145 z-order eyeball (the input handoff's first ask):** analytically cleared
|
|
||||||
this session — chat's only non-zero-ZLevel element (`0x1000000E`, ZLevel 900) was already the
|
|
||||||
backmost top-level sibling, so the #145 fold can't change its order; the toolbar now honors
|
|
||||||
ZLevel (retail-faithful). No regression seen across ~10 launches. Not separately user-confirmed
|
|
||||||
in isolation, but low-risk — glance once if convenient.
|
|
||||||
- The `InventoryFrameImportProbe` real-dat test SKIPS in CI (no dat). It only asserts when
|
|
||||||
`ACDREAM_DAT_DIR` (or the default `~/Documents/Asheron's Call`) exists.
|
|
||||||
- `ClientObjectTable.GetContents(playerGuid)` returns `IReadOnlyList<uint>` (guids), NOT
|
|
||||||
`ClientObject`s — a prior research agent's claim was wrong; trust the source.
|
|
||||||
- The main-pack cell (`m_topContainer 0x100001C9`) uses a placeholder icon (the main pack ≡ the
|
|
||||||
player, no item IconId) — AP-51. Pin a backpack RenderSurface DID if it bothers you.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI inventory arc. **Read
|
|
||||||
> `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` first.** B-Controller
|
|
||||||
> (inventory population + burden meter + captions) shipped + visually confirmed on
|
|
||||||
> `claude/hopeful-maxwell-214a12` (tip `c38f098`; `main` is a clean ff ancestor — ff + branch
|
|
||||||
> fresh, or continue). The backdrop wash-out (a #145 continuation: mounted sub-window slots were
|
|
||||||
> inheriting the base root's ZLevel 1000 → behind the backdrop) is fixed. **Next: B-Wire** — parse
|
|
||||||
> the player's wire `EncumbranceVal` (PropertyInt 5) to retire the client-side burden-sum fallback
|
|
||||||
> (AP-48), plus the inventory wire gaps in `2026-06-16-inventory-deep-dive.md §4` /
|
|
||||||
> `2026-06-16-ui-panels-synthesis.md §3.3`. Use the full brainstorm → spec → plan → subagent-driven
|
|
||||||
> flow; mandatory grep-named → cross-ref → pseudocode → port for every wire format; conformance
|
|
||||||
> tests throughout. After B-Wire: the contents-grid scroll polish (wire gutter `0x100001C7`),
|
|
||||||
> B-Drag (inventory drag SOURCE), then Sub-phase C (paperdoll `UiViewport` doll + `0x10000032`
|
|
||||||
> UiItemSlot per-slot art).
|
|
||||||
|
|
||||||
**MEMORY.md index line** (already added this session):
|
|
||||||
- [Project: D.2b retail UI engine](project_d2b_retail_ui.md) — … Inventory B-Controller SHIPPED 2026-06-21. DO-NOT-RETRY: a mounted sub-window slot must keep its OWN frame ZLevel — inheriting the base root's ZLevel 1000 sinks the panel behind the Alphablend backdrop (the #145-continuation wash-out).
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
# Handoff — D.2b inventory: A + B-Grid shipped, #145 fixed, B-Controller next
|
|
||||||
|
|
||||||
**Date:** 2026-06-21
|
|
||||||
**From:** the session that shipped the **window manager (Sub-phase A)**, **B-Grid** (inventory
|
|
||||||
sub-window mount + grid), and fixed **#145** (ZLevel z-order). All committed on branch
|
|
||||||
`claude/hopeful-maxwell-214a12`, tip `4904ff4`. Full suite green (2767).
|
|
||||||
**Predecessor handoff:** `docs/research/2026-06-20-window-manager-inventory-handoff.md` (the
|
|
||||||
A→B→C arc). This doc supersedes it for "what's next."
|
|
||||||
**Line numbers drift — grep the symbol.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. What shipped this session
|
|
||||||
|
|
||||||
- **Sub-phase A — window manager.** `UiRoot` named-window registry (`RegisterWindow` /
|
|
||||||
`ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`), raise-on-click, and F12 →
|
|
||||||
toggle via the **existing** `InputAction.ToggleInventoryPanel`. `UiHost` forwarders. Spec
|
|
||||||
`docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md`; plan
|
|
||||||
`…/plans/2026-06-20-d2b-window-manager.md`. Commits `6409038`/`036db8b`/`882b4dd`/`e3152ad`.
|
|
||||||
Visually confirmed (F12 toggles, click raises, chat-focus gate).
|
|
||||||
- **Cross-check correction:** retail's inventory key is **F12** (`ToggleInventoryPanel`),
|
|
||||||
NOT `I` (`I` = the `Laugh` emote per `retail-default.keymap.txt:246`). The handoff guessed I.
|
|
||||||
|
|
||||||
- **Sub-phase B-Grid — inventory frame.** `UiItemList` N-cell **grid mode**
|
|
||||||
(`Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells`; single-cell toolbar preserved)
|
|
||||||
+ the **sub-window mount** in `LayoutImporter.Resolve` + the `GameWindow` swap of the A
|
|
||||||
placeholder for `LayoutImporter.Import(0x21000023)`. Spec
|
|
||||||
`…/specs/2026-06-20-d2b-inventory-grid-mount-design.md`; plan
|
|
||||||
`…/plans/2026-06-20-d2b-inventory-grid-mount.md`. Commits `4fd4b09`/`85098f5`/`7d971a2`.
|
|
||||||
|
|
||||||
- **#145 — ZLevel z-order (DONE `45a5cc5`).** The importer mapped `ZOrder ← ReadOrder` only, so
|
|
||||||
the gmInventoryUI full-window backdrop (`0x100001D0`, ReadOrder 4) painted over the panels
|
|
||||||
(ReadOrder 1-3). Now `ZOrder = ReadOrder − ZLevel·10000` (higher ZLevel = further back). See §3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Read first
|
|
||||||
|
|
||||||
- This doc + the two B-Grid spec/plan files above.
|
|
||||||
- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 (the core-panels build plan).
|
|
||||||
- `docs/research/2026-06-16-inventory-deep-dive.md` (gmInventoryUI nesting + wire catalog).
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` (toolkit crib — updated this session).
|
|
||||||
- `claude-memory/project_object_item_model.md` (the `ClientObjectTable` — B-Controller's data source).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Decomposition + status
|
|
||||||
|
|
||||||
Sub-phase B was decomposed into four steps (approved this session):
|
|
||||||
|
|
||||||
| Step | Status |
|
|
||||||
|---|---|
|
|
||||||
| **B-Grid** — UiItemList grid + sub-window mount | ✅ **SHIPPED** (mount works for all 3 panels; paperdoll equip-slot positions visually confirmed) |
|
|
||||||
| **B-Controller** — populate grids + burden meter + captions | ⬜ **NEXT** (see §5) |
|
|
||||||
| **B-Wire** — wire gaps (DropItem/ViewContents/…) | ⬜ pending |
|
|
||||||
| **B-Drag** — inventory cell as drag SOURCE (reuse the shipped spine) | ⬜ pending |
|
|
||||||
| **Sub-phase C** — paperdoll doll + per-slot equip art | ⬜ pending (the wrong-slot-graphics fix lives here) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Key discoveries (durable, non-obvious)
|
|
||||||
|
|
||||||
1. **The nested panels are Type-0 inheritors, not game-class Types.** `gmInventoryUI 0x21000023`'s
|
|
||||||
three panels — paperdoll `0x100001CD`, backpack `0x100001CE`, 3D-items `0x100001CF` — are
|
|
||||||
**Type-0, media-less, childless leaves** that nest via `BaseElement`+`BaseLayoutId`
|
|
||||||
(BaseLayoutId → `0x21000024`/`0x21000022`/`0x21000021`). The research agent's "game-class Type
|
|
||||||
+ recursive Import" model was WRONG — confirmed by dumping `0x21000023`.
|
|
||||||
|
|
||||||
2. **The mount = inheritance carrying base children.** `ElementReader.Merge` dropped the base's
|
|
||||||
children. The mount (`LayoutImporter.ShouldMountBaseChildren` + the `Resolve` tail) attaches a
|
|
||||||
base's resolved subtree when the derived element is a pure container (no own children, no own
|
|
||||||
media) inheriting from a base WITH children. Inert for media-bearing inheritors (close
|
|
||||||
button/title) and childless style prototypes (vitals/chat/toolbar).
|
|
||||||
|
|
||||||
3. **ZLevel z-order: higher = further back.** `DatWidgetFactory` folds `ZLevel` into `ZOrder`
|
|
||||||
(`ReadOrder − ZLevel·10000`). ZLevels observed: vitals all **0**; toolbar **0,1,2**; chat
|
|
||||||
**0,900**; inventory **0,10,50,100** (backdrop 100). Vitals unchanged; **chat + toolbar shifted
|
|
||||||
to their dat layering — needs a visual regression confirm (see §6).**
|
|
||||||
|
|
||||||
4. **The paperdoll equip slots need `0x10000032` (UiItemSlot) registration.** They resolve to type
|
|
||||||
`0x10000032`, which the factory does NOT register, so they fall back to a generic `UiDatElement`
|
|
||||||
sprite (the uniform blue border seen on screen) instead of per-slot silhouettes. This + the
|
|
||||||
dressed doll is **Sub-phase C**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Current visual state (F12 with ACDREAM_RETAIL_UI=1)
|
|
||||||
|
|
||||||
The inventory window opens and renders the nested frame: outer chrome + title + the **paperdoll
|
|
||||||
equip slots correctly positioned** over the backdrop. **Gaps (all expected — next sub-steps):**
|
|
||||||
- Equip slots draw a generic border, not per-slot silhouettes → **C**.
|
|
||||||
- Backpack strip + 3D-items contents look empty (the panels mounted; their content is unpopulated)
|
|
||||||
→ **B-Controller**.
|
|
||||||
- Item cells empty everywhere (no items bound) → **B-Controller**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. B-Controller — the next step
|
|
||||||
|
|
||||||
**Goal:** make the inventory show your live contents — populate the backpack/3D-items grids from
|
|
||||||
`ClientObjectTable`, drive the burden meter, render the captions. The `gm*UI::PostInit` analogue.
|
|
||||||
|
|
||||||
**READY:**
|
|
||||||
- `ClientObjectTable.GetContents(containerGuid)` → ordered guids; `Get(guid)` → icon fields
|
|
||||||
(`src/AcDream.Core/Items/ClientObjectTable.cs`). The player's own pack is already in the table
|
|
||||||
from the CreateObject stream — **no new wire needed for the own-pack path** (B-Wire is for other
|
|
||||||
containers + live deltas).
|
|
||||||
- `UiItemList` grid mode (B-Grid) — set `Columns`/`CellWidth`/`CellHeight` (read the cell pitch
|
|
||||||
from the nested layout's item template) then `Flush()` + `AddItem()` per item.
|
|
||||||
- `IconComposer` for the cell icons; `UiItemSlot.SetItem(guid, iconTex)`.
|
|
||||||
- `ImportedLayout.FindElement(id)` to bind by id (the burden meter, the lists).
|
|
||||||
- The burden meter is element `0x100001D9` (Type 7 → `UiMeter`); drive fill via
|
|
||||||
`SetAttribute_Float`-equivalent (`UiMeter.Fill`); load ratio = burden/maxBurden.
|
|
||||||
|
|
||||||
**MISSING / to build:**
|
|
||||||
- `InventoryController`: find-by-id bind of the burden meter + the backpack/3D-items lists from
|
|
||||||
`invLayout` (the `ImportedLayout` built in `GameWindow`); subscribe to `ClientObjectTable`
|
|
||||||
`ObjectAdded/Moved/Removed`; rebuild cells.
|
|
||||||
- **Grid params:** dump `0x21000022` (backpack) + `0x21000021` (3D-items) to read the item-list
|
|
||||||
element sizes + cell template (geometry suggests backpack ~1 col, 3D-items ~6 cols of 36×36).
|
|
||||||
- **Type-0 text captions** ("Burden", "Contents of Backpack") don't render today — `UiDatElement`
|
|
||||||
doesn't draw text. Decide: promote those Type-0 elements to `UiText`, or a caption pass.
|
|
||||||
- Burden meter: verify `BuildMeter` produces a valid meter for the backpack meter's child shape
|
|
||||||
(it may differ from the vitals 3-slice shape).
|
|
||||||
|
|
||||||
**Brainstorm Qs (B-Controller):** Where does `InventoryController` get `invLayout` (expose the
|
|
||||||
`ImportedLayout` from `GameWindow`, or import inside the controller)? Cell pitch source (dump vs
|
|
||||||
hardcode 36)? Type-0 caption rendering (UiText promotion vs a caption helper)?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Open items / caveats
|
|
||||||
|
|
||||||
- **⚠ Confirm chat + toolbar didn't regress from the #145 ZLevel fix** (chat ZLevel 900, toolbar
|
|
||||||
1,2). The user looked but didn't explicitly confirm; first thing to eyeball next session.
|
|
||||||
- Type-0 text captions don't render (see §5).
|
|
||||||
- The B-Drag step reuses the **already-shipped** drag spine (`UiItemSlot` is a full drag source +
|
|
||||||
drop target; `IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut wire all
|
|
||||||
exist) — do NOT rebuild it. It needs the inventory cell as a SOURCE + the `SourceKind==Inventory`
|
|
||||||
branch in `ToolbarController.HandleDropRelease`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Branch state
|
|
||||||
|
|
||||||
All work is on `claude/hopeful-maxwell-214a12` (tip `4904ff4`), which is `main` (`a391b86`) +
|
|
||||||
this session's A + B-Grid + #145 commits (a clean fast-forward chain — `main` is an ancestor).
|
|
||||||
Decide at the start of the next session: **fast-forward `main` to `4904ff4` and start a fresh
|
|
||||||
worktree off `main`** (matches the prior-arc pattern), or continue this branch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI inventory arc. **Read
|
|
||||||
> `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md` first.** Sub-phase A
|
|
||||||
> (window manager, F12) and B-Grid (inventory sub-window mount + UiItemList grid) shipped, and #145
|
|
||||||
> (ZLevel z-order — the backdrop now sits behind the panels) is fixed — all on
|
|
||||||
> `claude/hopeful-maxwell-214a12` (tip `4904ff4`; `main` is an ancestor, so ff `main` and branch
|
|
||||||
> fresh, or continue). F12 now shows the nested inventory frame with the paperdoll equip slots
|
|
||||||
> correctly positioned, but the backpack/3D-items panels are empty and the equip slots show a
|
|
||||||
> generic border. **Next: B-Controller** — bind the imported `gmInventoryUI` tree by id and
|
|
||||||
> populate the backpack/3D-items grids from `ClientObjectTable.GetContents(player)` + drive the
|
|
||||||
> burden meter (`0x100001D9`) + render the Type-0 captions. Use the full brainstorm → spec → plan →
|
|
||||||
> subagent-driven flow; mandatory grep-named→cross-ref→pseudocode→port for any wire format;
|
|
||||||
> conformance tests throughout. Reuse the shipped grid + window manager + drag spine — don't
|
|
||||||
> rebuild. **First, eyeball chat + toolbar for any z-order regression from the #145 ZLevel fix.**
|
|
||||||
> After B-Controller: B-Wire (wire gaps), B-Drag (inventory drag SOURCE), then Sub-phase C
|
|
||||||
> (paperdoll doll + per-slot equip art — the wrong-graphics fix; needs `0x10000032` UiItemSlot
|
|
||||||
> registration).
|
|
||||||
|
|
||||||
**MEMORY.md index line:**
|
|
||||||
- [Handoff: D.2b A+B-Grid shipped, #145 fixed, B-Controller next (2026-06-21)](research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md) — window manager (F12) + inventory sub-window mount (inheritance-children, NOT game-class Type) + UiItemList grid all shipped; #145 ZLevel z-order fixed (backdrop behind panels). Next B-Controller (populate grids + burden meter from ClientObjectTable). Paperdoll equip slots need 0x10000032; chat/toolbar ZLevel-regression unconfirmed.
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
# Handoff — D.2b inventory window finish (Stage 1) shipped + visually confirmed; slot-art + paperdoll next
|
|
||||||
|
|
||||||
**Date:** 2026-06-21 (after B-Wire)
|
|
||||||
**From:** the session that shipped **B-Wire** (inventory wire layer) **and Stage 1 of the
|
|
||||||
inventory window finish** (scroll + frame + vertical resize + 102-slot grid), both visually
|
|
||||||
confirmed at the live gate.
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12`, tip **`1be7e65`** (+ this docs commit). `main` is a
|
|
||||||
clean ff ancestor — ff `main` + branch fresh, or continue this branch.
|
|
||||||
**Line numbers drift — grep the symbol.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. What shipped this session (all committed, build + full suite green, VISUALLY CONFIRMED)
|
|
||||||
|
|
||||||
Full suite green (`--no-build`, with the client running): **Core.Net 334 / App 543 / UI 425 /
|
|
||||||
Core 1530**, 0 failures.
|
|
||||||
|
|
||||||
### B-Wire — inventory wire layer (`b56087b`→`7c006d1`)
|
|
||||||
The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5). Root cause was
|
|
||||||
**delivery, not binding**: login PD dropped the player's int table, live `0x02CD` was unparsed, and
|
|
||||||
`ObjectTableWiring` gated non-UiEffects ints out. Plus the full inventory wire pass (DropItem 0x001B
|
|
||||||
/ GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195 builders; ViewContents 0x0196 /
|
|
||||||
SetStackSize 0x0197 / InventoryRemoveObject 0x0024 parsers; 0x0022 + 0x00A0 field fixes). Spec/plan
|
|
||||||
`docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md`. Detail: the B-Wire SHIPPED entry
|
|
||||||
in `claude-memory/project_d2b_retail_ui.md`.
|
|
||||||
|
|
||||||
### Inventory window finish Stage 1 (`366af0c`→`1be7e65`)
|
|
||||||
Spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`.
|
|
||||||
- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable`; gutter scrollbar `0x100001C7`
|
|
||||||
bound like `ChatWindowController`; mouse-wheel.
|
|
||||||
- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`), like vitals/chat/toolbar.
|
|
||||||
- **Vertical resize** — bottom-edge only (horizontal blocked); the grid + sub-window + scrollbar +
|
|
||||||
backdrop stretch, paperdoll + side-bags pinned; scrollbar thumb reflects view/content.
|
|
||||||
- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102);
|
|
||||||
side-bag column pads to 7.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Read first
|
|
||||||
|
|
||||||
- This doc.
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` — the **Stage 1 SHIPPED** entry + DO-NOT-RETRY (updated this session).
|
|
||||||
- `docs/ISSUES.md` — the **OPEN** "Inventory + equipment slots show the wrong empty-slot background art" issue (the immediate next task) + the two **SHIPPED** entries.
|
|
||||||
- `docs/architecture/retail-divergence-register.md` — AP-52 (side-bag 7 fallback), AP-53 (main-pack 102 fallback), AP-54 (resize approximation), AP-51 (main-pack icon, still open).
|
|
||||||
- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — for Stage 2 (paperdoll).
|
|
||||||
- `.layout-dumps/uiitem-0x21000037.txt` — the UIItem cell template states (for the correct empty-slot sprite).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY)
|
|
||||||
|
|
||||||
1. **Grid cells must be `Anchors = None`.** `UiElement.DrawSelfAndChildren` runs `ApplyAnchor` on
|
|
||||||
every child AFTER `OnDraw`; with default `Left|Top` anchors it captured each cell's scroll-0
|
|
||||||
position and reset `Top` to it every frame, fighting `UiItemList.LayoutCells`' scroll offset →
|
|
||||||
the grid "escaped the window" when scrolled. `AddItem` now sets `cell.Anchors = None` so
|
|
||||||
`LayoutCells` is the sole authority. **The unit tests missed it by calling `LayoutCells` in
|
|
||||||
isolation, never the draw traversal** — the regression test now drives the `ApplyAnchor`
|
|
||||||
interaction. (Fix `14ea938`.)
|
|
||||||
2. **Scroll reuses `UiScrollable` + a whole-row logical clip (no GL scissor)** — mirrors
|
|
||||||
`UiText.cs:198` (`cell.Visible = top >= 0 && top + h <= Height`). The gutter scrollbar `0x100001C7`
|
|
||||||
is a factory Type-11 `UiScrollbar` inheriting base `0x2100003E`; bind its `Model` to `grid.Scroll`
|
|
||||||
+ the same scrollbar sprite ids the chat scrollbar uses.
|
|
||||||
3. **Vertical-resize cascade via anchors.** Frame: `Resizable=true; ResizeX=false;
|
|
||||||
ResizableEdges=Bottom`. The stretch elements (grid `0x100001C6`, its sub-window `0x100001CF`,
|
|
||||||
scrollbar `0x100001C7`, backdrop `0x100001D0`, + the content root) get `Anchors=Left|Top|Bottom`
|
|
||||||
(fixed width, height tracks parent); paperdoll `0x100001CD` + side-bag `0x100001CE` pinned
|
|
||||||
`Left|Top`. `ComputeAnchoredRect`: Top|Bottom ⇒ `h = parentH − mB − mT`.
|
|
||||||
4. **Capacity-padding pattern.** The contents grid + side-bag column pad empty `UiItemSlot`s up to
|
|
||||||
the pack capacity (player `ItemsCapacity` default 102; `ContainersCapacity` default 7) after the
|
|
||||||
loose-item loop in `InventoryController.Populate`. Padding changed several existing tests' counts
|
|
||||||
(intended — update, don't revert).
|
|
||||||
5. **DO-NOT auto-kill the client.** The user manages client lifecycle: launch with a **plain
|
|
||||||
`dotnet run --no-build`** (no `Get-Process … CloseMainWindow/Stop-Process` preamble). If a rebuild
|
|
||||||
is blocked by a running client (`MSB3021`/`MSB3027` "locked by AcDream.App"), **ASK the user to
|
|
||||||
close it** — do not kill it. (`feedback_dont_kill_clients_before_launch`.) This overrides the
|
|
||||||
CLAUDE.md graceful-close snippet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`)
|
|
||||||
|
|
||||||
Renders correctly + confirmed: 8-piece bevel frame; vertical bottom-edge resize (expand-only,
|
|
||||||
372..560 px); contents grid clips to its panel, scrolls (wheel + gutter scrollbar), shows the full
|
|
||||||
**102-slot** main pack (items + empty frames); side-bag column with bags + empty slots; vertical
|
|
||||||
burden bar; backdrop covers the window. **Known-wrong (next task):**
|
|
||||||
- **Empty-slot background art is wrong** — inventory + side-bag empty cells use the TOOLBAR empty
|
|
||||||
sprite (`UiItemSlot.EmptySprite = 0x060074CF`); paperdoll equip slots show a generic blue
|
|
||||||
`UiDatElement` border. (User-flagged.)
|
|
||||||
- **Main-pack cell has no backpack icon** (AP-51, placeholder `tex=0`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. What's next (build order)
|
|
||||||
|
|
||||||
**(a) FIX the empty-slot background art — RECOMMENDED NEXT (the OPEN ISSUE).** Inventory + side-bag
|
|
||||||
empty cells should use the retail pack-slot frame, not the toolbar's `0x060074CF`. Find the correct
|
|
||||||
sprite from the UIItem cell template `0x21000037` (`.layout-dumps/uiitem-0x21000037.txt`) empty
|
|
||||||
state; set it on the cells `InventoryController` creates (an `EmptySprite` override per slot kind, or
|
|
||||||
a controller-set default distinct from the toolbar's). The paperdoll equip slots' per-slot
|
|
||||||
silhouettes are Sub-phase C (below). Small, self-contained; brainstorm → spec → plan as usual.
|
|
||||||
|
|
||||||
**(b) Main-pack backpack icon (AP-51).** The main-pack cell (`m_topContainer 0x100001C9`) should show
|
|
||||||
the equipped-backpack art. Pin the backpack RenderSurface DID (or the equipped-pack `CreateObject`
|
|
||||||
icon path).
|
|
||||||
|
|
||||||
**(c) Sub-phase C — paperdoll.** The 3D character doll via a Core→App `IUiViewportRenderer` seam
|
|
||||||
(`UiViewport`, Type `0xD`) + the `0x10000032` `UiItemSlot` registration so equip slots draw per-slot
|
|
||||||
silhouettes. Research: `2026-06-16-equipment-paperdoll-deep-dive.md`. The heaviest piece.
|
|
||||||
|
|
||||||
**(d) Deferred polish:** container-switching (select a side pack → show its 24-slot contents via the
|
|
||||||
already-parsed `ViewContents 0x0196`); the side-bag column scrollbar (`0x100001CB`, inert — 7 fit);
|
|
||||||
shrink-below-default resize (currently expand-only); the selected-target bar + stack split slider
|
|
||||||
(per the wiki notes the user pasted). B-Drag (drag items between slots) also remains.
|
|
||||||
|
|
||||||
Each gets the full brainstorm → spec → plan → execute flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. New-session prompt (paste into a fresh session)
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI inventory arc. **Read
|
|
||||||
> `docs/research/2026-06-21-d2b-inventory-finish-handoff.md` first.** B-Wire + the inventory window
|
|
||||||
> finish Stage 1 (scroll, 8-piece frame, vertical resize, 102-slot grid) shipped + visually confirmed
|
|
||||||
> on `claude/hopeful-maxwell-214a12` (tip `1be7e65`; `main` is a clean ff ancestor). **Next: fix the
|
|
||||||
> wrong empty-slot background art** — inventory + side-bag empty cells use the toolbar empty sprite
|
|
||||||
> `0x060074CF`; they need the retail pack-slot frame from the UIItem cell template `0x21000037` (see
|
|
||||||
> `.layout-dumps/uiitem-0x21000037.txt`). Then the main-pack backpack icon (AP-51), then Sub-phase C
|
|
||||||
> (paperdoll `UiViewport` doll + `0x10000032` per-slot equip silhouettes). Use the full brainstorm →
|
|
||||||
> spec → plan → subagent/inline flow. **DO NOT auto-kill the running client** — launch with plain
|
|
||||||
> `dotnet run --no-build`; if a rebuild is locked by a running client, ask the user to close it.
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
# Handoff — teleport foundation (fresh-look investigation) — 2026-06-21
|
|
||||||
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, NOT merged.
|
|
||||||
**Clean baseline commit:** `dd2eb8b` (the Slice 2 revert). Build + tests green here.
|
|
||||||
**Account for repro:** `notan` / `MittSnus81!` → `+Je`. The character is currently left at a far
|
|
||||||
outdoor town (landblock ~125,100 = `0x7D64`); portal via the Town Network hub (`0x0007`, an indoor cell)
|
|
||||||
to reach far towns.
|
|
||||||
|
|
||||||
> **Read this with fresh eyes.** The previous session built a "hold the player until the destination
|
|
||||||
> loads" teleport feature, hit a wall, and reverted it. This doc gives you the **evidence and the open
|
|
||||||
> questions** — NOT a prescription. You are explicitly invited to question every assumption below,
|
|
||||||
> including whether a "hold" is the right model at all. Investigate first (read-only), report, then
|
|
||||||
> brainstorm the fix with the user. **No band-aids** (the user's words: "feels shaky and bandaid").
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
Teleporting in acdream feels bad: **long transitions, the character STOPS at the portal instead of
|
|
||||||
running through, FPS sags after portaling, and a building lost its collision after a death→lifestone
|
|
||||||
teleport.** The previous session traced the "long transition" to a real root: **the destination
|
|
||||||
landblock does not stream in fast (or completely) during/around a teleport** (the #138 streaming gap),
|
|
||||||
likely amplified by **`_datLock` contention** from ACE's CreateObject flood on arrival. A "hold until
|
|
||||||
loaded" gate just made every teleport wait ~10 s for a load that wasn't happening — so it was reverted.
|
|
||||||
**The real work is the foundation: make teleport-destination streaming fast AND complete, and fix the
|
|
||||||
post-teleport FPS leak.** Then (and only then) a retail-style visual cover is polish, not a crutch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What was built, and what was reverted
|
|
||||||
|
|
||||||
The original goal was **Work item B: the retail teleport flow** (fade → portal-tunnel → hold-until-loaded
|
|
||||||
→ place; covers login/logout/death/portal). Design + plan are committed and still valid as *aspiration*:
|
|
||||||
- Spec: `docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md`
|
|
||||||
- Plan: `docs/superpowers/plans/2026-06-21-retail-teleport-flow.md` (22 TDD tasks, 5 slices)
|
|
||||||
- Prior research: `docs/research/2026-06-21-teleport-issues-handoff.md` (the TAS state-machine decomp).
|
|
||||||
|
|
||||||
Shipped, then status:
|
|
||||||
- **Slice 1 — `TeleportAnimSequencer`** (pure 7-state TAS machine, `src/AcDream.Core/World/`): **KEPT.**
|
|
||||||
Dormant, unwired, fully unit-tested (29 tests). Harmless. Reusable later.
|
|
||||||
- **Slice 2 — the readiness-gate hold** (outdoor teleport holds until the physics landblock is loaded):
|
|
||||||
**REVERTED** (`dd2eb8b`, reverting `ad8c24e..c880973` back to `00ef47e`). Outdoor teleports now place
|
|
||||||
**immediately** again (fast). See "Why reverted" below.
|
|
||||||
- **Slices 3–5 (the TAS animation/controller, login/logout/death unification): PARKED** (never started).
|
|
||||||
|
|
||||||
### Why Slice 2 was reverted (the key learning)
|
|
||||||
Slice 2 gated outdoor arrival on "is the destination physics landblock resident?" and held otherwise.
|
|
||||||
Live testing showed **every outdoor teleport froze ~10 s then force-placed**, because the destination
|
|
||||||
landblock **never became resident during the hold** (`lbs=0` the whole time). The hold was band-aiding a
|
|
||||||
foundation problem (slow/incomplete streaming) instead of fixing it — and it didn't even prevent the
|
|
||||||
#145 edge cascade it was designed for (it force-snapped onto NO-LANDBLOCK after the timeout regardless).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Root-cause findings so far (verify, don't trust)
|
|
||||||
|
|
||||||
A 3-agent read-only investigation (workflow `wf_8b67a9d1-35c`, all high-confidence) + manual
|
|
||||||
source-verification found **two distinct problems** behind the 10 s freeze:
|
|
||||||
|
|
||||||
### 1. A real (now-reverted) bug: `IsLandblockLoaded` key mismatch
|
|
||||||
The Slice 2 gate queried `IsLandblockLoaded(destCell & 0xFFFF0000)` (e.g. `0x7D640000`), but streaming
|
|
||||||
stores landblocks under the `EncodeLandblockId` form — low 16 bits = `0xFFFF` (e.g. `0x7D64FFFF`)
|
|
||||||
(`src/AcDream.App/Streaming/StreamingRegion.cs:98-99`). A raw `ContainsKey` never matched → the gate was
|
|
||||||
permanently `NotReady`. This was **fixed** (`c880973`, normalize the key + the regression test the
|
|
||||||
original missed) then **reverted along with the rest of Slice 2**. *If a future hold is reintroduced, the
|
|
||||||
predicate must normalize the key (the fix is preserved in history at `c880973`).*
|
|
||||||
|
|
||||||
### 2. The actual foundation problem: the destination doesn't stream in fast/complete
|
|
||||||
Even with the key bug fixed, `lbs=0` (zero landblocks resident) for the entire hold — the destination
|
|
||||||
genuinely was not loaded. Evidence + leads:
|
|
||||||
- The streaming machinery is *commanded* correctly: on a dungeon-OUT teleport the gate logs
|
|
||||||
`streaming: dungeon EXIT-expand -> (Lx,Ly)` and the observer is correctly pinned to the destination
|
|
||||||
during the hold (`src/AcDream.App/Rendering/GameWindow.cs` ~7441-7459 PortalSpace observer-pin;
|
|
||||||
`DungeonStreamingGate.cs:44-45` returns `InsideDungeon=false` for a teleport hold;
|
|
||||||
`StreamingController.cs` ~130-163 Tick dispatch, ~279-297 `ExitDungeonExpand`, ~161-163 `DrainAndApply`
|
|
||||||
runs unconditionally). So the *command* path is not obviously gated on `InWorld`.
|
|
||||||
- **Yet nothing loads during the hold, and the landblock appears the instant the player flips to
|
|
||||||
`InWorld`.** The leading hypothesis (MEDIUM confidence — NOT proven) is **`_datLock` starvation**: ACE
|
|
||||||
floods CreateObjects on arrival; `WorldSession.Tick` drains them in a tight loop
|
|
||||||
(`src/AcDream.Core.Net/WorldSession.cs` ~598-607, no yield), each under `lock(_datLock)`
|
|
||||||
(`GameWindow.cs` ~2679-2682), and the streaming worker also needs `_datLock` to build a landblock
|
|
||||||
(`BuildLandblockForStreaming` — confirm the exact site; the investigation cited a `_datLock` acquire on
|
|
||||||
the build path). So the render-thread CreateObject flood may block the background streamer for the whole
|
|
||||||
arrival window. **This is the #1 thing to confirm or refute.**
|
|
||||||
- `AddLandblock` registers terrain + cells + portals **atomically** (`PhysicsEngine.cs` ~66-74), and the
|
|
||||||
spatial lookups (`SampleTerrainZ`, `TryGetLandblockContext`) find landblocks by world-XY **bounds**, not
|
|
||||||
by dictionary key — which is why a force-placed player grounds correctly the instant the LB appears.
|
|
||||||
|
|
||||||
### 3. The lost-collision symptom (death→lifestone → building has no collision)
|
|
||||||
Not yet root-caused. Hypothesis: the destination loaded **incompletely** — terrain present but the
|
|
||||||
building/cell **collision** (`ShadowObjects` / the per-cell shadow list) not registered. See
|
|
||||||
`memory/feedback_retail_per_cell_shadow_list.md` (retail collision = per-cell list with portal-aware
|
|
||||||
registration). Likely the same root as #2 (incomplete async load), but **verify independently** — it may
|
|
||||||
be a distinct registration-order bug.
|
|
||||||
|
|
||||||
### 4. FPS sag after portaling (Work item C)
|
|
||||||
Separate known issue (a cumulative leak). Candidates from the prior research: streaming/entity state not
|
|
||||||
freed on teleport round-trips, `_lastSpawnByGuid` / `GpuWorldState` accumulation, dungeon collapse/expand
|
|
||||||
not cleaning up. The `_datLock` contention also contributes to per-frame hitching during the flood.
|
|
||||||
**Note:** `ACDREAM_PROBE_RESOLVE=1` is itself a massive FPS killer (it `Console.WriteLine`s every physics
|
|
||||||
resolve — a 561k-line log). Do NOT measure FPS with it on; it confounds everything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The investigation to run (the new session's job)
|
|
||||||
|
|
||||||
Root-cause, read-only, then report + brainstorm. Core questions:
|
|
||||||
1. **Why is the teleport destination not resident fast?** Confirm/refute the `_datLock` starvation
|
|
||||||
hypothesis: instrument (lightly) the streamer worker's load completions and the `_datLock` hold time
|
|
||||||
during an arrival, and see whether the worker is blocked by the CreateObject flood. Is the async
|
|
||||||
load→apply pipeline gated, starved, or just slow? Does the dungeon collapse→EXIT-expand transition add
|
|
||||||
latency?
|
|
||||||
2. **Why does collision get dropped** after a teleport (the lost-building-collision)? Trace where cell /
|
|
||||||
ShadowObject collision is registered during streaming and what order vs terrain; is it skipped or
|
|
||||||
raced on a teleport arrival?
|
|
||||||
3. **What leaks** (Work item C) across teleport round-trips? Capture landblock + entity counts across N
|
|
||||||
teleports; find what grows unbounded.
|
|
||||||
4. **What does retail actually do?** The decomp (`docs/research/named-retail/`) + the prior TAS research
|
|
||||||
show retail plays a tunnel animation that *covers a synchronous-feeling load*, gated on
|
|
||||||
`position_update_complete` (the DDD interrogation). Retail's load is fast within the tunnel. So the
|
|
||||||
question isn't "hold vs no-hold" in the abstract — it's **"how do we make the load fast + complete,"**
|
|
||||||
after which either a short hold-with-cover OR place-immediately becomes viable.
|
|
||||||
|
|
||||||
## Open DESIGN questions to reconsider with fresh eyes (don't assume the prior answers)
|
|
||||||
- **Hold vs place-immediately.** The reverted baseline places immediately (fast, but risks pop-in /
|
|
||||||
the #145 edge cascade on a not-yet-streamed edge arrival). Retail holds behind a tunnel anim but loads
|
|
||||||
fast. Which model fits once streaming is fast? (AC2D, a reference, sends movement keys + trusts server
|
|
||||||
Z and does no client-side hold — worth weighing.)
|
|
||||||
- **The `PortalSpace` input-freeze.** Today entering a teleport freezes input (`OnTeleportStarted` sets
|
|
||||||
`PlayerState.PortalSpace`), so the **character STOPS at the portal** instead of running through. The
|
|
||||||
user explicitly wants "run through the portal." Is freezing input on portal-enter even correct? What
|
|
||||||
does retail do (keep momentum? freeze?)? This is independent of streaming and may be its own fix.
|
|
||||||
- **The #145 edge cascade.** With place-immediately restored, a teleport that arrives near a landblock
|
|
||||||
EDGE onto an unstreamed neighbour can still march the cell label + free-fall Z (the original #145
|
|
||||||
residual). The hold never actually fixed this. The real fix is fast/complete streaming so the arrival
|
|
||||||
lands on a loaded world; failing that, the cell-relative physics frame (#145 Slices 1–3, already
|
|
||||||
shipped) limits it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Apparatus / how to reproduce + diagnose
|
|
||||||
|
|
||||||
- **Launch** (PowerShell; client lifecycle is the USER's — launch with plain `dotnet run`, do NOT
|
|
||||||
pre-close clients; if a rebuild is locked by a running client, ASK):
|
|
||||||
```powershell
|
|
||||||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
|
||||||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
|
||||||
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
|
|
||||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
|
|
||||||
```
|
|
||||||
Build green FIRST (`dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`); launch uses `--no-build`.
|
|
||||||
- **Repro the bad teleport:** in-world, portal via the hub (`0x0007`) to a far town, or recall to
|
|
||||||
lifestone. Watch the `live: teleport started` → `teleport complete` gap and `[snap] ... NO-LANDBLOCK
|
|
||||||
(lbs=0)`.
|
|
||||||
- **Diagnostics:** `ACDREAM_PROBE_RESOLVE=1` exists but is FPS-toxic + confounding — prefer a *new, light*
|
|
||||||
probe (e.g. log streamer load-completions + `_datLock` hold-time during an arrival). The prior apparatus
|
|
||||||
logs (`launch-gate-retest*.log`, `launch-clean-noprobe*.log`) are in the worktree root but transient —
|
|
||||||
the evidence above is the durable summary.
|
|
||||||
- **It's a Debug build** — FPS is not representative; a `Release` build is far faster. Build Release if you
|
|
||||||
want a real frame-rate read.
|
|
||||||
|
|
||||||
## Clean baseline — what NOT to touch
|
|
||||||
- `dd2eb8b` is the clean baseline: outdoor teleports place immediately, Slice 1 sequencer dormant.
|
|
||||||
- Do NOT re-introduce the Slice 2 hold without fixing the streaming foundation first (that's the band-aid
|
|
||||||
we removed).
|
|
||||||
- Frozen/shipped phases per the milestones doc remain off-limits.
|
|
||||||
|
|
||||||
## Key references
|
|
||||||
- Render/physics digests: `claude-memory/project_render_pipeline_digest.md`,
|
|
||||||
`claude-memory/project_physics_collision_digest.md` (DO-NOT-RETRY tables).
|
|
||||||
- Streaming refs: `memory/reference_two_tier_streaming.md`, `memory/reference_indoor_cell_tracking.md`.
|
|
||||||
- `memory/feedback_apparatus_for_physics_bugs.md`, `memory/feedback_retail_per_cell_shadow_list.md`,
|
|
||||||
`memory/feedback_verify_subagent_claims_against_source.md` (read cited code yourself; agents fabricate).
|
|
||||||
- ISSUES: #138 (teleport-OUT EXPAND gap), #145 (edge cascade), Work item C (FPS leak) — update
|
|
||||||
`docs/ISSUES.md` with the new symptoms (lost-collision-after-teleport; char-stops-at-portal) as part of
|
|
||||||
the investigation.
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# Handoff — teleport issues cluster (2026-06-21)
|
|
||||||
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, NOT merged ("merge later").
|
|
||||||
**Account:** `notan` / `MittSnus81!` → `+Je`. Portal via the Town Network hub (`0x0007`) to far towns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What shipped this session (context)
|
|
||||||
|
|
||||||
#145 **cell-relative physics frame (Option B)**, Slices 1–3 + 7 (`438bb68`→`403a338`). The carried-anchor
|
|
||||||
fix closes the far-town cascade **for a STREAMED-terrain arrival** — live-verified across ~10 far-town
|
|
||||||
landblocks over 2 sessions, zero march. Design + plan:
|
|
||||||
`docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md` + the matching plan.
|
|
||||||
Adversarial review passed (2 flagged issues were verified false positives). Slices **4–6** (contact-plane /
|
|
||||||
walkable cell-relative + `_liveCenter` fully render-only — invisible architectural completeness) remain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Work item A — #145 RESIDUAL (reopened): unstreamed-arrival-near-edge cascade
|
|
||||||
|
|
||||||
**Trigger:** a teleport that arrives onto a **NOT-YET-STREAMED** landblock **near an edge** → the cascade
|
|
||||||
recurs (+ a Z free-fall). **Evidence** (`launch5.log`):
|
|
||||||
- `[snap] claim=0xC98C0028 pos=(113.666,190.259,22.010) branch=NO-LANDBLOCK (lbs=0) -> verbatim` — arrival
|
|
||||||
local Y=190.3, **1.7 m from the 192 edge**, destination not streamed → cell marches `0xC98C → 0xC9FE`
|
|
||||||
(+2 lbY/tick), Z free-falls 22→−19; outbound wire sends the marched cell `C9FE0031` + compensating garbage
|
|
||||||
`localY=−21684` → ACE `MOVEMENT SPEED` / `failed transition` → **player stuck**.
|
|
||||||
- Same session, **mid-block** unstreamed arrival `0x977B000C` (Y=73.8) → Z free-fall only, **no march**.
|
|
||||||
- Hub `0x00070133` **VALIDATED** (cells resident) → fine.
|
|
||||||
|
|
||||||
**Root (HYPOTHESIS — not apparatus-confirmed):** outdoor teleport places immediately but the destination
|
|
||||||
streams a few ticks later (`streaming: dungeon EXIT-expand -> (201,140)` logs just after placement). During
|
|
||||||
the gap the resolve runs against an empty world; at an edge the player crosses into the **unstreamed
|
|
||||||
neighbour**, where the carried anchor (which computes to `(0,0,0)` at the recentered center) gives no
|
|
||||||
protection. **Same root as the Z free-fall** (`#135`/`#138` placed-before-streamed gap).
|
|
||||||
|
|
||||||
**⚠️ DO NOT guess-patch** (original #145 burned 5 attempts). **Apparatus first:** add a diagnostic that logs
|
|
||||||
the **anchor value + the engagement-guard state** in `PhysicsEngine.ResolveWithTransition` at the crossing
|
|
||||||
tick (behind a probe flag), reproduce a `0xC98C`-style arrival once, and confirm whether the anchor
|
|
||||||
**disengages** (CellId/CellPosition landblock divergence) or the **body is repositioned into a marched frame
|
|
||||||
first**. *Then* the fix is a known quantity.
|
|
||||||
|
|
||||||
**Likely fix:** a **streaming-gap HOLD** — freeze the per-tick resolve (no integration/gravity, no membership
|
|
||||||
march) while the player's landblock is unloaded, until it streams in (a few ticks). The async-streaming
|
|
||||||
equivalent of retail's synchronous load; would fix **both** the cascade and the Z plunge.
|
|
||||||
**Note:** this may be **subsumed by Work item B** — the retail teleport flow holds until the world loads, so
|
|
||||||
there's no unstreamed gap to begin with. Decide A-vs-B ordering at brainstorm.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Work item B — KEYSTONE FEATURE: the retail teleport flow
|
|
||||||
|
|
||||||
**Symptoms (user-reported; all ONE root):**
|
|
||||||
1. Camera **floats** to the destination on teleport (disliked).
|
|
||||||
2. Teleport **takes too long** / feels off vs retail.
|
|
||||||
3. Player **input is not locked** during teleport.
|
|
||||||
|
|
||||||
**Retail behaviour (user = oracle, treat as axiom):** teleport plays an **animation** (portal effect), holds
|
|
||||||
input + camera, **waits for the world to load**, then exits/places the player. Used for **ALL** teleportation:
|
|
||||||
**login, logout, death, portal.**
|
|
||||||
|
|
||||||
**Insight:** acdream places **immediately** (raw placement → the floating camera + the unstreamed gap). A
|
|
||||||
faithful retail teleport flow (anim + input/camera lock + **hold-until-loaded** + single exit point) fixes the
|
|
||||||
float, the timing, the input-lock, **and very likely Work item A.** This is the keystone.
|
|
||||||
|
|
||||||
**This is a FEATURE → resume `superpowers:brainstorming`** (user is the retail oracle on look/feel). The
|
|
||||||
3-agent research (workflow `wf_61501a7a-1d4`) is DONE — findings below. **Extend acdream's machinery, don't
|
|
||||||
rebuild.**
|
|
||||||
|
|
||||||
### RETAIL flow (decomp-verified) — `gmSmartBoxUI` teleport **animation state machine**
|
|
||||||
|
|
||||||
`TeleportAnimState` enum (`acclient.h:6871`): the client plays a **fade + portal-tunnel animation that COVERS
|
|
||||||
the world-load wait** — used by ALL of login / logout / death / portal:
|
|
||||||
|
|
||||||
```
|
|
||||||
TAS_OFF(0) → WORLD_FADE_OUT(1, ~1s) → TUNNEL_FADE_IN(2, ~1s) → TUNNEL(3 = HOLD for world load)
|
|
||||||
→ TUNNEL_CONTINUE(4, min ~2s) → TUNNEL_FADE_OUT(5, ~1s) → WORLD_FADE_IN(6, ~1s) → OFF
|
|
||||||
```
|
|
||||||
- State machine: `gmSmartBoxUI::UseTime` (pc 219400); begin `BeginTeleportAnimation` (218888); end `EndTeleportAnimation` (218994).
|
|
||||||
- **The world-load HOLD** is `TAS_TUNNEL(3)`, gated on `SmartBox::teleport_in_progress` (pc 90815) = `(player != 0) && (position_update_complete == 0)`; `position_update_complete` flips to 1 when the **DDD interrogation / position update** completes → animation advances. **This is the "exit when the world has loaded" the user wants.**
|
|
||||||
- **Input lock:** `PlayerModule::SetLockUI` sets bit `0x1000000` in `options2_` (pc 486713) for the whole animation; cleared at `TAS_OFF`.
|
|
||||||
- **Unification:** **death + logout share** the flow via `logOffRequested`; **login** enters via `RecvNotice_BeginEnterWorld`; **portal** starts directly at `TAS_TUNNEL` (skips the fade-out). Sounds: `Sound_UI_EnterPortal`(0x6A) / `ExitPortal`(0x6B). Exit callback `SendLoginCompleteNotification` (pc 367516).
|
|
||||||
- Missing from decomp: exact `TELEPORT_ANIM_FADE_TIME` / `MIN_CONTINUE_TIME` constants; the DDD code that sets `position_update_complete`.
|
|
||||||
|
|
||||||
### acdream TODAY — what exists, what's missing
|
|
||||||
|
|
||||||
acdream ALREADY has the *skeleton*: `PlayerState.PortalSpace` **input lock** (`PlayerMovementController.cs:840-854`)
|
|
||||||
+ a **hold-until-readiness** controller (`TeleportArrivalController.cs:56-105`; `GameWindow.OnTeleportStarted`
|
|
||||||
:5487, `OnLivePositionUpdated`/`BeginArrival` :5394, `PlaceTeleportArrival` :5443-5478, readiness :5419-5439).
|
|
||||||
|
|
||||||
**The gaps (the actual work):**
|
|
||||||
1. **NO animation / visual cover.** During the hold the camera is frozen at the OLD spot, then `PlaceTeleportArrival` **snaps** both cameras (`:5464-5469`, no fade/lerp). That snap + the world streaming in *is* the "floating" the user dislikes. → **Build a client teleport-anim state machine (TAS-shaped: fade-out → tunnel/hold → fade-in) that covers the wait.**
|
|
||||||
2. **Readiness gate is too weak → this is the #145 residual.** Outdoor readiness gates on `SampleTerrainZ != null` (`:5436`) — terrain heightmap can sample BEFORE the collision **landblock cells** load, so the player is placed onto a `branch=NO-LANDBLOCK` world → the unstreamed-arrival cascade + Z free-fall (Work item A). → **Gate on the full landblock being loaded** (`teleport_in_progress`-equivalent). **Fixing this likely closes Work item A.**
|
|
||||||
3. **`yaw` still updates during the hold** (`MouseLook` not frozen, gotcha) — lock it too.
|
|
||||||
4. **Login / logout / death NOT unified.** Login uses inline auto-entry guards (`:1036-1065`) that DUPLICATE teleport's readiness (`:5419-5439`) + recenter (`:2874-2881` vs `:5364-5370`). **Logout does not exist; death only feeds `Chat.OnPlayerKilled` (`:2569`), no game effect.** → Route all four through one arrival/anim controller; de-dupe readiness + recenter.
|
|
||||||
|
|
||||||
### Design direction (for the brainstorm)
|
|
||||||
|
|
||||||
Build a **client-side teleport animation + arrival controller** (port the TAS state machine shape) layered on
|
|
||||||
the existing PortalSpace lock + TeleportArrivalController hold: (a) a fade/tunnel **visual cover** over the
|
|
||||||
world-load wait (kills the float); (b) **strengthen the readiness gate** to the full landblock (kills the #145
|
|
||||||
residual + the Z free-fall); (c) **unify** login/logout/death/portal entry points + de-dupe the readiness/recenter
|
|
||||||
logic; (d) tune timing so it doesn't feel longer than retail (the current 600-frame/10s timeout is the worst case).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Work item C — FPS leak after teleports (perf bug, SEPARATE)
|
|
||||||
|
|
||||||
**Symptom:** FPS drops low after a couple of teleports (cumulative).
|
|
||||||
**Hypothesis:** a streaming/entity **leak** — landblocks or render/physics entities not freed on teleport,
|
|
||||||
accumulating across round-trips (candidates: dungeon collapse/expand not cleaning up; `RehydrateServerEntities`
|
|
||||||
/ `_lastSpawnByGuid` accumulating; `GpuWorldState` entities not pruned). **NEXT:** capture FPS + landblock /
|
|
||||||
entity counts across N teleports (`ACDREAM_DUMP_LIVE_SPAWNS=1` + streaming logs + the DebugPanel counts), find
|
|
||||||
what grows unbounded. Independent of A and B.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pointers / apparatus
|
|
||||||
|
|
||||||
- **Logs (worktree root):** `launch5.log` (#145 residual capture — the `0xC98C` stuck-at-`−21684` run),
|
|
||||||
`launch4.log` (Z free-fall + a 3-far-town round-trip), `launch3`/`launch2` (clean streamed-arrival runs).
|
|
||||||
- **#145 record:** `docs/ISSUES.md` #145 (reopened, with the trigger table) + the physics digest banner
|
|
||||||
(`claude-memory/project_physics_collision_digest.md`).
|
|
||||||
- **Probes (no rebuild):** `ACDREAM_PROBE_CELL=1` (`[cell-transit]`), `ACDREAM_PROBE_RESOLVE=1`,
|
|
||||||
`ACDREAM_CAPTURE_RESOLVE=<path>`, `ACDREAM_DUMP_LIVE_SPAWNS=1`.
|
|
||||||
- **Decomp oracle:** `docs/research/named-retail/` (grep `class::method`); for the teleport flow, look at the
|
|
||||||
portal/teleport + DDD-loading + animation paths.
|
|
||||||
- **Wire cross-check:** `references/holtburger` (client teleport flow) + `references/ACE` (server teleport /
|
|
||||||
`MOVEMENT SPEED` validation — the `failed transition` rejects).
|
|
||||||
|
|
||||||
## Suggested order (brainstorm to confirm)
|
|
||||||
|
|
||||||
1. **B first** (retail teleport flow) — it's the keystone and likely subsumes **A**. Brainstorm → spec → build.
|
|
||||||
2. Re-test **A**: if the hold-until-loaded flow eliminates the unstreamed gap, the #145 residual is gone; if a
|
|
||||||
narrow edge case survives, apply the diagnostic + targeted hold.
|
|
||||||
3. **C** (FPS leak) — independent; can run in parallel as its own investigation.
|
|
||||||
4. (Background) Slices **4–6** — the invisible Option-B completeness, unrelated to this cluster.
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# Handoff — D.2b inventory container-switching (next clean win)
|
|
||||||
|
|
||||||
**Date:** 2026-06-22 (banked at the end of the empty-slot-art session, per [[feedback_session_handoff]])
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12`, tip `1f8dd7a` (+ this docs commit). `main` is a clean ff ancestor (68 commits behind).
|
|
||||||
**Status:** SCOPED, not started. Brainstorm explored the wire + controller; this captures the design sketch so a fresh session executes it with the live-wire round-trip getting proper attention. Run the full brainstorm → spec → plan → execute flow.
|
|
||||||
**Line numbers drift — grep the symbol.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why this is the next pick
|
|
||||||
|
|
||||||
After the empty-slot-art arc (inventory contents/side-bag/main-pack empty cells now dat-resolve their art — see [[project_d2b_retail_ui]] + the empty-slot handoff), the side-bag column is **rendered but inert**: clicking a bag does nothing. Container-switching makes it functional AND closes the **AP-56** deferral (the selected-container triangle + green/yellow selection square live here). The user picked this over the paperdoll (Sub-phase C) because the paperdoll's empty equip slots are entangled with the heavy 3D doll viewport (retail empties show the doll), whereas container-switching is self-contained.
|
|
||||||
|
|
||||||
This is the **first inventory feature with live two-way wire interaction** (a `Use` → `ViewContents` round-trip), a step up from the read-only display work so far — hence executing it fresh rather than tired.
|
|
||||||
|
|
||||||
## Read first
|
|
||||||
|
|
||||||
- This doc.
|
|
||||||
- `claude-memory/project_d2b_retail_ui.md` — the D.2b SSOT + the empty-slot-art SHIPPED entry (the resolver + AP-55/AP-56).
|
|
||||||
- `claude-memory/reference_retail_inventory_paperdoll.md` — the player-facing spec: the "preferred pack" auto-fit rules + the 7-side-bag / 102-main-pack capacities.
|
|
||||||
- `docs/architecture/retail-divergence-register.md` — **AP-56** (the selected-container indicator deferred — this phase retires it) + AP-53 (container-switch noted as deferred).
|
|
||||||
|
|
||||||
## What's already in place (the wire layer is DONE)
|
|
||||||
|
|
||||||
- **`InteractRequests.BuildUse` (`0x0036`)** — open/use a container by guid (`src/AcDream.Core.Net/Messages/InteractRequests.cs:38`). This is how retail opens a container.
|
|
||||||
- **`GameEvents.ParseViewContents` (`0x0196`)** — the server's full contents list `(ContainerGuid, [guid, containerType]×N)` (`src/AcDream.Core.Net/Messages/GameEvents.cs:372`). Handler at `GameEventWiring.cs:260`.
|
|
||||||
- **`WorldSession.SendNoLongerViewingContents` (`0x0195`)** — close a container view (`WorldSession.cs:1212`).
|
|
||||||
- **`ClientObjectTable.GetContents(containerId)`** — the per-container item index (`src/AcDream.Core/Items/ClientObjectTable.cs:313`).
|
|
||||||
- **The standing TODO** at `GameEventWiring.cs:256`: `ViewContents` currently does an **additive merge**; container-open must treat it as a **full REPLACE** (flush the container's prior membership first) so server-side removals don't linger. **This phase consumes that TODO.**
|
|
||||||
|
|
||||||
## Design sketch (to be confirmed in the brainstorm)
|
|
||||||
|
|
||||||
1. **Open-container state in `InventoryController`.** Add an `_openContainer` field (default = the player guid / main pack). `Populate` shows `GetContents(_openContainer)` instead of always the player. Repaint when the open container's membership changes (extend `Concerns`).
|
|
||||||
2. **Click a side-bag cell → open it.** The side-bag `UiItemSlot`s already carry the bag guid (bound in `Populate`). On click: set `_openContainer = bagGuid`; `SendUse(bagGuid)` (opens it server-side); when the previous container was a side bag, `SendNoLongerViewingContents(prevGuid)`. Click the main-pack cell (`0x100001C9`) → reset `_openContainer` to the player.
|
|
||||||
3. **`ViewContents 0x0196` → full REPLACE.** Flush the container's membership in `ClientObjectTable` before recording the entries (the TODO). Then `InventoryController` repaints the grid for `_openContainer`.
|
|
||||||
4. **Selected-container indicator (retires AP-56).** Draw the open-container triangle `0x06005D9C` + the green/yellow selection square on the selected side-bag cell — a new `UiItemSlot` "selected" overlay (model it on the existing `DragAccept` overlay in `UiItemSlot.OnDraw`; guard `id != 0` per [[feedback_ui_resolve_zero_magenta]]).
|
|
||||||
5. **Caption.** "Contents of Backpack" → the open container's name when a side bag is selected (the caption host is already driven by `InventoryController.AttachCaption`).
|
|
||||||
|
|
||||||
## Open questions for the brainstorm
|
|
||||||
|
|
||||||
- **The green/yellow selection-square sprite id** — the triangle is `0x06005D9C` (from the container prototype `0x1000033F` child `0x10000450`); the selection square's id is NOT yet found. Dump `0x21000037` / the gmBackpackUI layout (`0x21000022`) for the selected-container state media, or cdb a live retail client with a side bag open.
|
|
||||||
- **Are a side bag's contents pre-known, or do they arrive only on open?** Likely the latter (retail sends contents on open via `ViewContents`, not upfront) — so the `Use` round-trip is required, and `GetContents(bagGuid)` is empty until the reply lands. Confirm against a live capture (WireMCP on `127.0.0.1:9000`, or watch the `ViewContents` handler at the live gate).
|
|
||||||
- **Preferred-pack semantics** (from `reference_retail_inventory_paperdoll`): opening a pack makes it the "preferred" pack for auto-fit. Out of scope for the first cut (display-only switching), but note it.
|
|
||||||
- **Empty-container display** — a side bag with no contents shows an empty 24-slot grid (its `ItemsCapacity`, default 24 per AP-52/the wiki). Pad like the main pack does.
|
|
||||||
|
|
||||||
## Acceptance / scope
|
|
||||||
|
|
||||||
- First cut: click a side bag → its contents show in the grid; the selected bag shows the triangle + selection square; click the main pack → back. `ViewContents` is a full replace. Build + full suite green; **visual gate** (F12, the user clicks bags and confirms the grid switches + the selected indicator).
|
|
||||||
- Retires **AP-56** (selected-container indicator) in the same commit that draws it.
|
|
||||||
- Out of scope: preferred-pack auto-fit, drag-INTO a side bag (that's B-Drag), the side-bag column scrollbar (`0x100001CB`, inert — 7 fit).
|
|
||||||
|
|
||||||
## New-session prompt
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI inventory arc. **Read `docs/research/2026-06-22-container-switching-handoff.md` first.** The empty-slot art shipped + was visually confirmed (`claude/hopeful-maxwell-214a12`, tip `1f8dd7a`). **Next: inventory container-switching** — click a side bag → show its contents in the grid (via `Use 0x0036` → `ViewContents 0x0196` full-replace), draw the selected-container triangle `0x06005D9C` + green/yellow selection square (retires AP-56), click the main pack to go back. The wire builders/parsers all exist (`BuildUse`, `ParseViewContents`, `SendNoLongerViewingContents`); consume the `GameEventWiring.cs:256` full-REPLACE TODO. Run the full brainstorm → spec → plan → subagent-driven → visual-gate flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it.
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
# Handoff — D.2b Sub-phase C: the paperdoll (equip slots + 3D doll)
|
|
||||||
|
|
||||||
**Date:** 2026-06-22 (banked at the end of the inventory drag-drop session, per [[feedback_session_handoff]])
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12`, tip `82ab0e0`. `main` is a clean fast-forward ancestor.
|
|
||||||
**Status:** SCOPED, not started. The decomposition + corrections + reuse map below are the design sketch; a fresh session runs the full brainstorm → spec → plan → subagent-driven → visual-gate flow **per slice**.
|
|
||||||
**Line numbers drift — grep the symbol.**
|
|
||||||
|
|
||||||
> **⚠ SUPERSEDED / CORRECTED 2026-06-23 — Slice 1 SHIPPED (`a4c6852`).** This handoff's "⚠ Correction"
|
|
||||||
> section below ("empty equip slots are TRANSPARENT, no silhouettes") **was WRONG** and cost a visual-gate
|
|
||||||
> iteration. The user-axiom truth (his retail screenshots): the paperdoll has a **"Slots" toggle button
|
|
||||||
> (`0x100005BE` = `m_SlotCheckbox`)**. Button OFF (default) = the **live 3-D character** (the doll — can be
|
|
||||||
> naked) + the non-armor slots (weapon/shield/wand/jewelry/under-clothes); button ON = the doll **hides** and
|
|
||||||
> the **armor slots** become visible. **The "figure"/"silhouette" IS the doll (live render), NOT per-slot
|
|
||||||
> silhouette sprites — there are none.** So the doll `UiViewport` (Type 0xD, `0x100001D5`) + the Slots toggle
|
|
||||||
> = **Slice 2**. Slice 1 (equip-slot bind + wield/unwield + **visible empty-slot frames** + the `EquipMask`
|
|
||||||
> correction + the `PickupEvent` unwield fix) SHIPPED. **The corrected model + the full DO-NOT-RETRY list live
|
|
||||||
> in `claude-memory/project_d2b_retail_ui.md` (the Slice 1 entry) — read that, not the §"⚠ Correction" below.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Read first (in this order)
|
|
||||||
|
|
||||||
1. **This doc.**
|
|
||||||
2. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE research. Full element-id → `EquipMask` table (§3a, ~25 slots), the viewport mechanism (§5), the wield wire (§4), the reuse analysis (§5c), and the open `UNVERIFIED` list (§7). **Do NOT re-derive what's already CONFIRMED there — cite it.**
|
|
||||||
3. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT (the shipped log: container-switching, icons, capacity bars, drag-drop; the `UiItemSlot`/drag-spine/importer facts you'll reuse).
|
|
||||||
4. **`docs/architecture/retail-divergence-register.md`** — for the AP rows you'll add; and `docs/architecture/code-structure.md` Rule 2 (the Core→App seam constraint that governs the doll viewport).
|
|
||||||
|
|
||||||
## Why this is next
|
|
||||||
|
|
||||||
The inventory window is functionally complete in 2D (browse/switch bags, icons, capacity bars, drag-drop move). The **last placeholder** is the paperdoll: the ~25 equip slots render as generic blue `UiDatElement` borders, and there's no character doll. It's the standing roadmap NEXT and the most visible remaining gap when F12 is open.
|
|
||||||
|
|
||||||
## ⚠ Correction to carry in (the deep-dive settles this — don't repeat the old assumption)
|
|
||||||
|
|
||||||
The earlier brainstorm framed Slice 1 as "per-slot body-part **silhouettes**." **That is wrong.** Per the deep-dive §2a (decomp lines 99–102, CONFIRMED): retail equip slots **default INVISIBLE and show only when occupied** — an **empty** slot is **transparent and the doll body shows through it**; an **occupied** slot shows the item icon. There are no per-slot silhouette sprites. acdream's current blue border is simply the wrong rendering of an empty slot.
|
|
||||||
|
|
||||||
Consequence: the doll is the visual backdrop the empty slots reveal. Slice 1 (slots) and Slice 2 (doll) are therefore **interdependent** for the empty-slot look — decide the empty-slot treatment for a doll-less Slice 1 in the brainstorm (transparent-to-panel vs a temporary placeholder).
|
|
||||||
|
|
||||||
## The decomposition — TWO slices (each its own spec → plan → implement)
|
|
||||||
|
|
||||||
### Slice 1 — equip slots (functional; the lighter, independently-valuable first piece)
|
|
||||||
|
|
||||||
Bind the ~25 equip slots to live equipped-item data + make them drag-drop equip/unequip targets. **No 3D doll yet.** Delivers: you SEE your equipped gear (icons in the right slots) and can drag gear onto a slot to wield it / off to unwield.
|
|
||||||
|
|
||||||
- **The slots already exist + are positioned** (the inventory frame mounts gmPaperDollUI at `0x100001CD`; `DatWidgetFactory` builds `0x10000031` → `UiItemList`; B-Grid confirmed the positions). The work is a new **`PaperdollController`** (the `gmPaperDollUI::PostInit` analogue) that:
|
|
||||||
- Maps each slot **element-id → `EquipMask`** via the deep-dive §3a table (port `GetLocationInfoFromElementID`, decomp 173620 — it's a static switch; transcribe the table, don't call the decomp at runtime).
|
|
||||||
- Populates each slot's icon from the item whose `ClientObject.CurrentlyEquippedLocation == that slot's EquipMask` (acdream ALREADY tracks `CurrentlyEquippedLocation` per item via `WieldObject 0x0023` + `CreateObject`; `ClientObjectTable`). So the equipped-state data is already there — no new parse needed for Slice 1 MVP.
|
|
||||||
- Makes each slot a `UiItemSlot` (the shipped shared widget) — single 32×32 cell, occupancy-gated icon, drag accept/reject overlay. Empty-slot rendering per the correction above.
|
|
||||||
- Registers the controller as the `IItemListDragHandler` for the equip slots (reuse the shipped drag-drop spine): `OnDragOver` accepts iff the dragged item's `ValidLocations` includes the slot's `EquipMask`; `HandleDropRelease` = **wield** (`GetAndWieldItem 0x001A`) optimistically + via wire; dragging a slot's item OUT to the grid = un-wield (`PutItemInContainer 0x0019`, the SHIPPED path).
|
|
||||||
- **THE WIRE GAP (build it):** `GetAndWieldItem` (`0x001A`, payload `uint itemGuid; (uint)EquipMask`) has **no builder/sender in acdream** (deep-dive §4a — only the unrelated UI font property `0x1A` exists). Add `InteractRequests.BuildGetAndWieldItem(seq, itemGuid, equipMask)` (mirror `BuildPickUp`) + `WorldSession.SendGetAndWieldItem(...)` (mirror `SendPutItemInContainer`). ACE: `GameActionGetAndWieldItem.Handle` (item + EquipMask). holtburger `commands.rs:808` confirms the shape.
|
|
||||||
- **The appearance reply** (`ObjDescEvent 0xF625`) is already PARSED (deep-dive §4a); it matters for the DOLL (Slice 2), not Slice 1's icons.
|
|
||||||
|
|
||||||
**Slice 1 scope cuts:** no doll, no part-selection highlight, no left/right-side disambiguation polish (the paired wrist/finger slots — handle via the SIDE column in §3a only if trivial), no Aetheria sigil slots (hidden by default).
|
|
||||||
|
|
||||||
### Slice 2 — the 3D doll viewport (the heavy piece: the UI↔3D bridge)
|
|
||||||
|
|
||||||
The `UIElement_Viewport` (Type `0xD`, element `0x100001D5` in gmPaperDollUI) hosting a re-dressed clone of the local player, drawn into the slot region behind the equip slots. **This is the single biggest new piece** — see deep-dive §5 (CONFIRMED viewport mechanism) + §5d (the design-open seam).
|
|
||||||
|
|
||||||
- **Reuse the existing char path** (deep-dive §5c, CONFIRMED): acdream already renders animated, equipped characters in-world via `EntitySpawnAdapter` → `AnimatedEntityState` (palette/part/hidden-part overrides from ObjDesc). The doll = "build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through that pipeline, draw it with a fixed camera + one distant light into a UI rect." Re-dress on `ObjDescEvent 0xF625` = rebuild the entity's overrides.
|
|
||||||
- **The new widget = `UiViewport`** (registers at dat Type `0xD`, leaf). It needs a **render-into-rect seam**: a Core `IUiViewportRenderer` interface implemented in App (Code-Structure Rule 2 — Core defines, App implements; never the reverse), invoked as a scissored single-entity 3D pass keyed to the widget's screen rect. The exact integration (after the world pass vs a UI overlay) is **DESIGN-OPEN — settle in the Slice 2 brainstorm** (deep-dive §7).
|
|
||||||
- Camera/light immediates, heading-toward-viewer (191.37°), idle animation, and the player-clone-vs-fresh-WorldEntity question are all in deep-dive §5d + §7 (some `UNVERIFIED` — decode the float immediates there).
|
|
||||||
|
|
||||||
## What's already in place (reuse, don't rebuild)
|
|
||||||
|
|
||||||
- **The mount:** `GameWindow.cs:~2208` pins `0x100001CD` (paperdoll) in the inventory frame; the importer pulls in the nested gmPaperDollUI (`0x21000024`) subtree (`InventoryFrameImportProbe.cs:25` = `PaperdollPanel`). The equip-slot elements are already imported + positioned.
|
|
||||||
- **`UiItemSlot`** (shared item widget) — occupancy-gated icon, `SpriteResolve`, drag payload/ghost, `DragAcceptSprite`/`DragRejectSprite`, `IsOpenContainer`/`Selected`/`CapacityFill` overlays. The equip slot is the single-cell case.
|
|
||||||
- **The drag-drop spine** (`IItemListDragHandler`, `UiRoot` BeginDrag/FinishDrag, `UiItemSlot` drag events) + the **optimistic-move machinery** (`ClientObjectTable.MoveItemOptimistic`/`ConfirmMove`/`RollbackMove` + the gapless-insert + the pending-count) — wield/unwield reuses this (wield = an equip-target drop handler; unwield = the shipped `PutItemInContainer` path).
|
|
||||||
- **`ClientObjectTable.CurrentlyEquippedLocation`** per item — the equipped-state source for Slice 1 (no new parse needed for MVP).
|
|
||||||
- **`IconComposer`** — item icons (incl. the `IsThePlayer` constant-backpack special; deep-dive notes the paperdoll icon path).
|
|
||||||
- **`EntitySpawnAdapter` / `AnimatedEntityState`** — the char render path Slice 2 reuses.
|
|
||||||
- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/part/texture/anim-part) — Slice 2's re-dress input.
|
|
||||||
|
|
||||||
## Open questions for the brainstorm(s)
|
|
||||||
|
|
||||||
- **Slice 1 empty-slot look without the doll** — transparent-to-panel vs a temporary placeholder (the doll is the real backdrop). The correction above means there's no silhouette sprite to use.
|
|
||||||
- **Equipped-icon source** — `CurrentlyEquippedLocation` (have it) is enough for MVP; the deep-dive §3c/§7 notes the richer `InventoryPlacement` equipped list (PlayerDescription equipped section) is **not surfaced** by `PlayerDescriptionParser` (partial). Only extend the parser if MVP needs priority/coverage resolution (overlapping armor/clothing) — likely Slice-2-or-later.
|
|
||||||
- **`GetAndWieldItem` placement/auto-slot** — does the EquipMask come purely from the drop-target slot (like the doll's `GetLocationInfoFromElementID`), and how does auto-wield (drop on the doll body, not a specific slot) pick the slot? holtburger `resolve_and_clear_slots` (§4a) is the reference.
|
|
||||||
- **The whole of Slice 2's UI↔3D seam** (deep-dive §7) — the heaviest design call; its own brainstorm.
|
|
||||||
- **`0x100001E0 = MissileAmmo 0x800000`** is LIKELY (corrupted decomp immediate, §7) — confirm by dump if the ammo slot matters for MVP.
|
|
||||||
|
|
||||||
## Acceptance / scope per slice
|
|
||||||
|
|
||||||
- **Slice 1:** equip slots show your equipped gear's icons in the right slots; drag a wieldable item onto a slot → it wields (`GetAndWieldItem 0x001A`, optimistic + rollback); drag a slot's item to the pack → it unwields. Build + full suite green; **visual gate** (F12 → equipped gear shows in the doll slots, drag-to-equip works). Divergence rows for any approximation. Retires the "generic blue border" gap.
|
|
||||||
- **Slice 2:** the 3D doll renders the re-dressed player clone behind the slots; empty slots show the doll body through them; equipping updates the doll live (via `ObjDescEvent`). Its own spec/plan; the `IUiViewportRenderer` seam is the crux.
|
|
||||||
|
|
||||||
## New-session prompt
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (tip `82ab0e0`; `main` is a clean ff ancestor). **READ FIRST:** `docs/research/2026-06-22-paperdoll-handoff.md`, then its "Read first" list (esp. the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` and `claude-memory/project_d2b_retail_ui.md`).
|
|
||||||
>
|
|
||||||
> **NEXT: Sub-phase C — the paperdoll, Slice 1 (equip slots), first.** Bind the ~25 mounted equip slots (under `0x100001CD` / gmPaperDollUI `0x21000024`) to live equipped-item data and make them drag-drop wield/unwield targets — a new `PaperdollController` (the `gmPaperDollUI::PostInit` analogue) that maps element-id → `EquipMask` (deep-dive §3a table), populates each slot's icon from the item whose `CurrentlyEquippedLocation == that slot's EquipMask`, and registers as the equip-slots' `IItemListDragHandler`. **Build the wire gap:** `GetAndWieldItem 0x001A` (`itemGuid + EquipMask`) — `InteractRequests.BuildGetAndWieldItem` + `WorldSession.SendGetAndWieldItem` (mirror `BuildPickUp`/`SendPutItemInContainer`); wield = optimistic + rollback (reuse `MoveItemOptimistic`); unwield = the shipped `PutItemInContainer 0x0019` path. **Carry the correction:** retail empty equip slots are TRANSPARENT (the doll shows through), NOT silhouettes — decide the doll-less empty-slot look in the brainstorm. **Slice 2 (the 3D doll `UiViewport` Type 0xD + the Core→App `IUiViewportRenderer` seam) is a SEPARATE, heavier cycle — do not start it in Slice 1.**
|
|
||||||
>
|
|
||||||
> Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow for Slice 1. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. Reuse the shipped `UiItemSlot` + drag-drop spine + optimistic-move machinery; don't rebuild them.
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
# Report — dense-town FPS attribution (Arwic, post-cells-fix) — 2026-06-23
|
|
||||||
|
|
||||||
**Mode:** report-only investigation (no edits, no diagnostic drops). Deliverable
|
|
||||||
is this doc + the chat verdict. Continues the handoff
|
|
||||||
`2026-06-23-dense-town-fps-deepdive-handoff.md`. The cells fix (29→75 fps) is
|
|
||||||
KEPT and confirmed intact.
|
|
||||||
|
|
||||||
**Method:** 7 parallel read-only subsystem readers (orchestration, punch/seal,
|
|
||||||
particles, portal-vis CPU, alloc/GC, pview-master, apparatus) + independent
|
|
||||||
verification reads of the 6 core files by the lead. Every claim below is cited
|
|
||||||
to `file:line` from source read in full. **No live measurement was taken** — the
|
|
||||||
decisive runs are listed in §6 for the user to drive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OUTCOME (FINAL — shipped 2026-06-24)
|
|
||||||
|
|
||||||
Dense Arwic: **75 → ~165 fps** (steady; ~130 at the absolute densest standing-still
|
|
||||||
view — over the 144 target except that one extreme). Two isolated, verified,
|
|
||||||
pixels-identical fixes (render-perf is not faithfulness-gated):
|
|
||||||
|
|
||||||
1. **Cell-object draw batching** (`290e731`) — `DrawCellObjectLists` collapsed N
|
|
||||||
per-cell `WbDrawDispatcher.Draw` calls into one cross-cell batched draw
|
|
||||||
(`cellobjects` 3.5 → ~0.4 ms).
|
|
||||||
2. **Cell-particle consolidation** (`9f51a4d`) — the per-cell `DrawCellParticles`
|
|
||||||
re-walk (O(cells×particles)) collapsed into one union pass; also fixed a latent
|
|
||||||
additive **double-draw** in multi-aperture cells.
|
|
||||||
|
|
||||||
Throwaway profiling apparatus stripped (`a9d06a6`). Build + full suite green.
|
|
||||||
|
|
||||||
**The handoff's framing was wrong, and is corrected here:** the dense town was
|
|
||||||
NEVER GPU-bound. The "~12 ms GPU" was a **glFinish self-measurement artifact** (the
|
|
||||||
profiler's own pipeline flushes inflated its number); the true GPU is ~0.5 ms
|
|
||||||
standing still. The frame is **~96 % CPU-bound**, and the cost scales with how many
|
|
||||||
buildings are in view.
|
|
||||||
|
|
||||||
**Remaining headroom — deliberately NOT pursued (user-approved finalize):** the last
|
|
||||||
~2.1 ms CPU is `WbDrawDispatcher`'s per-frame static-scenery rebuild (`ls.scenery`);
|
|
||||||
~2.1 ms GPU is terrain rasterization (`ls.terrain`). Both live in FROZEN,
|
|
||||||
load-bearing subsystems. The only way to fully cut scenery-CPU is a cross-frame
|
|
||||||
cache of the entity dispatcher — judged HIGH risk (regresses ALL entity rendering if
|
|
||||||
mis-keyed; the #53/#119/#128 bug class; thrashes on the eye's rest-jitter) for ~15
|
|
||||||
fps at one extreme view already over target. The safe micro-opts net only ~0.3 ms.
|
|
||||||
Full analysis: §0b + the dispatcher-investigation workflow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0a. MEASURED VERDICT — clean split, `ACDREAM_FPS_PROF=2` (2026-06-23, live Arwic)
|
|
||||||
|
|
||||||
The hypothesis below was **confirmed by direct measurement.** Added a diagnostic
|
|
||||||
`ACDREAM_FPS_PROF=2` mode (whole-frame `TimeElapsed` query, per-pass `glFinish`
|
|
||||||
DISABLED) and ran it live in Arwic:
|
|
||||||
|
|
||||||
| view | wall | cpuRender | **gpu** | present(wait) | vsync/msaa |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| facing AWAY from town | 6.7 ms (~149 fps) | 6.6 ms | **0.5 ms** | 0.1 ms | off / 4× |
|
|
||||||
| facing INTO town | ~13 ms (~70 fps) | ~13 ms | **0.5 ms** | 0.1 ms | off / 4× |
|
|
||||||
|
|
||||||
**The frame is ~96% CPU-render-bound. The GPU renders the entire dense town in
|
|
||||||
0.5 ms and is idle the rest of the frame.** `cpuRender ≈ wall`, `present ≈ 0.1 ms`,
|
|
||||||
`gpu = 0.5 ms` regardless of view. The handoff's "~12 ms GPU" was **entirely a
|
|
||||||
glFinish-serialization artifact** (§1). Turning the camera into the town doubles
|
|
||||||
`cpuRender` (6.6 → 13 ms) while `gpu` never moves — the cost scales with the number
|
|
||||||
of buildings in frustum, i.e. the per-building CPU work, not pixels.
|
|
||||||
|
|
||||||
**DEAD for good (GPU is 0.5 ms):** MSAA, fill, overdraw, fragment shaders, far draw
|
|
||||||
distance, any GPU-side lever. An MSAA=0 test is moot. **All fixes target CPU.**
|
|
||||||
|
|
||||||
The spikes (cpuRender max 30–43 ms → dips to ~25 fps) are consistent with gen-0 GC
|
|
||||||
from the ~5–8 k allocations/frame (§4); confirm with a gen-0 GC counter if needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0b. CPU SUB-PHASE BREAKDOWN — `[CPU-PHASE]`, live Arwic facing town (2026-06-23)
|
|
||||||
|
|
||||||
Added `[CPU-PHASE]` timers around each `DrawInside` phase (run under `=2`, so draw
|
|
||||||
phases = pure CPU submission). Steady-state, facing town (`update≈0`, `cpuRender`
|
|
||||||
~8 ms — the absolute varies 8–13 ms with view density; the RANKING is stable):
|
|
||||||
|
|
||||||
| phase | ms/frame | what |
|
|
||||||
|---|---|---|
|
|
||||||
| **cellobjects** | **3.3–4.5** | `DrawCellObjectLists` — per-cell entity/static draw + per-cell particle pass |
|
|
||||||
| **landscape** | **2.1–2.9** | sky (per-submesh) + terrain + scenery + late dynamics/particles/weather |
|
|
||||||
| **partition** | **0.7–3.2** | `InteriorEntityPartition.Partition` + `ViewconeCuller.Build` |
|
|
||||||
| **dynamics** | **0.6–1.3** | `DrawDynamicsLast` — dynamics draw + dynamics particles |
|
|
||||||
| prepare | 0.04–1.3 | `PrepareRenderBatches` |
|
|
||||||
| shells | 0.07–0.21 | the cells fix (cheap — working) |
|
|
||||||
| flood | 0.08–0.27 | the 48 portal floods |
|
|
||||||
| assemble | 0.03–0.24 | `ClipFrameAssembler` |
|
|
||||||
| portalmask | 0.06–0.09 | the 31 punch/seal fans |
|
|
||||||
|
|
||||||
**MEASUREMENT OVERTURNED THE STATIC RANKING (§2/§4/§5):**
|
|
||||||
- **Portal floods are NOT the cost** (`flood = 0.1 ms`). **H1's "48 floods" emphasis and
|
|
||||||
Tier C flood-caching are DEAD** — caching saves ~0.1 ms.
|
|
||||||
- **Punch/seal is NOT the cost** (`portalmask = 0.08 ms`). Tier B #5 batching saves nothing.
|
|
||||||
- **The clip-math alloc storm is NOT the steady cost** (flood+assemble ≈ 0.2 ms). It may
|
|
||||||
still drive the *spikes* via GC (separate), but it is not the 8–13 ms steady cost.
|
|
||||||
- **The cells fix works** (`shells = 0.1 ms`).
|
|
||||||
|
|
||||||
**REAL TARGET — per-cell / per-entity DRAW SUBMISSION:** `cellobjects + landscape +
|
|
||||||
dynamics ≈ 6.5 ms` + `partition ≈ 2 ms`. Common thread: `WbDrawDispatcher.Draw` is
|
|
||||||
called **once per visible cell** in `DrawCellObjectLists` (each orphaning 6 SSBOs via
|
|
||||||
`glBufferData` — `WbDrawDispatcher.cs:1521-1558`), plus per-cell `DrawCellParticles`
|
|
||||||
re-walks, plus un-batched per-submesh sky. Scales with visible cells/entities ⇒ the
|
|
||||||
facing-the-town cost. **Lesson: allocation COUNT ≠ CPU TIME; the per-phase CPU timer
|
|
||||||
is what found the real hotspot.**
|
|
||||||
|
|
||||||
**Revised fix priority (supersedes §5):**
|
|
||||||
1. **Batch per-cell entity/static draws** across cells into few `WbDrawDispatcher.Draw`
|
|
||||||
calls (the cell-shell fix, applied to cell OBJECTS) — targets `cellobjects` (~3.5 ms).
|
|
||||||
Pre-cull per-entity by viewcone, then one batched draw.
|
|
||||||
2. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of 6 `glBufferData`
|
|
||||||
orphans per call — compounds with #1 (helps `cellobjects`/`landscape`/`dynamics`).
|
|
||||||
3. **Consolidate particle `Draw` calls** (per-cell `DrawCellParticles` re-walks all
|
|
||||||
particles) — targets part of `cellobjects` + `landscape`.
|
|
||||||
4. **Batch sky submeshes** — targets `landscape`.
|
|
||||||
5. **Optimize `partition`** (`InteriorEntityPartition.Partition` + `ViewconeCuller.Build`,
|
|
||||||
~2 ms CPU/frame) — possibly cache/incremental.
|
|
||||||
|
|
||||||
Spikes (cpuRender max 30–43 ms; correlated `gpu`-max spikes to 300+ ms) coincide with
|
|
||||||
`landscape`/`cellobjects` jumps + GPU upload — likely streaming mesh upload hitches
|
|
||||||
(`_wbMeshAdapter.Tick`) and/or GC. Diagnose separately from the steady cost.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Verdict (TL;DR) — original static prediction, now MEASURED-CONFIRMED above
|
|
||||||
|
|
||||||
The handoff frames the remaining ~12 ms as "diffuse GPU cost." The static
|
|
||||||
evidence says that frame is **mis-attributed**: the 12 ms is a **glFinish-
|
|
||||||
serialized** number, and the actual GPU *rasterization* is tiny (terrain 0.3,
|
|
||||||
cells 0.2, entities 0.2 ms — the geometry genuinely is cheap, exactly your
|
|
||||||
intuition). The remaining cost is overwhelmingly **CPU-submission / OpenGL
|
|
||||||
driver-overhead / GC**, not GPU fill:
|
|
||||||
|
|
||||||
- **48 uncached portal floods per frame** (1 root + ~47 buildings), recomputed
|
|
||||||
from scratch every frame even when standing still, with **no caching**.
|
|
||||||
- **~5,000–8,000 short-lived heap allocations per frame** (the Sutherland-Hodgman
|
|
||||||
clip math alone is ~3–5 k) → gen-0 GC → the periodic spikes to 30–40 fps.
|
|
||||||
- **Hundreds of redundant GL state calls per frame**: 31 punch/seal fans each
|
|
||||||
doing a full state set+restore (~450–650 GL calls), `WbDrawDispatcher`
|
|
||||||
orphaning 6 SSBOs via `glBufferData` on every one of its 3–5 calls (~66–110
|
|
||||||
buffer reallocs), per-submesh sky draws, ~80–128 `glEnable/Disable(ClipDistance)`
|
|
||||||
toggles.
|
|
||||||
- The particle system is **re-walked ~22× per frame** (once per cell), each walk
|
|
||||||
allocating and each `Draw` doing `glBufferData` orphans — draw-count bound,
|
|
||||||
**not fill** (consistent with the resolution-independence observation).
|
|
||||||
|
|
||||||
**This is the classic OpenGL CPU-bound profile**, and it explains all three of
|
|
||||||
your observations at once: resolution-independent, scales with town density, and
|
|
||||||
a fast GPU can't help.
|
|
||||||
|
|
||||||
**The single blocking gap:** we cannot prove CPU-vs-GPU today because the whole-
|
|
||||||
frame `TimeElapsed` query and the per-pass `glFinish` are gated on the *same*
|
|
||||||
`ACDREAM_FPS_PROF=1` flag (`FrameProfiler.cs:72-73`). The decisive next step is to
|
|
||||||
decouple them (a ~5-line apparatus change) **or** capture a RenderDoc frame — both
|
|
||||||
give the honest split.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The measurement-trust problem (read this first)
|
|
||||||
|
|
||||||
`cpuRender` is a wall-clock stopwatch around the *entire* `OnRender`
|
|
||||||
(`FrameProfiler.cs:99,110`), so it **absorbs every glFinish stall**. When
|
|
||||||
`ACDREAM_FPS_PROF=1`:
|
|
||||||
|
|
||||||
- Each instrumented renderer calls `gl.Finish()` **twice** (before + after):
|
|
||||||
`EnvCellRenderer.cs:845,1050`; `ParticleRenderer.cs:128,181`;
|
|
||||||
`PortalDepthMaskRenderer.cs:214,319`; terrain hooks `GameWindow.cs:10799,10803`.
|
|
||||||
- Counting call frequency: ~62 finishes from punch/seal (31 fans × 2) + ~44 from
|
|
||||||
particles (22 batches × 2) + cells + terrain ≈ **150+ full pipeline flushes per
|
|
||||||
frame.**
|
|
||||||
- Each `glFinish` drains the GPU and blocks the CPU. The GPU then sits **idle**
|
|
||||||
between passes waiting for the CPU to issue the next finish-bracketed batch —
|
|
||||||
and that idle time falls *inside* the whole-frame `TimeElapsed` window.
|
|
||||||
|
|
||||||
**Consequence:** the `gpu ≈ 12 ms` total is *serialized* GPU time (rasterization +
|
|
||||||
inter-pass idle), not *pipelined* GPU time. The per-pass `[PASS-GPU]` absolutes
|
|
||||||
(particles 3.1, punchseal 2.9) are inflated by their own finishes and are upper
|
|
||||||
bounds, not real costs. **No honest CPU/GPU split exists in the current
|
|
||||||
apparatus.** (Independently re-derived by two of the seven readers.)
|
|
||||||
|
|
||||||
`vsync` is off by default (`GameWindow.cs:998`), so vsync-quantization is unlikely
|
|
||||||
but must be confirmed from the printed `vsync=` field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Ranked hypotheses
|
|
||||||
|
|
||||||
### H1 (leading) — The frame is CPU-submission / driver-overhead bound, not GPU-fill bound
|
|
||||||
- **For:** attributed GPU rasterization is tiny (terrain 0.31, cells 0.23,
|
|
||||||
entities 0.22 ms — all measured in the handoff). Resolution-independent (resize
|
|
||||||
didn't move FPS). Scales with building count. The CPU side has clear O(buildings)
|
|
||||||
+ O(cells) structure issuing hundreds of small state-changing GL calls. The
|
|
||||||
cells fix worked precisely because it collapsed 94 heavy state-setting `Render`
|
|
||||||
submissions into 1 — a *submission* win.
|
|
||||||
- **Against:** not yet measured cleanly (glFinish contamination, §1). The resize
|
|
||||||
test was on a tainted `FAR_RADIUS=4` build (handoff caveat).
|
|
||||||
- **Falsify:** RUN 1 (§6) with glFinish decoupled — if `gpu p50 ≈ wall p50` and
|
|
||||||
`cpuRender ≪ wall`, H1 is **wrong** and it's genuinely GPU-bound. If
|
|
||||||
`cpuRender ≈ wall` and `gpu ≪ wall`, H1 confirmed.
|
|
||||||
|
|
||||||
### H2 — Frame spikes (cpuRender p95 ~30 ms, dips to 30–40 fps) are gen-0 GC pauses
|
|
||||||
- **For:** ~5,000–8,000 short-lived heap objects/frame, almost all in the
|
|
||||||
portal-flood + clip + assemble path (§4). At 75 fps that's 375k–600k allocs/s →
|
|
||||||
frequent gen-0 STW collections. Retail uses fixed static scratch buffers; this
|
|
||||||
alloc storm is "purely a .NET port artifact with no retail equivalent."
|
|
||||||
- **Against:** could partly be the streaming/upload hitch (`_wbMeshAdapter.Tick`,
|
|
||||||
`GameWindow.cs:8427`) on cell load. Both can be true.
|
|
||||||
- **Falsify:** watch the .NET gen-0 GC counter / allocation rate during a stutter,
|
|
||||||
or correlate spikes with `dotnet-counters`. If allocs/frame is low, GC isn't it.
|
|
||||||
|
|
||||||
### H3 — Particles are draw-count/state bound (~22 fragmented batches), not fill
|
|
||||||
- **For:** `ParticleRenderer.Draw` is called from **~11 call sites**, including
|
|
||||||
**per-cell** `DrawCellParticles` (~31×); each call re-walks the *entire* live
|
|
||||||
particle set via `EnumerateLive` (`ParticleRenderer.cs:196`), sorts it, and each
|
|
||||||
batch does a `glBufferData` orphan (`:278`). Trivial fragment shader, depth-write
|
|
||||||
off. Resolution-independent ⇒ not fill.
|
|
||||||
- **Against:** additive emitters have real overdraw (no depth write); secondary.
|
|
||||||
- **Falsify:** RUN 3 resolution sweep — if particle GPU is flat with resolution,
|
|
||||||
not fill. RenderDoc quad-overdraw overlay.
|
|
||||||
|
|
||||||
### H4 — Punch/seal (31 fans) is CPU state-churn, NOT 2.9 ms of GPU
|
|
||||||
- **For:** each `DrawDepthFan` is a depth-only, color-masked, ≤32-vert fan — GPU
|
|
||||||
cost is negligible. The 2.9 ms is 62 glFinish stalls. The *real* cost is the
|
|
||||||
full per-fan state set+restore (`PortalDepthMaskRenderer.cs:229-313`) — ~450–650
|
|
||||||
GL calls/frame, including a `UseProgram`/`UseProgram(0)` round-trip and a
|
|
||||||
`uViewProjection` re-upload per fan even though all fans share one matrix.
|
|
||||||
- **Against:** none material.
|
|
||||||
- **Falsify:** RUN 2 `[PASS-GPU]` with per-pass `TimeElapsed` (no glFinish) — fan
|
|
||||||
GPU will read ≪ 2.9 ms. RenderDoc per-draw time.
|
|
||||||
|
|
||||||
### H5 — `WbDrawDispatcher` SSBO orphaning is a hidden chunk of the "unattributed 5.5 ms"
|
|
||||||
- **For:** every `Draw()` re-uploads 6 SSBOs via `glBufferData(DynamicDraw)`
|
|
||||||
(orphan+realloc, not `BufferSubData`) — `WbDrawDispatcher.cs:1521-1558` — ×3–5
|
|
||||||
calls/frame = ~66–110 buffer reallocs. Plus per-transparent-cell `EnvCell.Render`
|
|
||||||
repeats all 6 SSBO uploads for a 1-cell instance set (`EnvCellRenderer.cs:1281-1367`).
|
|
||||||
- **Against:** the dispatcher has its own `ACDREAM_WB_DIAG` GPU timer; cross-check
|
|
||||||
it (run separately — it nests illegally with FPS_PROF, `FrameProfiler.cs:31`).
|
|
||||||
- **Falsify:** RenderDoc; or `ACDREAM_WB_DIAG=1` in isolation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. What's ruled out (do not re-chase)
|
|
||||||
|
|
||||||
- **Distance-degrade / LOD / triangle count** — dead (handoff §3, entity GPU 0.22 ms).
|
|
||||||
- **MSAA / fill / overdraw of opaque geometry** — resolution-independent; re-verify
|
|
||||||
cleanly in RUN 3 but the static evidence agrees (cheap fragment work everywhere).
|
|
||||||
- **Update thread** — `update = 0.1 ms`.
|
|
||||||
- **Terrain per-slice redraw** — the apparatus reader flagged `_terrain.Draw` being
|
|
||||||
inside a per-slice loop (`GameWindow.cs:10795-10803`) as a HIGH suspect, **but
|
|
||||||
the handoff already measured terrain at 1 slice / 0.31 ms.** At *outdoor* Arwic
|
|
||||||
there is one full-screen outside slice, so terrain draws once. The per-slice
|
|
||||||
multiplier only bites for **interior roots with multiple doorway slices** — a
|
|
||||||
separate, non-Arwic concern. **NOT an Arwic FPS lever.**
|
|
||||||
- **The cells fix** — intact and correct (`RetailPViewRenderer.cs:664-701`). Do not
|
|
||||||
touch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Per-subsystem cost ledger (static, cited)
|
|
||||||
|
|
||||||
**Portal visibility (CPU + GC — the biggest structural cost)**
|
|
||||||
- 48 BFS floods/frame, **no frame-to-frame caching**, recomputed when stationary
|
|
||||||
(`RetailPViewRenderer.cs:64-83,228-235`; rebuilt `GameWindow.cs:8752`).
|
|
||||||
- Per flood allocates `PortalVisibilityFrame` + 2 `HashSet` + `Dictionary` +
|
|
||||||
`uint[128]` before processing a portal (`PortalVisibilityBuilder.cs:126-227`).
|
|
||||||
- **`PortalProjection` clip math is the #1 GC source: ~3,000–5,000 short-lived
|
|
||||||
`List<Vector4>`/`List<Vector2>`/`ToArray()` per frame** — `ProjectToClip:95`,
|
|
||||||
`ClipToRegion:127`, `ClipHomogeneousEdge:216`, `MergeSubPixelVertices:184`.
|
|
||||||
Retail used a **static two-buffer swap on a fixed vertex array** — so pooling is
|
|
||||||
*more* retail-faithful, not less.
|
|
||||||
- `CellTodoList.Insert` is O(N) (`:1006-1016`); `GetRange` allocates (`:254,583`);
|
|
||||||
`CanonicalKey` allocates a `StringBuilder` per polygon (`PortalView.cs:255`).
|
|
||||||
|
|
||||||
**ClipFrameAssembler (CPU + GC)** — ~800–1,200 heap objects/frame (4 dicts + 95
|
|
||||||
lists + 94 `CellView` + 94 `int[]` + per-polygon `Vector4[]`), all discarded each
|
|
||||||
frame (`ClipFrameAssembler.cs:85-219`). No retail counterpart.
|
|
||||||
|
|
||||||
**Punch/seal (CPU state churn)** — 31 fans × full state set+restore = ~450–650 GL
|
|
||||||
calls/frame; per-fan `UseProgram` round-trip + `uViewProjection` re-upload
|
|
||||||
(`PortalDepthMaskRenderer.cs:229-313`). Retail does **not** restore state per
|
|
||||||
polygon (it leaves state installed for the next poly); our self-contained-state
|
|
||||||
contract is the overhead. The two-pass stencil is an acdream-only #117 addition
|
|
||||||
(retail relied on painter's order). Zero managed alloc inside `DrawDepthFan`
|
|
||||||
(good — `_scratch` field + `stackalloc`).
|
|
||||||
|
|
||||||
**Particles (CPU draw-count + GC)** — ~11 `Draw` call sites, per-cell
|
|
||||||
`DrawCellParticles` re-walks all live particles ×~31 (`ParticleRenderer.cs:196`);
|
|
||||||
`new List<ParticleDraw>` (`:195`) + `new List<ParticleInstance>(64)` (`:152`) per
|
|
||||||
call; per-batch `glBufferData` orphan (`:278`). Draw-count bound.
|
|
||||||
|
|
||||||
**EnvCellRenderer (CPU + GC + redundant uploads)** — opaque batched (the fix), but
|
|
||||||
**transparent stays per-cell**: each transparent cell repeats all 6 SSBO uploads
|
|
||||||
for a 1-cell set (`EnvCellRenderer.cs:1281-1367`). `_cellLightSetCache.Clear()` at
|
|
||||||
`:1141` forces **188–282 `int[]` re-allocations/frame** (94 cells × 2–3 passes);
|
|
||||||
the light sets are camera-independent and stable within a frame. `Render` allocates
|
|
||||||
4 collections per call (`:904,905,927,928`). `CellHasTransparent` is an O(gfx×batch)
|
|
||||||
walk with no cached result.
|
|
||||||
|
|
||||||
**Orchestration (CPU driver overhead)** — sky drawn per-submesh, no batching
|
|
||||||
(~8–15 `DrawElements` + per-submesh `BlendFunc`/4×`SetFloat`, ×2 passes/frame,
|
|
||||||
`SkyRenderer.cs:219-429`); ~80–128 `glEnable/Disable(ClipDistance0..7)` toggles/frame
|
|
||||||
(`MaxPlanes=8`); `PrepareRenderBatches` runs `Parallel.ForEach` **on the render
|
|
||||||
thread**, blocking `OnRender` (`EnvCellRenderer.cs:642`); `ParseEnvFloat` →
|
|
||||||
`Environment.GetEnvironmentVariable` ×2/frame in the hot path
|
|
||||||
(`GameWindow.cs:8639-8641`); `new[]{entry}` per `DrawEntityBucket` (~50–100/frame,
|
|
||||||
`RetailPViewRenderer.cs:943`); `new[]{NoClipSlice}` per slot-less cell
|
|
||||||
(~125/frame, `:918` — trivially a static readonly array).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fix leads, ranked by (impact × retail-faithfulness)
|
|
||||||
|
|
||||||
Brainstorm before any render change (handoff lesson — rushed render changes were
|
|
||||||
reverted). These are *sketches for the implementation session*, not commitments.
|
|
||||||
|
|
||||||
**Tier A — strictly retail-faithful (matches retail's static-scratch model), high impact, low risk:**
|
|
||||||
1. **Pool the `PortalProjection` clip buffers** (`ArrayPool`/double-buffer swap).
|
|
||||||
Kills the single largest GC source (~3–5 k allocs/frame). Output contract
|
|
||||||
unchanged. → directly targets H2 spikes.
|
|
||||||
2. **Pool `ClipFrameAssembler` + `PortalVisibilityBuilder` BFS scratch** (Reset/
|
|
||||||
Clear instead of `new`). ~1 k allocs/frame gone.
|
|
||||||
3. **Move `_cellLightSetCache.Clear()` out of `RenderModernMDIInternal`** to once-
|
|
||||||
per-frame. Removes 188–282 `int[]`/frame. Light sets are frame-stable.
|
|
||||||
4. **Static readonly `NoClipSlice[]`** + pool `DrawEntityBucket`'s `new[]{entry}`.
|
|
||||||
|
|
||||||
**Tier B — internal GL batching (the cells-fix pattern, no visual change), high impact:**
|
|
||||||
5. **Batch punch/seal**: set state ONCE before the fan loop, upload `uViewProjection`
|
|
||||||
ONCE, merge all fan vertices into one VBO + draw with offsets. Drop the per-fan
|
|
||||||
`UseProgram(0)`. (Retail also drew per-poly but with cheap D3D state blocks; our
|
|
||||||
GL program-rebind/uniform-upload per fan is the cost.)
|
|
||||||
6. **Batch transparent EnvCell shells** like opaque (sort instances far→near, one
|
|
||||||
`Render`). Removes N_transparent × 6-SSBO re-uploads.
|
|
||||||
7. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of
|
|
||||||
`glBufferData(DynamicDraw)` orphan per call.
|
|
||||||
8. **Consolidate particle `Draw` to ≤3 calls/frame** (one per `RenderPass`),
|
|
||||||
pre-partition emitters by pass, single `BufferSubData` per `Draw`; additive in a
|
|
||||||
separate order-independent group.
|
|
||||||
9. **Batch sky submeshes** (shared VBO / sort by blend mode).
|
|
||||||
|
|
||||||
**Tier C — acceleration that diverges from retail (needs explicit brainstorm + a
|
|
||||||
divergence-register row):**
|
|
||||||
10. **Cache per-building portal floods** keyed on (buildingId, quantized camera
|
|
||||||
pose). Eliminates ~47 of 48 floods/frame when stationary. **But retail
|
|
||||||
recomputes per frame from the BSP walk** — this is a memoization acdream adds,
|
|
||||||
not a faithful match. Pure-function caching is defensible, but it changes the
|
|
||||||
"recompute every frame" structure and must be invalidated on camera move + cell
|
|
||||||
load/unload. Lower priority than Tier A (which kills the GC without diverging).
|
|
||||||
11. **Move `PrepareRenderBatches` off the render thread** (double-buffer in
|
|
||||||
`OnUpdate`). One-frame visibility latency.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Decisive measurement plan (user-driven; report-only until approved)
|
|
||||||
|
|
||||||
**Apparatus gap to fix first (one small change, then measure):** add a second flag
|
|
||||||
(e.g. `ACDREAM_FPS_PROF=2` or `ACDREAM_FPS_NOFINISH=1`) that keeps the whole-frame
|
|
||||||
`TimeElapsed` query but **disables the per-pass `glFinish`** — and/or convert the
|
|
||||||
per-pass timers to **non-nested per-pass `TimeElapsed` queries** (the
|
|
||||||
`WbDrawDispatcher` `ACDREAM_WB_DIAG` queries at `WbDrawDispatcher.cs:1642,1670` are
|
|
||||||
the working template — no glFinish, no inflation). This is diagnostic-only.
|
|
||||||
|
|
||||||
- **RUN 1 — honest split (glFinish off, frame query on), Arwic, stand still 10 s.**
|
|
||||||
`gpu≈wall` ⇒ GPU-bound (go to RUN 4). `cpuRender≈wall, gpu≪wall` ⇒ **CPU-bound
|
|
||||||
(H1)**. `present(wait)≈wall` ⇒ vsync/swap (check `vsync=`).
|
|
||||||
- **RUN 2 — per-pass `[PASS-GPU]` via TimeElapsed (no glFinish).** Honest
|
|
||||||
particles / punchseal / cells / terrain ms + calls/frame.
|
|
||||||
- **RUN 3 — resolution sweep (720p → 1080p) on a CLEAN build.** GPU scales ~2.25× ⇒
|
|
||||||
fill-bound; flat ⇒ vertex/CPU-bound. (Re-verifies the tainted resize test.)
|
|
||||||
- **RUN 4 — RenderDoc Arwic frame capture.** Exact per-draw GPU, total draw count
|
|
||||||
(>200/frame at 720p = batching problem), quad-overdraw overlay (punch fans over
|
|
||||||
the viewport show as red), per-pass timeline, MSAA-resolve cost.
|
|
||||||
|
|
||||||
Confirm/refute: H1 ⇐ RUN 1; H3/H4 ⇐ RUN 2/3/4; H5 ⇐ `ACDREAM_WB_DIAG=1` solo +
|
|
||||||
RenderDoc; H2 ⇐ gen-0 GC counter during a stutter.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. What this is NOT
|
|
||||||
|
|
||||||
- **NOT a GPU fill / shading / MSAA problem** — the GPU rasterizes this geometry in
|
|
||||||
a couple of ms; the geometry is cheap (your intuition is correct).
|
|
||||||
- **NOT a terrain per-slice redraw at Arwic** — outdoor = 1 slice, terrain draws
|
|
||||||
once (0.31 ms measured). That lead applies only to multi-slice interior roots.
|
|
||||||
- **NOT a single 24 ms lever** — it's death by a thousand CPU/driver/GC cuts; the
|
|
||||||
win is the *sum* of the Tier A+B fixes.
|
|
||||||
- **NOT fixed by the cells batching alone** — that solved the one big GPU
|
|
||||||
submission sink; the CPU flood + GC + state churn remained underneath.
|
|
||||||
- **The 12 ms `gpu` number is NOT trustworthy as "GPU rasterization time"** — it's
|
|
||||||
glFinish-serialized. Re-measure honestly before drawing conclusions.
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
# Handoff — dense-town FPS deep-dive (push past 75 fps) — 2026-06-23
|
|
||||||
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree:
|
|
||||||
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
|
|
||||||
**References / named-retail / WorldBuilder** live in the MAIN repo
|
|
||||||
`C:\Users\erikn\source\repos\acdream\` (read via absolute path; NOT in the worktree).
|
|
||||||
|
|
||||||
> **Read with fresh eyes.** This session **shipped a real, verified fix** (EnvCell
|
|
||||||
> shell-draw batching: dense-town Arwic **29 → 75 fps, 2.6×**) — KEEP IT. But the
|
|
||||||
> user finds 75 fps in a dense town on modern hardware **unacceptable** ("a modern
|
|
||||||
> GPU + OpenGL should chew through this") and wants a **deep-dive to find the next
|
|
||||||
> root cause and push FPS much higher.** This is not "ship it"; this is "keep
|
|
||||||
> hunting." The remaining cost is **diffuse** (no single big lever left) — so the
|
|
||||||
> deep-dive needs a *better* instrument than this session's glFinish hack. **Strong
|
|
||||||
> recommendation: capture a frame with RenderDoc** (the right tool for "where does
|
|
||||||
> the GPU time actually go, per draw").
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. TL;DR
|
|
||||||
|
|
||||||
1. **SHIPPED + committed (do NOT revert):** EnvCell shell batching. Root cause was
|
|
||||||
`EnvCellRenderer.Render` called **~94×/frame** (once per visible/look-in cell ×
|
|
||||||
opaque+transparent) = **24.75 ms = 75 % of the GPU frame** at Arwic. Fixed by
|
|
||||||
batching the opaque pass into ONE `Render(Opaque, allCells)` + skipping the
|
|
||||||
transparent `Render` for opaque-only cells. cells: **94 calls/24.75 ms → 1 call/
|
|
||||||
0.37 ms**. Frame **34 ms/29 fps → ~13 ms/75 fps**. Visually verified (no missing
|
|
||||||
walls, transparency + look-ins correct).
|
|
||||||
2. **STILL OPEN (the user's ask):** 75 fps is unacceptable. The remaining ~12–13 ms
|
|
||||||
is **diffuse** — particles ~3 ms, aperture punch/seal ~3 ms, ~5.5 ms scattered
|
|
||||||
(sky/entities/clear/weather), plus periodic frame **spikes** to 30–40 ms. No
|
|
||||||
second 24 ms sink. **Deep-dive to find why a modern GPU needs ~12 ms for a dense
|
|
||||||
AC town at 720p, and push FPS much higher.**
|
|
||||||
3. **The handoff's original theory (distance-degrade / LOD) is DEAD** — refuted by
|
|
||||||
measurement (entity-draw GPU is 0.22 ms; it was never triangle/detail-bound).
|
|
||||||
Do not re-pursue distance-degrade.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What SHIPPED this session (commits on the branch, newest→oldest)
|
|
||||||
|
|
||||||
| SHA | What |
|
|
||||||
|---|---|
|
|
||||||
| `376f4e6` | chore(diag): particle + punch/seal glFinish timers [throwaway] |
|
|
||||||
| `8067d3b` | **perf(pview): batch EnvCell look-in shell opaque pass** (interior roots) |
|
|
||||||
| `3af7d00` | **perf(pview): batch EnvCell shell opaque + skip empty transparent** (the Arwic win) |
|
|
||||||
| `f72f7ce` | feat(envcell): `CellHasTransparent` predicate (part of the fix, NOT apparatus) |
|
|
||||||
| `e27923b` | chore(diag): FPS profiling apparatus [throwaway] |
|
|
||||||
| `fd9354f` | docs: implementation plan |
|
|
||||||
| `fcf60d8` | docs: design spec |
|
|
||||||
|
|
||||||
(Prior session, KEEP: `536f1c0` `_datLock` fix.)
|
|
||||||
|
|
||||||
**The fix (KEEP):** `RetailPViewRenderer.DrawEnvCellShells` + `DrawBuildingLookIns`
|
|
||||||
no longer call `EnvCellRenderer.Render` per-cell. Opaque shells batch into one
|
|
||||||
`Render(Opaque, allCells)` (z-buffer order; lighting is per-instance, `CellId`-keyed
|
|
||||||
SSBO at `EnvCellRenderer.cs` `RenderModernMDIInternal` ~`:1324` → safe to batch).
|
|
||||||
Transparent stays per-cell far→near but skips opaque-only cells via
|
|
||||||
`EnvCellRenderer.CellHasTransparent`. Spec/plan: `docs/superpowers/specs|plans/
|
|
||||||
2026-06-23-envcell-shell-batching-*.md`. **Plan Task 4 (final verify) + Task 5
|
|
||||||
(strip apparatus) are NOT done** — apparatus kept for the deep-dive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The apparatus (`ACDREAM_FPS_PROF=1`) — committed, throwaway, STRIP at the end
|
|
||||||
|
|
||||||
- **`src/AcDream.App/Rendering/FrameProfiler.cs`** — per-frame split, printed `[FPS-PROF]`
|
|
||||||
every ~1 s: `wall` (real frame period, p50/p95/min/max), `update` (OnUpdate CPU),
|
|
||||||
`cpuRender` (OnRender CPU), `present(wait)` (= wall − update − cpuRender), `gpu`
|
|
||||||
(whole-frame `TimeElapsed` query — **the reliable total**). Plus `[PASS-GPU]` —
|
|
||||||
per-renderer **glFinish-bracketed** GPU via `AddRendererGpu(name, ms)`: `cells`,
|
|
||||||
`terrain`, `particles`, `punchseal`, each with `ms/frame` + `calls/frame`.
|
|
||||||
- **Hooks:** `GameWindow.OnRender` BeginFrame/EndFrame + `OnUpdate` `MarkUpdateStart`
|
|
||||||
+ terrain glFinish (in `DrawRetailPViewLandscapeSlice`); `EnvCellRenderer.Render`
|
|
||||||
glFinish try/finally; `ParticleRenderer.Draw` glFinish; `PortalDepthMaskRenderer.
|
|
||||||
DrawDepthFan` glFinish. All gated on `FrameProfiler.PassGpuEnabled`.
|
|
||||||
- **`tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs`** — throwaway
|
|
||||||
dat probe (degrade-coverage; was for the dead distance-degrade theory).
|
|
||||||
- **⚠ glFinish CAVEAT:** the per-renderer glFinish **serializes CPU↔GPU and inflates**
|
|
||||||
the per-pass absolutes (and folds GPU wait into `cpuRender`). Use `[PASS-GPU]` for
|
|
||||||
**relative** attribution only; trust the whole-frame `gpu` for the total. For
|
|
||||||
accurate per-draw GPU, use **RenderDoc** (see §5).
|
|
||||||
- **STRIP everything** (plan Task 5) when the FPS work fully lands: `FrameProfiler.cs`,
|
|
||||||
the GameWindow hooks/fields (`_fpsProf`/`_frameProfiler`/`_msaaSamples`), the terrain
|
|
||||||
glFinish, the EnvCell/Particle/PortalDepthMask glFinish brackets (KEEP
|
|
||||||
`CellHasTransparent` — it's the fix), `DegradeCoverageProbeTests.cs`, and the
|
|
||||||
worktree-root `fps-*.log` / `wbdiag.log` / `degrade-probe.log`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The investigation — what's RULED OUT (do not re-investigate)
|
|
||||||
|
|
||||||
Measured live in Arwic (the dense-town test bed). Frame was 29 fps / 34 ms pre-fix.
|
|
||||||
|
|
||||||
| Suspect | Verdict + evidence |
|
|
||||||
|---|---|
|
|
||||||
| **Distance-degrade / LOD / triangle count** (the prior handoff's theory) | ❌ DEAD — entity-draw GPU is **0.22 ms**; never detail-bound. (Degrade-coverage data: 92–96 % of scenery HAS degrade tables, but trees hide at 200 m+ and degrade only saves triangles, which aren't the cost.) |
|
|
||||||
| **MSAA / fill / overdraw / shading** | ❌ **Resolution-independent** — resizing the window tiny did NOT change FPS; MSAA-off (`ACDREAM_MSAA_SAMPLES=0`) didn't help. ⚠ the resize test was run on a `FAR_RADIUS=4` build that had side-effects — **RE-VERIFY resolution-dependence on a CLEAN build in the deep-dive** before fully trusting "not fill". |
|
|
||||||
| **Update thread** (physics/anim/net/apply) | ❌ `update = 0.1 ms`. |
|
|
||||||
| **Far terrain draw distance** | ❌ `ACDREAM_FAR_RADIUS=4` didn't help (and slowed things — streaming side-effects; that build's numbers are suspect). |
|
|
||||||
| **Terrain draw** | ❌ 1 slice / 0.3 ms. |
|
|
||||||
| **Entity leak** | ❌ modest (`esg≈288`), entity GPU 0.22 ms regardless. |
|
|
||||||
| **EnvCell per-cell Render (94×)** | ✅ WAS the cause — **FIXED this session.** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The diffuse remainder — the deep-dive target
|
|
||||||
|
|
||||||
Arwic, **after** the cells fix, whole-frame `gpu ≈ 12 ms` (profiler on; ~13 ms /
|
|
||||||
75 fps profiler-off). glFinish-inflated per-pass (upper bounds):
|
|
||||||
|
|
||||||
| Pass | GPU (inflated) | calls/frame | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| cells | 0.23 ms | 1 | ✅ fixed |
|
|
||||||
| terrain | 0.31 ms | 1 | cheap |
|
|
||||||
| **particles** | ~3.1 ms | **22 batches** | additive blend; overdraw? |
|
|
||||||
| **punch/seal** (`DrawDepthFan`) | ~2.9 ms | **31 fans** | per-fan full GL-state setup (smells like the cells issue) |
|
|
||||||
| **UNATTRIBUTED** | **~5.5 ms** | — | sky, entities (0.22), depth clear, weather, MDI-dispatch overhead, + glFinish inflation |
|
|
||||||
|
|
||||||
Plus **frame SPIKES** (separate from the steady cost): `cpuRender` p95 ~30 ms,
|
|
||||||
`present(wait)` p95 ~18 ms → periodic dips to "30 low" fps (stutter). Likely GC or a
|
|
||||||
periodic streaming/upload hitch. May matter more for *perceived* smoothness.
|
|
||||||
|
|
||||||
**Open questions for the deep-dive (ranked):**
|
|
||||||
1. **Attribute the ~5.5 ms unattributed GPU.** Instrument sky / weather / the depth
|
|
||||||
clear / the entity MDI, OR (better) RenderDoc. Is one of them secretly big?
|
|
||||||
2. **Separate CPU from GPU cleanly.** The glFinish folds GPU into `cpuRender`. Run
|
|
||||||
with the per-renderer glFinish **disabled** (whole-frame `gpu` query only) to get
|
|
||||||
the honest `cpuRender` vs `gpu` vs `present` split. If `cpuRender` is high, suspect
|
|
||||||
the **CPU side**: `PortalVisibilityBuilder` flooding ~47 nearby buildings in
|
|
||||||
`MergeNearbyBuildingFloods` (the per-frame flood/walk for a dense town could be
|
|
||||||
real CPU). That was never isolated.
|
|
||||||
3. **particles (~3 ms, 22 batches):** additive overdraw vs draw-count. Reconcile with
|
|
||||||
"resolution-independent" (re-verify the resize test cleanly). If overdraw, reducing
|
|
||||||
particle fill / count helps; if draw-count, batch the 22.
|
|
||||||
4. **punch/seal (31 fans):** are we punching/sealing too many apertures for an outdoor
|
|
||||||
root? Each `DrawDepthFan` does a full GL-state set/restore (like the per-cell
|
|
||||||
Render that we just fixed) — likely **batchable** (set state once, draw N fans).
|
|
||||||
Check what retail does (decomp: `DrawPortalPolyInternal`, the seal/punch order).
|
|
||||||
5. **Frame spikes:** GC (allocations per frame — the pview path allocates
|
|
||||||
`new CellView()`/`new Vector4[]` per aperture in `DrawBuildingLookIns`, `new[]`
|
|
||||||
tuples per slice) or streaming. A GC/alloc audit of the per-frame render path is
|
|
||||||
worthwhile.
|
|
||||||
6. **The meta-question:** even fully attributed, why ~12 ms GPU for this geometry on
|
|
||||||
modern HW? Look for state thrashing / many small draws / the 2-pass translucency /
|
|
||||||
redundant per-frame uploads. The user's "something is structurally off" thesis
|
|
||||||
still stands for the remaining 12 ms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. RECOMMENDED next-session method
|
|
||||||
|
|
||||||
- **RenderDoc frame capture in Arwic** is the right tool (CLAUDE.md: rendering perf →
|
|
||||||
RenderDoc). It gives exact per-draw GPU time, draw count, overdraw, and state
|
|
||||||
changes WITHOUT the glFinish distortion. This will attribute the 12 ms far better
|
|
||||||
than the apparatus. The user is on Windows with a modern GPU. Start here.
|
|
||||||
- Keep the `[FPS-PROF]`/`[PASS-GPU]` apparatus as a cheap secondary check, but trust
|
|
||||||
RenderDoc for absolutes. Consider switching the per-pass timers from glFinish to
|
|
||||||
**non-nested per-pass `TimeElapsed` queries** (no glFinish → no inflation; sequential
|
|
||||||
passes don't nest) if you want accurate in-engine per-pass numbers without RenderDoc.
|
|
||||||
- Measurement discipline (this session's lessons): profiler-OFF for the true FPS;
|
|
||||||
constant-view (stand still) for stable per-pass reads; re-verify the resize/
|
|
||||||
resolution test on a CLEAN build; the user drives the client lifecycle (graceful
|
|
||||||
`CloseMainWindow` before any rebuild — the DLL is locked while it runs).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Repro / launch
|
|
||||||
|
|
||||||
User: `notan` / `MittSnus81!`. Test bed: **Arwic** surface town (the user can get
|
|
||||||
there; dense buildings → the cost). Build Release; the user runs + watches FPS.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
|
||||||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
|
||||||
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
|
|
||||||
$env:ACDREAM_FPS_PROF="1" # [FPS-PROF]+[PASS-GPU]; OMIT for the true FPS the user sees
|
|
||||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log"
|
|
||||||
```
|
|
||||||
Title bar shows `fps | ms` (vsync off by default). `[FPS-PROF]`/`[PASS-GPU]` flush
|
|
||||||
~1 s. Logs are UTF-16 (Tee-Object) — read via PowerShell `Get-Content | Select-String`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Do-not-retry / lessons
|
|
||||||
- The cells fix is correct + verified — do NOT revert. Both phases shipped.
|
|
||||||
- Distance-degrade is NOT the cause — dead lead, don't re-pursue.
|
|
||||||
- glFinish per-call inflates per-pass absolutes — relative only; whole-frame `gpu`
|
|
||||||
query is the reliable total; RenderDoc for true per-draw.
|
|
||||||
- The `FAR_RADIUS=4` build had streaming side-effects — its numbers (incl. the resize
|
|
||||||
test) are suspect; re-verify on a clean build.
|
|
||||||
- Don't declare FPS "fixed" on a sub-metric — only on the total frame time the user
|
|
||||||
sees + user eyes (the prior session's mistake; honored this session).
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
# Handoff — FPS in dense towns = MISSING DISTANCE-DEGRADE (port it) — 2026-06-23
|
|
||||||
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree:
|
|
||||||
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
|
|
||||||
**Working tree:** clean. Everything below is committed on the branch.
|
|
||||||
**References / named-retail / WorldBuilder** live in the MAIN repo
|
|
||||||
`C:\Users\erikn\source\repos\acdream\` (read via absolute path; they are NOT in the worktree).
|
|
||||||
|
|
||||||
> **Read with fresh eyes.** This session SHIPPED a real, verified fix (the `_datLock`
|
|
||||||
> contention that caused the 30↔200 *swing*) — keep it, do not revisit it. But that fix
|
|
||||||
> did NOT solve the user's actual pain: **sustained ~30 FPS in dense towns (Fort Tethana),
|
|
||||||
> and it changes with view direction.** That second symptom was then root-caused, end to
|
|
||||||
> end, to a different cause: **acdream has no distance-based LOD/degrade — it draws every
|
|
||||||
> object in the frustum at full detail at any distance.** Retail degrades then hides distant
|
|
||||||
> objects. **The next session's job: port retail's per-frame distance-degrade.** The data is
|
|
||||||
> already in our dat tables; only the per-frame distance decision is missing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. TL;DR
|
|
||||||
|
|
||||||
1. **DONE + committed (do not touch):** the `_datLock`-contention fix. The streaming worker
|
|
||||||
now pre-reads the terrain-apply's dats into a `PhysicsDatBundle` so `ApplyLoadedTerrainLocked`
|
|
||||||
makes zero `DatCollection` calls and its `lock(_datLock)` is removed. **Measured: apply
|
|
||||||
lock-wait 24 ms median / 88 ms p95 → 0.2 µs.** This killed the streaming-induced *spikes*.
|
|
||||||
2. **STILL OPEN (the real FPS pain):** sustained ~30 FPS in dense towns, **view-direction-
|
|
||||||
dependent** ⇒ GPU/render-bound. Root cause **verified**: no distance LOD/degrade; every
|
|
||||||
frustum-visible object (statics + the huge volume of procedural scenery) is drawn at full
|
|
||||||
detail regardless of distance. ~1,400 draw calls / ~24,000 instances per frame in dense town.
|
|
||||||
3. **THE FIX (next session):** port retail `CPhysicsPart::UpdateViewerDistance` →
|
|
||||||
`GfxObjDegradeInfo::get_degrade` — per-frame, per-object: pick a lower-detail degrade slot
|
|
||||||
by camera distance, and hide past max distance. **Render-pipeline change** (objects bake
|
|
||||||
their close-mesh at spawn today; the draw must start carrying degrade variants + selecting
|
|
||||||
per frame). It's a faithful port, not a redesign — but render changes have been reverted
|
|
||||||
here when rushed, so do it via the grep→pseudocode→port→verify workflow with a fresh head.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What this session SHIPPED — the `_datLock` fix (committed; DO NOT REVERT)
|
|
||||||
|
|
||||||
**Symptom it fixed:** the wild 30↔200 *swing* while moving/streaming/portal-hopping.
|
|
||||||
|
|
||||||
**Root cause (measured + confirmed by the code's own comment):** `DatCollection` is not
|
|
||||||
thread-safe, so a single global `_datLock` serializes ALL `DatCollection.Get<T>`. The streaming
|
|
||||||
worker holds that lock for the ENTIRE per-landblock build (`BuildLandblockForStreaming`,
|
|
||||||
`GameWindow.cs:~5910`; comment at `:~5885` literally predicted this and named the fix). The
|
|
||||||
update thread's `ApplyLoadedTerrain` also took `_datLock` (for its own dat reads), so it BLOCKED
|
|
||||||
on the worker — measured **24 ms median / 88 ms p95** of pure lock-wait. A 43 ms apply = a 23-fps
|
|
||||||
frame; idle = 200 fps. That asymmetry was invisible to the title-bar ms (apply runs in `OnUpdate`,
|
|
||||||
FPS counter is `OnRender`).
|
|
||||||
|
|
||||||
**Fix (design A1, spec + plan committed):** the worker (already under `_datLock`) pre-reads the six
|
|
||||||
dats the apply needs into a `PhysicsDatBundle` carried on `LoadedLandblock`. The apply reads from
|
|
||||||
the bundle, makes zero `Get<T>` calls, and its `lock(_datLock)` is deleted. Safe because `_datLock`'s
|
|
||||||
only cross-thread job is serializing `DatCollection`; the apply's other writes are update-thread-only
|
|
||||||
(`_physicsEngine`, `ShadowObjects`, renderer, `_worldState`, `_buildingRegistries`) or already
|
|
||||||
concurrent-safe (`PhysicsDataCache` is all `ConcurrentDictionary`).
|
|
||||||
|
|
||||||
**Commits (newest→oldest, prior HEAD was `92e95be`):**
|
|
||||||
| SHA | What |
|
|
||||||
|---|---|
|
|
||||||
| `b3925f4` | chore(diag): [FRAME-DIAG] StreamingController counters (apparatus) |
|
|
||||||
| `536f1c0` | **fix(streaming): drop `_datLock` from the terrain apply** (the fix) |
|
|
||||||
| `81a5605` | refactor(apply): read dats from the bundle, not DatCollection |
|
|
||||||
| `4a99b55` | feat(streaming): worker pre-reads the apply's dats into the bundle |
|
|
||||||
| `3a0e349` | feat(streaming): `PhysicsDatBundle` on `LoadedLandblock` |
|
|
||||||
| `5ab5d39` | docs: implementation plan |
|
|
||||||
| `c715e55` | docs: design spec |
|
|
||||||
|
|
||||||
**Verified (empirical, full session town-walk + 4 teleports):** `lockwait` = 0.1 µs median /
|
|
||||||
0.2–0.3 µs p95 on EVERY line (was 24 ms / 88 ms). apply town p95 37 ms → 11 ms. Build + 1568
|
|
||||||
Core tests green. **The lock fix is correct and real. Keep it.** (It only addressed the *swing*,
|
|
||||||
not the dense-town floor — see §2.)
|
|
||||||
|
|
||||||
**Files the fix touched:**
|
|
||||||
- `src/AcDream.Core/World/PhysicsDatBundle.cs` (NEW) — the bundle record; `.Empty` for far tier.
|
|
||||||
Note: `Environment` aliased to `DatEnvironment` (collides with `System.Environment`).
|
|
||||||
- `src/AcDream.Core/World/LoadedLandblock.cs` — added `PhysicsDatBundle? PhysicsDats = null`
|
|
||||||
(trailing default → back-compatible).
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs` — `BuildPhysicsDatBundle(landblockId, entities)`
|
|
||||||
(mirrors the apply's 6 read sites; called from `BuildLandblockForStreamingLocked` near return);
|
|
||||||
the 6 apply sites rewritten `_dats.Get<X>(id)` → `datBundle.X[id]` via `TryGetValue`; the
|
|
||||||
`lock(_datLock)` removed from `ApplyLoadedTerrain`.
|
|
||||||
- `src/AcDream.App/Streaming/StreamingController.cs` — only diag counters (apparatus).
|
|
||||||
- Spec: `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md`.
|
|
||||||
- Plan: `docs/superpowers/plans/2026-06-23-datlock-contention-fix.md` (Task 5 — apparatus strip +
|
|
||||||
spec "verified" mark — is NOT done; see §4).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. STILL OPEN — the REAL FPS pain (why Fort Tethana is still ~30 FPS)
|
|
||||||
|
|
||||||
**User's report (the axiom):** "When I portaled in it was high, as soon as I enter the town it
|
|
||||||
drops, also depending on what direction I look." → portal-in over empty terrain = high; turn toward
|
|
||||||
the dense town = drop.
|
|
||||||
|
|
||||||
**Diagnosis (locked):** FPS that changes with **view direction** can only be the cost of drawing
|
|
||||||
what's in the frustum — i.e. **GPU/render-bound**. This is NOT the apply, the streaming, or the lock
|
|
||||||
(those are view-independent). The lock fix cannot touch it.
|
|
||||||
|
|
||||||
**Root cause (VERIFIED against source):** there is **no distance-based LOD or degrade** on entities
|
|
||||||
or scenery. The draw path culls by frustum-AABB only, then draws everything in view at full detail:
|
|
||||||
- `WbDrawDispatcher.WalkEntitiesInto` (`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:~744-747`):
|
|
||||||
per-entity cull is `FrustumCuller.IsAabbVisible(...)` — **no distance check**.
|
|
||||||
- `:~779-780`: for any in-frustum entity, **every `MeshRef` is emitted as an instance, every
|
|
||||||
frame**, regardless of distance.
|
|
||||||
- `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs` resolves each object to its CLOSE-detail mesh
|
|
||||||
(`Degrades[0]`) and its own comment (`~line 57-59`) says: *"We don't yet have distance-based LOD
|
|
||||||
plumbing, so this resolver always returns slot 0."* **We ported the degrade DATA, not the per-frame
|
|
||||||
distance DECISION.** (It already uses `Degrades[i].MaxDist` in `IsRuntimeHiddenMarker` for the #136
|
|
||||||
"red cone" — so the per-slot distances ARE available to us.)
|
|
||||||
- Scenery (trees/bushes/rocks) is procedurally generated per landblock (`SceneryGenerator`), is the
|
|
||||||
BULK of dense-town entity count, and goes through the same no-distance path.
|
|
||||||
- (Co-factors, lower priority: MSAA 4× on the High preset amplifies fill-rate; 2-pass translucency
|
|
||||||
for foliage. Don't chase these first — the missing distance-degrade is the structural cause.)
|
|
||||||
|
|
||||||
**Measurement caveat carried forward:** the existing `[WB-DIAG] gpu_us` timer brackets ONLY the
|
|
||||||
entity MDI dispatch (opaque+transparent) — it does NOT measure total GPU frame time (terrain shading,
|
|
||||||
sky, particles, MSAA resolve, fill-rate stalls). So "gpu_us≈400µs" is NOT the frame cost. **The next
|
|
||||||
session needs a real total-frame-time / GPU-frame-time meter** (swap-to-swap delta, or a glFinish-
|
|
||||||
bracketed full-frame timer) to (a) confirm GPU-bound and (b) prove the fix. Do not trust the existing
|
|
||||||
gpu_us as the frame budget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. THE FIX (next session) — port retail's per-frame distance-degrade
|
|
||||||
|
|
||||||
**Retail mechanism (named-retail decomp; addresses verified this session):**
|
|
||||||
- `CPhysicsObj::UpdateViewerDistance` (`0x0050f340` / `0x00510b30`) → `CPartArray::UpdateViewerDistance`
|
|
||||||
→ per-part `CPhysicsPart::UpdateViewerDistance` (`0x0050e030`, named-retail line ~275517). Per
|
|
||||||
frame, per part: compute distance to viewer, call `get_degrade(distance / gfxobj_scale.z, ...)`.
|
|
||||||
- `GfxObjDegradeInfo::get_degrade` (`0x0051e4b0`, named-retail line ~293086). Algorithm:
|
|
||||||
- if `degrades_disabled` → slot 0.
|
|
||||||
- `Render::force_level` → debug override (skip).
|
|
||||||
- **`effective_dist = |distance| − Render::s_rDegradeDistance`** (the user's "Degrade Distance"
|
|
||||||
setting, a 0–100 slider; `Render_DegradeDistance`, named-retail line ~3270-3272).
|
|
||||||
- bias = `Render::auto_update_deg_mul ? Render::deg_mul : Render::s_rUserSuppliedDegradeBias`.
|
|
||||||
- walk degrade slots; each slot has **`ideal_dist`** and **`max_dist`**; threshold per slot =
|
|
||||||
`ideal_dist − (ideal_dist − max_dist) * bias`. Pick the slot whose band contains `effective_dist`;
|
|
||||||
past the last band, use the last slot (whose `gfxobj_id` may be **0 = hidden**).
|
|
||||||
- `GfxObjDegradeInfo::get_max_degrade_distance` (`0x0051e2d0`, line ~292918) — the hide-past-this distance.
|
|
||||||
- `CPhysicsPart::Draw` (`0x0050d7a0`) draws `gfxobj[deg_level]`.
|
|
||||||
- **READ THESE FULLY before porting** (this handoff only summarizes): `get_degrade` body at
|
|
||||||
named-retail `acclient_2013_pseudo_c.txt:293086+`, `UpdateViewerDistance` at `:275517+`.
|
|
||||||
Cross-check the degrade-slot struct fields (`ideal_dist`, `max_dist`, `degrade_mode`, `gfxobj_id`)
|
|
||||||
against `docs/research/named-retail/acclient.h` and acdream's `GfxObjDegradeInfo` / `GfxObjInfo`
|
|
||||||
dat structs (we already expose `Degrades[i].Id` + `Degrades[i].MaxDist`; confirm whether `ideal_dist`
|
|
||||||
is also exposed or must be added).
|
|
||||||
|
|
||||||
**The render-pipeline change (the hard part — design it first):** today an object resolves to
|
|
||||||
`Degrades[0]` at SPAWN and bakes `MeshRef`s. Distance-degrade needs the **draw** to select the slot
|
|
||||||
**per frame** by camera distance. Options to brainstorm:
|
|
||||||
- (a) carry ALL degrade-slot GfxObj variants per part on the entity/`MeshRef`, and have
|
|
||||||
`WbDrawDispatcher` pick the slot per-frame (true retail; most work; per-frame `get_degrade` per part).
|
|
||||||
- (b) **hide-only first cut**: keep slot-0 mesh, but in `WalkEntitiesInto` skip emitting instances for
|
|
||||||
entities beyond their `get_max_degrade_distance` (scaled by the DegradeDistance setting). This is the
|
|
||||||
big, low-risk win (distant scenery/buildings stop drawing) WITHOUT per-frame mesh-swapping. Plumb a
|
|
||||||
per-entity max-draw-distance (read from the degrade table at spawn) + a per-entity camera-distance
|
|
||||||
check in the walk. **Recommended phase 1.**
|
|
||||||
- (c) full LOD-mesh selection (slot swap at intermediate distances) as phase 2 on top of (b).
|
|
||||||
|
|
||||||
**Open design questions for the brainstorm:**
|
|
||||||
- Where to evaluate distance: per-entity (cheap, approximate) vs per-part (retail-exact)? Start
|
|
||||||
per-entity.
|
|
||||||
- The `Render_DegradeDistance` setting: pick the retail default; wire through `QualitySettings` /
|
|
||||||
`RuntimeOptions` (per CLAUDE.md rule 4). Don't hardcode a magic distance — derive from the degrade
|
|
||||||
table + the setting.
|
|
||||||
- Hysteresis/popping: retail's bands prevent thrash; a naive per-frame threshold can pop. Use the
|
|
||||||
ideal/max band, not a single cutoff.
|
|
||||||
- Animated entities currently BYPASS the per-entity cull (they're landblock-tracked). Decide whether
|
|
||||||
they also distance-degrade (retail does, but be careful with the player + nearby NPCs — they should
|
|
||||||
stay slot 0 / never hide).
|
|
||||||
- **Divergence register:** the "no distance-degrade" deviation is NOT yet a row in
|
|
||||||
`docs/architecture/retail-divergence-register.md`. Add the row when the deviation is formally
|
|
||||||
acknowledged, and DELETE it in the commit that lands the port (per the register rules).
|
|
||||||
|
|
||||||
**Acceptance:** user confirms Fort Tethana / Holtburg FPS no longer drops to 30 when facing the dense
|
|
||||||
town. Prove it with the new total-frame-time meter (before/after) AND user eyes. NO "fixed" claim on a
|
|
||||||
sub-metric again (see §6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Apparatus — `[FRAME-DIAG]` (committed; needs strip; build a NEW meter for the next phase)
|
|
||||||
|
|
||||||
- **`[FRAME-DIAG]`** is committed across `GameWindow.cs` (fields, `OnUpdate` flush, the apply timing +
|
|
||||||
cell/bsp/shadow/upload/lockwait sub-spans, the `MaybeFlushTerrainDiag` print) and
|
|
||||||
`StreamingController.cs` (the counters). Gated on `ACDREAM_WB_DIAG=1`, flushes every ~5 s on outdoor
|
|
||||||
frames. Line format:
|
|
||||||
`[FRAME-DIAG] apply_us=Xm/Yp95 lockwait=… [cell=… bsp=… shadow=…] (upl=…) entUpl_us=… applies_max/upd=N deferred=N forceReload=N(drop=N) esg=N spawn=N resident=N walked=N`
|
|
||||||
- **It measures the APPLY (update) side — now PROVEN fixed (lockwait→0).** It does NOT measure the
|
|
||||||
total render frame time, which is what the distance-degrade work needs. **Next phase: add a
|
|
||||||
total-frame-time + GPU-frame-time meter** (the missing measurement; §2 caveat).
|
|
||||||
- **STRIP all of `[FRAME-DIAG]` + any new meter** when the FPS work fully lands (mirrors `92e95be`).
|
|
||||||
Plan Task 5 (apparatus strip + spec "verified" mark) was deliberately NOT done so the before/after
|
|
||||||
stays measurable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Verified file:line map (carry forward; lines drift — match by symbol)
|
|
||||||
|
|
||||||
| Thing | Location |
|
|
||||||
|---|---|
|
|
||||||
| Worker holds `_datLock` for full build (the old contention) | `GameWindow.cs:~5910` `BuildLandblockForStreaming`; comment `:~5885` |
|
|
||||||
| Apply wrapper (lock now REMOVED) | `GameWindow.cs:~6608` `ApplyLoadedTerrain` |
|
|
||||||
| Apply body (now dat-free; reads `datBundle`) | `GameWindow.cs:~6786` `ApplyLoadedTerrainLocked` |
|
|
||||||
| Bundle gather (worker) | `GameWindow.cs` `BuildPhysicsDatBundle` |
|
|
||||||
| **No-distance-cull (the open bug)** | `WbDrawDispatcher.cs:~744-747` (frustum only), `:~779-780` (emit all meshrefs) |
|
|
||||||
| **Degrade resolver stub ("always slot 0")** | `GfxObjDegradeResolver.cs:~57-59`; `IsRuntimeHiddenMarker` uses `MaxDist` |
|
|
||||||
| Entity store (drawn set) | `GpuWorldState.cs` `_flatEntities` / `LandblockEntries` / `Entities` |
|
|
||||||
| `[WB-DIAG]` entity-draw CPU+GPU timer (partial — entity MDI only) | `WbDrawDispatcher.cs:~301-329` |
|
|
||||||
| Retail `get_degrade` | named-retail `acclient_2013_pseudo_c.txt:293086` (`0x0051e4b0`) |
|
|
||||||
| Retail per-part `UpdateViewerDistance` | named-retail `:275517` (`0x0050e030`) |
|
|
||||||
| Retail `get_max_degrade_distance` | named-retail `:292918` (`0x0051e2d0`) |
|
|
||||||
| Retail `Render_DegradeDistance` setting (0–100) | named-retail `:3270-3272`, `:169441` |
|
|
||||||
| WorldBuilder reference (no distance LOD either — we must add it) | `references/WorldBuilder/.../SceneryRenderManager.cs`, `VisibilityManager.cs` (MAIN repo) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Repro + capture
|
|
||||||
|
|
||||||
Launch (Release; user drives + watches FPS; user manages client lifecycle):
|
|
||||||
```powershell
|
|
||||||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
|
||||||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
|
||||||
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
|
|
||||||
$env:ACDREAM_WB_DIAG="1" # turn OFF for the clean FPS number
|
|
||||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log"
|
|
||||||
```
|
|
||||||
Character `+Je` (`notan` / `MittSnus81!`). Repro: portal in (high FPS) → enter the dense town → **look
|
|
||||||
toward the town vs away** (FPS drops facing the dense geometry). Fort Tethana / Holtburg are the test
|
|
||||||
beds. Build Release for any FPS read (Debug is not representative). The old probe logs from this
|
|
||||||
session are in the worktree root (`frame-diag*.log`, `fps.log` — untracked; ignore/clean).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. DO-NOT-RETRY / lessons (read before you act)
|
|
||||||
|
|
||||||
- **"lock-wait = 0" is NOT "FPS fixed."** This session declared the FPS fixed on the lockwait sub-metric
|
|
||||||
and was wrong — the user was still at 30 in Fort Tethana. **Never claim FPS fixed without measuring the
|
|
||||||
TOTAL frame time the user actually sees, plus user-eyes confirmation.**
|
|
||||||
- **Verify subagent claims against source.** Two Explore agents this session drifted line numbers and one
|
|
||||||
fabricated a "rehydrate replays the player" race that source refuted (`SelectGuidsToRehydrate` excludes
|
|
||||||
the player). Read the cited code yourself before designing on it.
|
|
||||||
- **Render changes get reverted when rushed/speculative** (AP-48 cull deleted `_lastSpawnByGuid` → missing
|
|
||||||
walls; the sky-fog shader edits). The distance-degrade port is retail-faithful, but do it via
|
|
||||||
grep→pseudocode→port→verify, brainstorm the draw-path approach first, and measure before/after.
|
|
||||||
- **The `_datLock` fix is correct — do not re-investigate or revert it.** lockwait 88ms→0 is proven.
|
|
||||||
- **The existing `[WB-DIAG] gpu_us` under-reports** (entity MDI only) — don't use it as the frame budget.
|
|
||||||
- **DatCollection is not thread-safe**; only the worker may `Get<T>` off the update thread (under `_datLock`).
|
|
||||||
The apply is now dat-free — keep it that way (the bundle is how it gets dats).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Other OPEN follow-ups (deferred this session — lower priority than §3)
|
|
||||||
|
|
||||||
- **H3 — unbounded entity leak / GPU heat-up over hops.** `_entitiesByServerGuid` + `_lastSpawnByGuid`
|
|
||||||
never evict across teleports (`esg`/`spawn` climbed 1→528 over hops; ACE doesn't DeleteObject across
|
|
||||||
teleport). Inflates the in-frustum drawn set, so it COMPOUNDS the §3 cost. Fix = the SAFE render-only
|
|
||||||
eviction (drop far entities' render/GPU state, KEEP `_lastSpawnByGuid`, re-materialize on re-entry) —
|
|
||||||
NOT the naive AP-48 cull. Pruner: `RemoveLiveEntityByServerGuid` (`GameWindow.cs:~3831`, only caller =
|
|
||||||
DeleteObject handler + respawn de-dup). Distance-degrade (§3) may make this less urgent.
|
|
||||||
- **Invisible char on outdoor portal (intermittent).** VERIFIED mechanism: `ForceReloadWindow` (sky-arcs
|
|
||||||
fix `9945d46`) tears down the whole window on every outdoor teleport; the player is persistent-rescued
|
|
||||||
(`GpuWorldState.RemoveLandblock:~298`) → re-injected via `DrainRescued`/`AppendLiveEntity`, but lands in
|
|
||||||
`_pendingByLandblock` (not drawn) until its landblock re-loads. The fade can lift before the player is
|
|
||||||
flushed into the drawn set → invisible. (The Explore agent's "rehydrate replays player" theory was
|
|
||||||
WRONG — player is excluded from rehydrate at `LandblockEntityRehydrator.cs:79`.) Likely fix: gate the
|
|
||||||
teleport fade-lift on "player is in the drawn set," not just terrain residency. Needs a player-draw-
|
|
||||||
membership probe to pin "fade-too-early" vs "stuck-in-pending."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. References (read FIRST per the relevant thread)
|
|
||||||
- `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` — the shipped fix's design.
|
|
||||||
- `docs/research/2026-06-23-fps-gpu-churn-handoff.md` (MAIN repo) — the handoff that STARTED this session.
|
|
||||||
- `docs/research/2026-06-22-world-load-fps-rootcause-report.md` (MAIN repo) — prior FPS deep-dive.
|
|
||||||
- named-retail `docs/research/named-retail/acclient_2013_pseudo_c.txt` — `get_degrade` (:293086),
|
|
||||||
`UpdateViewerDistance` (:275517). `acclient.h` for the degrade structs.
|
|
||||||
- `references/WorldBuilder/` (MAIN repo) — scenery/object render path acdream extracted (no distance LOD;
|
|
||||||
read `docs/architecture/worldbuilder-inventory.md` first).
|
|
||||||
- `claude-memory/project_render_pipeline_digest.md` + `project_physics_collision_digest.md` (DO-NOT-RETRY tables).
|
|
||||||
- CLAUDE.md "Development workflow: grep named → decompile → verify → port" — MANDATORY for the distance-degrade port.
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
# Handoff — D.2b Sub-phase C, Slice 2: the paperdoll 3-D doll + the "Slots" toggle
|
|
||||||
|
|
||||||
**Date:** 2026-06-23 (end of the Slice 1 session)
|
|
||||||
**Branch:** `claude/hopeful-maxwell-214a12`. **Slice 1 SHIPPED + merged** — `main` is fast-forwarded to the branch tip (`main == branch` at `6897148`). Slice 2 continues on this branch.
|
|
||||||
**Status:** SCOPED, not started. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow.
|
|
||||||
**Line numbers drift — grep the symbol.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Read first (in this order)
|
|
||||||
|
|
||||||
1. **This doc.**
|
|
||||||
2. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT. The **"SLICE 1 … SHIPPED 2026-06-23"** entry holds the CORRECTED paperdoll model + the full DO-NOT-RETRY. **It supersedes the visual/look claims in the older docs below.**
|
|
||||||
3. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE element-id / wire / viewport research: §5 (the viewport mechanism, CONFIRMED), §5c (reuse analysis), §5d + §7 (camera/light immediates + the UNVERIFIED list). **⚠ Caveat:** its §2a "empty slot shows the doll body behind it" and the 2026-06-22 handoff's "transparent / per-slot silhouettes" framing were **WRONG about the LOOK** — see the corrected model below. The element ids, opcodes, and viewport plumbing in it are still good.
|
|
||||||
4. **`docs/architecture/code-structure.md` Rule 2** — Core defines interfaces, App implements; never the reverse. Governs the `IUiViewportRenderer` seam.
|
|
||||||
5. **`src/AcDream.App/UI/Layout/PaperdollController.cs`** — Slice 1's controller (the 21 slot bindings + wield/unwield drag handler you'll extend, not rewrite).
|
|
||||||
6. **`memory/reference_modern_rendering_pipeline.md`** + `EntitySpawnAdapter`/`AnimatedEntityState` — the in-world animated-character path the doll reuses.
|
|
||||||
|
|
||||||
## The CORRECTED paperdoll model (USER AXIOM — confirmed against his retail screenshots, 2026-06-23)
|
|
||||||
|
|
||||||
**There are NO per-slot silhouette sprites.** The "figure" in the paperdoll **IS the live 3-D character** (the doll — it can be naked if nothing is equipped). The panel has **two views toggled by the "Slots" button** (`0x100005BE` = `m_SlotCheckbox`, a `UIElement_Button`):
|
|
||||||
|
|
||||||
- **Button OFF (default) — "doll view":** show the **3-D character** + the **non-armor** slots (under-clothes / weapon / wand / shield / jewelry). The armor slots are hidden.
|
|
||||||
- **Button ON — "slot view":** the **character disappears** and the **armor slots become visible** (the grid).
|
|
||||||
|
|
||||||
It is a **TOGGLE (mutually exclusive: doll XOR armor-slots)**, NOT slots-overlaid-on-the-doll. Verify the exact show/hide split against the decomp (below) + the user's screenshots before coding — do not re-derive the look from the old deep-dive prose.
|
|
||||||
|
|
||||||
**What Slice 1 already did (don't redo):** bound all ~21 equip slots (`PaperdollController`, element-id→`EquipMask` map), wield (`GetAndWieldItem 0x001A`, optimistic + rollback) + unwield (the inventory-grid path + the `PickupEvent` two-table fix), and gave empty slots a **visible frame** (`emptySlotSprite = 0x06004D20`) so all positions are usable. Slice 1 shows **all** slots at once (no toggle, no doll). Slice 2 adds the doll + the toggle on top.
|
|
||||||
|
|
||||||
## Slice 2 — two pieces (each its own spec → plan → implement; the viewport is the crux)
|
|
||||||
|
|
||||||
### A. The "Slots" toggle (the lighter piece — do FIRST, it de-risks the layout)
|
|
||||||
|
|
||||||
Wire the `m_SlotCheckbox` button (`0x100005BE`) to switch between doll-view and slot-view:
|
|
||||||
- **Map each slot as ARMOR vs NON-ARMOR.** Retail's `RemakeCharacterInventory` (decomp `0x004a65c0`) builds `clothingPriorityMask` from the mask **`0x8007fff`** (line 176016) — clothing/jewelry/misc; the armor slots are the `*Armor` `EquipMask` bits (`ChestArmor 0x200 … LowerLegArmor 0x4000`). Confirm the exact partition from `GetLocationInfoFromElementID` (the §3a table) + the toggle handler.
|
|
||||||
- **READ THE TOGGLE HANDLER FIRST:** `gmPaperDollUI::ListenToElementMessage` (decomp `0x004a5c30`, ~line 175593) — the `idMessage == 1` branch (button click, ~line 175628+, NOT yet read this session) is the show/hide logic. The init does `SetAttribute_Bool(m_SlotCheckbox, 0xe, 0)` (line 175585) = start unchecked (doll view). Port the exact `SetVisible` toggling it does on the armor slots + the viewport.
|
|
||||||
- In acdream: `PaperdollController` keeps refs to the armor slots (toggle-hidden in doll view) + the viewport widget; the button's click flips their `Visible`. The non-armor slots stay visible in both views (verify).
|
|
||||||
- **Slice-1 caveat to revisit:** AP-66 (empty slots show a frame) — decide whether the frame stays in slot-view (likely yes) and what an empty NON-ARMOR slot shows in doll-view. The "frames flip to transparent / doll-through" idea in AP-66 was written under the overlay assumption; the TOGGLE model means there is no doll behind the armor slots in slot-view, so the frame likely **stays**. Re-word AP-66 once the toggle behavior is confirmed.
|
|
||||||
|
|
||||||
### B. The 3-D doll `UiViewport` (the heavy piece — the UI↔3D bridge, the real crux)
|
|
||||||
|
|
||||||
A `UiViewport` widget (registers at dat **Type `0xD`**, element **`0x100001D5`**) that renders a re-dressed clone of the local player into the widget's screen rect (a scissored single-entity 3-D pass), shown in doll-view.
|
|
||||||
|
|
||||||
- **The Core→App seam (Code-Structure Rule 2):** define `IUiViewportRenderer` in `AcDream.Core` (or the UI.Abstractions layer), implement in `AcDream.App` (it has GL + `EntitySpawnAdapter`). The widget's `OnDraw` only has a 2-D `UiRenderContext`; the 3-D pass needs a dedicated overlay hook the `UiHost`/`GameWindow` invokes for any `UiViewport` present (NOT inside `OnDraw`). **The exact integration point (after the world pass vs a UI overlay) is DESIGN-OPEN — settle it in the brainstorm** (deep-dive §5d/§7).
|
|
||||||
- **Reuse the existing char path (deep-dive §5c, CONFIRMED):** build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through `EntitySpawnAdapter`/`AnimatedEntityState` (palette/part/hidden-part overrides), draw it with a fixed camera + one distant light into the rect. Re-dress on `ObjDescEvent 0xF625` (already PARSED) = rebuild the entity's overrides — the C# analog of retail `RedressCreature` (decomp `0x004a5c90`?/line 173990 / 175535).
|
|
||||||
- **Camera/light/heading immediates** (retail `gmPaperDollUI::PostInit`, decomp ~175509-535; raw hex read this session, **decode + VERIFY**):
|
|
||||||
- `SetCamera(viewport, &dir, &pos)` with `dir ≈ (0x3df5c28f=0.12, 0xc019999a=-2.4, 0x3f6147ae=0.88)`, `pos = (0,0,0)`. (Arg order pos-vs-dir UNVERIFIED — `UIElement_Viewport::SetCamera` → `CreatureMode::SetCameraPosition/Direction`.)
|
|
||||||
- `SetLight(viewport, DISTANT_LIGHT, 2.0f, &dir)` with `dir ≈ (0x3e99999a=0.3, 0x3ff33333=1.9, <3rd component is a strncpy/lifter artifact — recover from Ghidra/re-decompile>)`.
|
|
||||||
- `set_heading(191.367905°)` so the doll faces the viewer; idle animation via `m_didAnimation` (`UpdateForRace` `0x004a…`/line 174129 swaps the idle DID per body-type via `DBObj::GetDIDByEnum`); `CreatureMode::UseSharpMode` (sharper mip bias).
|
|
||||||
- **Player-clone vs fresh WorldEntity (UNVERIFIED, deep-dive §7):** retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`). acdream's local player is NOT a renderable `WorldEntity` (it's the camera), so LIKELY build a dedicated `WorldEntity` from the player's Setup + ObjDesc and host it in a private viewport scene. Confirm the player's Setup id + current ObjDesc are available client-side (PlayerDescription / CreateObject / the equipped ObjDescEvents).
|
|
||||||
- **Polish, NOT MVP:** part-selection lighting (`ApplyPartSelectionLighting` / `GetSelectionMaskFromObject` / `CreateClickMap`, lines 174034/174762/174636) — the "which armor piece is this?" highlight + the doll click-map; the Aetheria sigil slots (`0x10000595/96/97`, `SetVisible(0)` by default). Defer.
|
|
||||||
|
|
||||||
## What's already in place (reuse, don't rebuild)
|
|
||||||
|
|
||||||
- **`PaperdollController`** — the slot bindings + wield/unwield + the element-id→`EquipMask` map. Extend it with the armor/non-armor partition + the viewport ref + the toggle.
|
|
||||||
- **The mount + import** — the gmPaperDollUI subtree (`0x21000024`) is imported under `0x100001CD` in the inventory frame; the viewport element `0x100001D5` is in the tree (currently skipped — `DatWidgetFactory` Type 12→skip; Type `0xD` is NOT registered yet → register it to `UiViewport`).
|
|
||||||
- **`EntitySpawnAdapter` / `AnimatedEntityState` / `WorldEntity`** — the per-instance animated-character render path (palette/part/hidden-part overrides). The doll's model pipeline.
|
|
||||||
- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/sub-palette/texture/anim-part) — the re-dress input.
|
|
||||||
- **`ClientObjectTable` + the wield wire** (`GetAndWieldItem`, `WieldObject 0x0023` + `ConfirmMove`, `PickupEvent` two-table fix) — all shipped in Slice 1.
|
|
||||||
|
|
||||||
## Open questions / UNVERIFIED (settle in the brainstorm)
|
|
||||||
|
|
||||||
1. **Overlay vs replace** — confirmed REPLACE (toggle) per the user, but verify the exact `SetVisible` set the `ListenToElementMessage idMessage==1` handler toggles (armor slots + viewport; do the non-armor slots stay shown in slot-view?).
|
|
||||||
2. **The `IUiViewportRenderer` integration point** — after the world pass, or a dedicated UI-overlay 3-D pass? Scissor + viewport from the widget's screen rect. Code-Structure Rule 2 = Core interface, App impl.
|
|
||||||
3. **Camera/light immediates** — decode + verify the floats above; recover the corrupted 3rd light component from Ghidra.
|
|
||||||
4. **Player-clone vs fresh `WorldEntity`** — acdream has no player `WorldEntity`; build one from Setup+ObjDesc. Confirm the data is available client-side.
|
|
||||||
5. **AP-66 fate** — does the empty-slot frame stay in slot-view (likely) or change? Re-word the register row once the toggle behavior is confirmed.
|
|
||||||
6. **`0x100001E0 = MissileAmmo 0x800000`** still LIKELY (AP-62) — gate-verify the ammo slot.
|
|
||||||
|
|
||||||
## Decomp anchors (named-retail `acclient_2013_pseudo_c.txt`)
|
|
||||||
|
|
||||||
- `gmPaperDollUI::PostInit` (~175232; the slot/viewport/button init I read this session — `SetVisible(0)` per slot, `SetCamera`/`SetLight`/`UseSharpMode`/`RedressCreature` at 175509-535, `m_SlotCheckbox = 0x100005BE` at 175557).
|
|
||||||
- `gmPaperDollUI::ListenToElementMessage` (~175593; **read the `idMessage==1` branch ~175628+ for the toggle**).
|
|
||||||
- `gmPaperDollUI::RedressCreature` (173990 / 175535; clone + `set_heading(191.37°)` + `set_sequence_animation` + `DoObjDescChangesFromDefault`).
|
|
||||||
- `gmPaperDollUI::UpdateForRace` (174129; per-body-type camera + idle DID).
|
|
||||||
- `gmPaperDollUI::RemakeCharacterInventory` (175983) + `SetUIItemIntoLocation` (175713/175950; populate equipped items — Slice 1 does the equivalent).
|
|
||||||
- `UIElement_Viewport::Create/SetCamera/SetLight/PostInit` + `CreatureMode::Render` (deep-dive §5a/§5b, lines 119029/91665).
|
|
||||||
|
|
||||||
## Acceptance (Slice 2)
|
|
||||||
|
|
||||||
- The "Slots" button toggles: doll-view (3-D character + non-armor slots) ↔ slot-view (armor slots). Matches the user's two screenshots.
|
|
||||||
- The doll renders the re-dressed local player (correct race/gender/equipped gear; naked if nothing equipped), faces the viewer, idles; updates live on equip/unequip via `ObjDescEvent 0xF625`.
|
|
||||||
- Build + full suite green; **visual gate** (user compares to retail). Divergence rows for any approximation; the `IUiViewportRenderer` seam respects Code-Structure Rule 2.
|
|
||||||
|
|
||||||
## New-session prompt
|
|
||||||
|
|
||||||
> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (Slice 1 shipped; `main` is ff-merged to the tip). **READ FIRST:** `docs/research/2026-06-23-paperdoll-slice2-handoff.md`, then its "Read first" list — especially the **Slice 1 entry in `claude-memory/project_d2b_retail_ui.md`** (the CORRECTED paperdoll model) and the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` §5.
|
|
||||||
>
|
|
||||||
> **NEXT: Sub-phase C — the paperdoll, Slice 2.** The corrected model (user axiom): the paperdoll "figure" IS the **live 3-D character** (not silhouettes); the **"Slots" button (`0x100005BE`)** TOGGLES between **doll-view** (3-D character + non-armor slots) and **slot-view** (armor slots) — mutually exclusive, NOT overlaid. Build (A) the **toggle** first — read `gmPaperDollUI::ListenToElementMessage`'s `idMessage==1` branch (~decomp 175628+) for the exact `SetVisible` show/hide of the armor slots + viewport; partition armor vs non-armor slots (armor = the `*Armor` EquipMask bits; clothing/jewelry/weapon = non-armor, cf. `clothingPriorityMask` `0x8007fff`). Then (B) the **3-D doll `UiViewport`** (register dat **Type `0xD`**, element `0x100001D5`): a Core `IUiViewportRenderer` seam (Code-Structure Rule 2 — Core defines, App implements) driving a scissored single-entity 3-D pass keyed to the widget rect; reuse `EntitySpawnAdapter`/`AnimatedEntityState` by building a `WorldEntity` from the local player's Setup + current ObjDesc, re-dressed on `ObjDescEvent 0xF625` (the C# analog of `RedressCreature`); fixed camera + one distant light + `set_heading(191.37°)` + idle anim (decode the `PostInit` immediates ~175509-535 + verify). **Extend the shipped `PaperdollController` — do NOT rewrite the slot bindings/wield/unwield.** The UI↔3-D integration point is DESIGN-OPEN — settle it in the brainstorm. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it.
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
# Collision-inclusion audit — retail vs acdream ("1 fix for all collision") — 2026-06-24
|
|
||||||
|
|
||||||
**Mode:** report-first / brainstorm-gated (DO-NOT-RETRY collision area). No code written.
|
|
||||||
**Method:** 10 parallel named-retail readers (5 retail-model + 5 acdream-channel) → synthesis → **adversarial verification of every claimed deviation against the actual cited code** (the [[feedback_verify_subagent_claims_against_source]] discipline). Plus orchestrator first-hand reads of `CollisionExemption.cs`, `EntityCollisionFlags.cs`, `PhysicsDataCache.cs`, `TransitionTypes.cs`, `GameWindow.cs:7388-7565`, and the divergence register.
|
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b`. Named-retail + `references/{ACE,ACViewer,holtburger}` + `symbols.json` live in the MAIN repo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR — the unifying insight
|
|
||||||
|
|
||||||
Retail's collision model is **two clean layers**:
|
|
||||||
|
|
||||||
- **Layer B (registration / broad phase)** — every object with a *DAT physics shape* is written **unconditionally** into the per-cell `shadow_object_list` of every cell its sphere overlaps. **No attribute is tested at registration** (not STATIC, not ETHEREAL, not HIDDEN — only `PARTICLE_EMITTER_PS 0x1000` diverts to a particle list). The only thing that decides "is this registerable" is **does it have a shape** (`physics_bsp` from DAT bit-0, or a `CSetup` cylsphere/sphere). Source: `CObjCell::find_cell_list@0x0052b4e0`, `CPhysicsObj::add_shadows_to_cells@0x00514ae0`.
|
|
||||||
- **Layer A (inclusion predicate / narrow phase)** — `CPhysicsObj::FindObjCollisions@0x0050f050` decides per-query whether the registered object actually blocks, via a flag predicate (ETHEREAL+IGNORE, viewer-vs-creature, IGNORE_CREATURES, PvP pool), then dispatches **one** shape branch (BSP-only iff `HAS_PHYSICS_BSP_PS 0x10000`, else CylSphere-then-Sphere).
|
|
||||||
|
|
||||||
**acdream already has this two-layer shape** (per-cell `ShadowObjectRegistry` + query-time `CollisionExemption`). The user's intuition is correct, and the divergences are now **narrow and enumerable** — they are NOT a scattered mess of per-channel filters anymore (the #147 portal-count skip was the last of that family and is already deleted in `9743537`). What remains:
|
|
||||||
|
|
||||||
1. **The shape-source rule is impure** — acdream fabricates collision from the **render-mesh AABB** for scenery + outdoor meshes (D1, the prime deviation; retail *never* consults render bounds). This is the one place acdream **over-includes** vs retail.
|
|
||||||
2. **The query predicate is incomplete** — ETHEREAL-alone short-circuit (D2), no true sphere primitive (D3), PvP/missile terms hardcoded false (watch-item W1).
|
|
||||||
3. **One cached collision transform can go stale** — EnvCell cell-struct cache lacks the per-apply rebase that buildings got in #146 (D8). Plus two indoor-channel omissions that are latent today (D4 entry-restrictions, D5 obstruction_ethereal).
|
|
||||||
|
|
||||||
So **"1 fix for all collision" = make the shape-source rule pure (DAT-only) + complete the query predicate + give every cached transform the same per-apply rebase.** It is a *unification of three things*, not one line — but it collapses to **one `ObjectCollides(...)` predicate + one registration rule + one rebase rule** that every channel funnels through.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verdict table (6 confirmed deviations, 2 refuted false-positives)
|
|
||||||
|
|
||||||
| ID | Sev | Layer | Title | Verdict | Register |
|
|
||||||
|----|-----|-------|-------|---------|----------|
|
|
||||||
| **D1** | **HIGH** | shape-source | Render-mesh-AABB → synthetic collision cylinder for scenery (#101 residual) | **CONFIRMED** (high) | extend **AP-2** (currently cites only the Setup case at `PhysicsDataCache.cs:96`; the scenery `0x8…` site at `GameWindow.cs:7408` is uncovered) |
|
|
||||||
| **D2** | MED | predicate | ETHEREAL-alone instant-skip; retail needs ETHEREAL **and** IGNORE_COLLISIONS | **CONFIRMED** (high) | **AD-7** exists |
|
|
||||||
| **D3** | LOW | shape-source | Setup `Sphere`s coerced to short cylinders (no true sphere primitive) | **CONFIRMED** (high) | **NEW ROW** needed |
|
|
||||||
| **D4** | LOW | predicate | EnvCell path omits `check_entry_restrictions` (access-locked cells) | **CONFIRMED** (high) | **NEW ROW** (no effect in dev content) |
|
|
||||||
| **D5** | MED | predicate/state | EnvCell never clears `obstruction_ethereal` — **field doesn't even exist** in acdream `SpherePath` | **CONFIRMED** (high) | **NEW ROW** (couples with D2) |
|
|
||||||
| **D8** | MED | stale-frame | EnvCell cell-struct `WorldTransform` never evicted/rebased (asymmetric with buildings #146) | **CONFIRMED** (med) | **NEW ROW** (latent; masked by single-block dungeon collapse) |
|
|
||||||
| ~~D6~~ | — | dispatch | "EnvCell never calls `placement_insert`" | **REFUTED** | acdream *does* branch — inside `BSPQuery.FindCollisions` (`BSPQuery.cs:1717`), not at the call site. Functionally identical to retail. **No fix.** |
|
|
||||||
| ~~D7~~ | — | terrain | "Terrain silently passes through when no landblock registered" | **REFUTED** | **Retail does the same** — `LScape::get_landcell@0x00505f10` returns null for an unloaded block, `transitional_insert@0x0050b6f0` returns OK_TS on null cell. This is the **#135/#138 streaming-gap free-fall**, a streaming-foundation issue, NOT collision-inclusion. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The retail model (the oracle table)
|
|
||||||
|
|
||||||
### Layer A — the inclusion predicate (`CPhysicsObj::FindObjCollisions@0x0050f050`)
|
|
||||||
|
|
||||||
PhysicsState flags and whether each gates collision (verified against `acclient.h` + the decomp body, lines 276776-276996, cross-checked vs ACE `PhysicsObj.cs:381-454`):
|
|
||||||
|
|
||||||
| Flag | Value | Gates collision? | Mechanism |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ETHEREAL_PS` | 0x4 | **Only with 0x10** | Instant-skip (return OK_TS, no shape test) requires **both** 0x4 AND 0x10 (`276782`). 0x4 alone → set `sphere_path.obstruction_ethereal=1` and **still run shape test** (`276795-276806`); downstream step-down passes through but contact is recorded. |
|
|
||||||
| `IGNORE_COLLISIONS_PS` | 0x10 | with 0x4 | (above) |
|
|
||||||
| `HAS_PHYSICS_BSP_PS` | 0x10000 | **dispatch** | Set (and not missile-ignored, not PvP-exempt `ebp_1==0`) → **BSP-only** via `CPartArray::FindObjCollisions` (`276858-276961`). Clear → CylSphere loop then Sphere branch. **Mutually exclusive** — never both. |
|
|
||||||
| `STATIC_PS` | 0x1 | no (response only) | Decides hit is reported as `collided_with_environment` vs object collision (`276969-276981`); does not skip. |
|
|
||||||
| `REPORT_COLLISIONS_PS` | 0x8 | no | Gates `DoCollisionEnd/Begin` *callback delivery* only (`278614`), not geometry. |
|
|
||||||
| `NODRAW_PS` | 0x20 | **no** | Not read anywhere in the collision path. Render-only. (Confirms handoff hypothesis.) |
|
|
||||||
| `MISSILE_PS` | 0x40 | no | Marks target `isCreature=true` for sphere dispatch; `missile_ignore` (`274390`) makes a missile mover skip missile targets. |
|
|
||||||
| `HIDDEN_PS` | 0x4000 | indirect | `set_hidden` *also* sets 0x10 + clears 0x8 as a side-effect (`282992`,`283029`); but HIDDEN alone doesn't set ETHEREAL, so Gate-1 still needs 0x4. |
|
|
||||||
| `SCRIPTED_COLLISION_PS` | 0x8000 | **no** | Never read in the physics collision path. |
|
|
||||||
| `FROZEN_PS` | 0x1000000 | no | Gates `update_object` (sim skip) only (`283955`). |
|
|
||||||
|
|
||||||
Plus the non-flag gates: mover≠self, not-creature-when-viewer/IGNORE_CREATURES mover, PvP pool (both-players → skip unless target Impenetrable OR both PK OR both PKLite).
|
|
||||||
|
|
||||||
### Layer B — registration / broad phase
|
|
||||||
|
|
||||||
`shadow_object_list` membership predicate (`CObjCell::find_cell_list@0x0052b4e0`):
|
|
||||||
- **(1)** object is NOT a pure particle emitter (`PARTICLE_EMITTER_PS 0x1000`), **(2)** at least one collision sphere overlaps the cell volume (indoor: `sphere_intersects_cell != OUTSIDE`; outdoor: sphere center maps to the cell grid square + boundary neighbours). **No other attribute tested at registration.** A single object lands in **multiple** cells' lists (the doorway-door-in-both-cells answer that the A6.P4/BR-7 flood already ports).
|
|
||||||
|
|
||||||
### The four collision channels (per-cell dispatch order: env → building → objects)
|
|
||||||
|
|
||||||
| Channel | Function | Inclusion rule |
|
|
||||||
|---|---|---|
|
|
||||||
| **Terrain** | `CLandCell::find_env_collisions@0x00532f20` | `check_entry_restrictions` → `find_terrain_poly` (always 2 triangles) → water-skip clause → `validate_walkable`. Always solid where the landblock is loaded. |
|
|
||||||
| **EnvCell (indoor)** | `CEnvCell::find_env_collisions@0x0052c130` | `check_entry_restrictions` → **zero `obstruction_ethereal`** → if `structure->physics_bsp != null`: `find_collisions` (or `placement_insert` for INITIAL_PLACEMENT). **No building leg.** |
|
|
||||||
| **Building shell** | `CBuildingObj::find_building_collisions@0x006b5300` via `CSortCell::find_collisions@0x005340a0` | In `CLandBlockInfo.buildings` for an outdoor block AND `makeBuilding` succeeded AND origin landcell non-null. **Portal count / portal list / model id NOT examined.** BSP on `part_array->parts[0]`. (This is the #147 truth.) |
|
|
||||||
| **Object shadows** | `CObjCell::find_obj_collisions@0x0052b750` → Layer A | Per-cell shadow list iteration. |
|
|
||||||
|
|
||||||
### Shape assignment (the rule D1 violates)
|
|
||||||
|
|
||||||
A `CPhysicsObj` gets a collision shape from **DAT only**: a part's `CGfxObj.physics_bsp` (deserialized by `CGfxObj::Serialize@0x00534970` *only* when serialized-flags bit-0 / `num_physics_polygons>0`), or `CSetup` cylsphere/sphere arrays. `CPartArray::InitParts@0x00517F40` builds parts from the Setup list only. When none exist, `CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK (passable) — **no synthesis from drawing geometry.** The render-mesh `gfx_bound_box` is used for cell-registration + frustum culling, **never** collision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The acdream model (per-channel de-facto predicate)
|
|
||||||
|
|
||||||
| Channel | acdream predicate (de-facto) | Code | Verdict vs retail |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Terrain** | foot-XY inside a registered landblock's `[0,192)²` AND outdoor cell. null landblock → pass through. | `PhysicsEngine.SampleTerrainWalkable` `:261`; `TransitionTypes.cs:2229-2247` | **Faithful** (D7 refuted — retail passes through unloaded blocks too). |
|
|
||||||
| **Building** | outdoor cell AND `GetBuilding != null` AND `ModelId != 0` AND `GetGfxObj(ModelId).BSP.Root != null` | `TransitionTypes.cs:2805-2819` | **Faithful** post-#146/#147. `ModelId==0 → inert` ≈ retail "no parts[0]". Per-apply rebase via `RemoveBuildingsForLandblock` (#146). |
|
|
||||||
| **Shadow objects** | source hi-byte 0x01/0x02 AND not building shell AND not phantom Setup/GfxObj AND ≥1 non-zero-radius shape **OR scenery mesh-AABB fallback** | `GameWindow.cs:7185-7565`; `ShadowObjectRegistry.cs`; `ShadowShapeBuilder.cs` | **D1 (mesh-AABB), D3 (sphere-as-cyl)** + watch-items. |
|
|
||||||
| **EnvCell** | cell in BFS shadow-set AND `CellPhysics.BSP.Root != null` | `TransitionTypes.cs:2062-2207`; `CellTransit.cs` | **D4, D5, D8.** |
|
|
||||||
| **Query exemption** | `CollisionExemption.ShouldSkip` — ETHEREAL(0x4) alone, viewer/creature, IGNORE_CREATURES, PvP pool | `CollisionExemption.cs:59` | **Faithful except D2** (ETHEREAL-alone). PvP block is a clean port (cross-checked vs ACE). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Confirmed deviations — detail
|
|
||||||
|
|
||||||
### D1 — Render-mesh-AABB synthetic collision cylinder (HIGH, the prime deviation)
|
|
||||||
- **Retail:** shape from DAT `physics_bsp`/`CSetup` only; no shape ⇒ passable (`CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK when `physics_bsp==null`; `CGfxObj::Serialize@0x00534970` gates `physics_bsp` on flags bit-0; `CPartArray::InitParts@0x00517F40`).
|
|
||||||
- **acdream:** for scenery (`0x80000000` IDs) with no CylSphere/BSP, synthesizes a clamped `[0.30,1.50]m` cylinder from the **world-space render-mesh AABB** and registers it. `GameWindow.cs:7421-7425` gate: `!isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0))`. For scenery `_isOutdoorMesh` is always true (`:7151`); the #101 phantom gates only cover `0x01…` GfxObjs (`IsPhantomGfxObjSource`, `PhysicsDataCache.cs:399-403`) and zero-radius Setups — a Setup-sourced scenery with `Radius>0.0001` and no `physics_bsp` still gets the synthesized cylinder.
|
|
||||||
- **Why it exists:** the comment admits a *gameplay* motive — "catches trees whose BSP is only on the canopy (player walks under)". This is exactly the attribute/shape category-error the user wants gone: acdream invents collision retail doesn't have.
|
|
||||||
- **Register:** **AP-2** must be widened to cover `GameWindow.cs:7408` (it currently cites only `PhysicsDataCache.cs:96`).
|
|
||||||
|
|
||||||
### D2 — ETHEREAL-alone instant-skip (MED)
|
|
||||||
- **Retail:** Gate-1 needs **both** 0x4 AND 0x10 (`276782`); 0x4 alone → `obstruction_ethereal=1` + still run shape test (`276795-276806`), consumed by BSP solid-containment gates (`321692`,`323742`,`324573`).
|
|
||||||
- **acdream:** `CollisionExemption.cs:78` skips on 0x4 alone. Documented shim (**AD-7**) because ACE `Door.Open()` broadcasts `0x0001000C` (ETHEREAL only). The `obstruction_ethereal` set-and-continue path is unported.
|
|
||||||
|
|
||||||
### D3 — No true sphere primitive (LOW)
|
|
||||||
- **Retail:** `HAS_PHYSICS_BSP_PS` clear → CylSphere then **Sphere via `CSphere::intersects_sphere`** (`276917`) — a real sphere test.
|
|
||||||
- **acdream:** `ShadowShapeBuilder.cs:68-81` coerces `Setup.Spheres` → short cylinders (`height = radius*2`); `ShadowCollisionType` enum (`ShadowObjectRegistry.cs:537`) has only `{BSP, Cylinder}` — no Sphere. **New register row.**
|
|
||||||
|
|
||||||
### D4 — EnvCell omits `check_entry_restrictions` (LOW, no current effect)
|
|
||||||
- **Retail:** `CEnvCell::find_env_collisions@0x0052c130` calls `check_entry_restrictions@0x0052b6d0` first; returns COLLIDED_TS when an access-locked cell's `restriction_obj` rejects the mover.
|
|
||||||
- **acdream:** `TransitionTypes.cs:2062-2207` has no such gate. No access-locked cells in dev content ⇒ no observable effect, but structurally absent. **New register row.**
|
|
||||||
|
|
||||||
### D5 — `obstruction_ethereal` never cleared — field absent entirely (MED)
|
|
||||||
- **Retail:** zeros `sphere_path.obstruction_ethereal` on every `find_env_collisions` call (`0x0052c144`) before BSP dispatch.
|
|
||||||
- **acdream:** the field **does not exist** in `SpherePath` (`TransitionTypes.cs:315-435`) — so the retail BSP solid-weakening gate keyed on it is simply not modelled. Couples with D2 (port the set/clear/consume sites together). **New register row.**
|
|
||||||
|
|
||||||
### D8 — EnvCell cell-struct transform never evicted/rebased (MED, latent)
|
|
||||||
- **Retail:** no persistent client-side streaming-relative transform cache; cell geometry rebuilt from live cell pointers (eviction analog: `CEnvCell::release`).
|
|
||||||
- **acdream:** `PhysicsDataCache._cellStruct` (`:179`) is first-wins `ContainsKey` with **no eviction** — contrast `RemoveBuildingsForLandblock` (`:472`, the #146 fix). `PhysicsEngine.RemoveLandblock` (`:121-141`) clears `_landblocks`/`ShadowObjects`/`CellGraph` but never cells. A dungeon streamed out→in after a `_liveCenter` recenter would hold a stale `WorldTransform` (off by `(oldCenter−newCenter)·192m`). Masked today by the single-block collapse+recenter dungeon pattern. **New register row.** This is the #146 stale-frame class the handoff predicted "almost certainly more of these" — and there is exactly one more.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Refuted (do NOT chase — anti-rabbit-hole)
|
|
||||||
|
|
||||||
- **D6 (placement_insert):** acdream **does** route INITIAL_PLACEMENT to `PlacementInsert` — the branch is inside `BSPQuery.FindCollisions` (`BSPQuery.cs:1717`, `if (path.InsertType == Placement || obj.Ethereal)`), not at the `FindEnvCollisions` call site. Identical functional result.
|
|
||||||
- **D7 (terrain pass-through):** retail `LScape::get_landcell@0x00505f10` returns null for an unloaded block and `transitional_insert@0x0050b6f0` returns OK_TS on a null cell — **retail passes through unloaded terrain too.** The free-fall is the **streaming-gap** (#135/#138), owned by the cell-relative-frame work, not collision-inclusion.
|
|
||||||
- **Building `ModelId==0` filter** (`TransitionTypes.cs:2813`): **checked, faithful** — equals retail's "no `parts[0]` ⇒ no shell BSP".
|
|
||||||
- **#147 portal-count skip:** already deleted (`9743537`). The last scattered attribute-as-filter is gone.
|
|
||||||
- **Registration-time attribute filters:** acdream's registration is already overlap-based with filtering deferred to query time (`CollisionExemption`) — **architecturally already retail-shaped.** The remaining divergences are shape-source + predicate-detail + one stale cache, not a registration-filter mess.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Watch-items (sub-deviations not promoted — note, don't necessarily fix now)
|
|
||||||
|
|
||||||
- **W1 — PvP/missile dispatch terms hardcoded false** (`TransitionTypes.cs:2629`): the cyl-vs-BSP dispatch assumes `pvpExempt=false && missile_ignore=false`. Harmless in M1.5 (no PK/missiles), but a missile/PK mover would wrongly take BSP-only against all BSP targets. Belongs in the unified predicate when PK/missiles ship.
|
|
||||||
- **W2 — `BldPortalInfo.ExactMatch`** decoded but unconsumed (`BuildingPhysics.cs:74`, `CellTransit.CheckBuildingTransit`): a *transit-feature*, not collision-inclusion — explicitly out of scope for this phase.
|
|
||||||
- **W3 — `ShadowShapeBuilder` default radius** `2f` (`:97`) vs `GameWindow` stab fallback `1f` (`:7215`) when `BoundingSphere` is null — minor inconsistency.
|
|
||||||
- **W4 — `CLandCell` ENTIRELY_WATER skip clause** (state 0x04 / 0x40) may be unimplemented in the terrain path — verify before deep-water content.
|
|
||||||
- **W5 — static-prune `VisibleCellIds` vs retail `do_not_load` stab_list** (`CellTransit.cs:549-562`): approximation; verify against the CEnvCell dat stab_list field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The unified target — "1 fix for all collision" (PROPOSAL — brainstorm-gate before coding)
|
|
||||||
|
|
||||||
One **`ObjectCollides(targetState, moverState, targetFlags, shape)`** predicate + one **registration rule** + one **rebase rule**, that every channel funnels through:
|
|
||||||
|
|
||||||
**(a) Pure shape resolution (one helper, DAT-only).** Shape comes ONLY from `physics_bsp` (DAT bit-0) or `CSetup` cylsphere/sphere; an object with no DAT shape registers **no shape** and is passable. *Delete the mesh-AABB synthesis (D1).*
|
|
||||||
|
|
||||||
**(b) The query predicate matching `FindObjCollisions@0x0050f050`:** instant-skip iff (0x4 AND 0x10); 0x4-alone → set `ObstructionEthereal` + continue (D2/D5); viewer/creature; IGNORE_CREATURES/creature; PvP pool; then dispatch BSP-only iff `HAS_PHYSICS_BSP_PS && !pvpExempt && !missileIgnore` else CylSphere→**Sphere** (D3). Wire the PvP/missile terms (W1).
|
|
||||||
|
|
||||||
**(c) One overlap-based per-cell registration** (already mostly present via `ShadowObjectRegistry` + the A6.P4/BR-7 flood) — **no attribute filter at registration**; everything funnels through it (statics, scenery, weenies, building shell, cell BSP).
|
|
||||||
|
|
||||||
**(d) One per-apply rebase** — every cached collision transform (cells, buildings, shadow positions) rebased to the current streaming center on each per-landblock apply via a symmetric `Remove*ForLandblock` pre-clear. *Add `RemoveCellsForLandblock` (D8).* (Or fold into the #145 cell-relative-frame port so collision frames can never go stale — the handoff's preferred long-term home.)
|
|
||||||
|
|
||||||
### Deletions
|
|
||||||
- `GameWindow.cs:7408-7565` mesh-AABB synthesized-cylinder fallback (D1).
|
|
||||||
- `CollisionExemption.cs:78` ETHEREAL-alone instant-skip → replace with both-bits Gate-1 + `ObstructionEthereal`-set-and-continue (D2); move ACE door-open compat to the **wire/adaptation layer**, not the collision predicate.
|
|
||||||
- `ShadowShapeBuilder.cs:68-81` sphere→cylinder coercion → true Sphere shape + primitive (D3).
|
|
||||||
- `TransitionTypes.cs:2629` hardcoded `pvpExempt/missileIgnore=false` → wire real terms (W1).
|
|
||||||
|
|
||||||
### Risks (each needs a user visual gate)
|
|
||||||
- **D1 removal makes shape-less scenery walk-through** — retail-faithful, but a *visible behavior change* (trees you currently bump may become passable; trees with real canopy-only BSP regain retail behavior). **Highest-risk item; gate carefully.**
|
|
||||||
- **D2 re-solidifies ACE-opened doors** unless the door-open wire path sends `ETHEREAL|IGNORE_COLLISIONS` in the **same** change — must land together or doors become impassable.
|
|
||||||
- **D2/D5 obstruction_ethereal** touches BSP solid-containment gates — port set+clear+consume **together**; a partial port hardens/weakens walls unpredictably.
|
|
||||||
- **W1 PvP/missile wiring** has no live M1.5 test path — risk of regressing the A6.P7 BSP-only door dispatch; gate + conformance-test the door case.
|
|
||||||
- **D3 sphere primitive** is new physics code — conformance-test vs `CSphere::intersects_sphere` golden values, not eyeballed.
|
|
||||||
- **D8 eviction** interacts with #135/#138 dungeon collapse/recenter — adding eviction could surface a re-stream ordering bug currently masked by "never evict"; verify against teleport-OUT.
|
|
||||||
|
|
||||||
### Suggested slicing (for the brainstorm)
|
|
||||||
1. **Shape-source purity (D1)** — delete mesh-AABB synthesis; the highest-value, most-isolated, retail-clarifying change. Visual gate: scenery + Holtburg.
|
|
||||||
2. **Sphere primitive (D3)** — unblocks faithful shape dispatch; conformance-tested.
|
|
||||||
3. **ETHEREAL/obstruction_ethereal (D2+D5)** — predicate + state field + door wire compat, landed together.
|
|
||||||
4. **Cell-transform rebase (D8)** — or fold into the #145 cell-relative-frame phase.
|
|
||||||
5. **Predicate wiring + entry-restrictions (W1+D4)** — lowest urgency (no M1.5 effect); wire as conservative no-ops now, activate when PK/missiles/locked-cells ship.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Divergence-register bookkeeping required
|
|
||||||
- **Widen AP-2** to cite `GameWindow.cs:7408` (scenery mesh-AABB), not only the Setup case (D1).
|
|
||||||
- **AD-7** already covers D2 — keep; retire it when the obstruction_ethereal port lands.
|
|
||||||
- **AP-6** already covers the analytic-cylinder-vs-retail-CylSphere dispatch — relevant to D3/W1.
|
|
||||||
- **New rows:** D3 (sphere-as-cylinder), D4 (entry-restrictions omitted), D5 (obstruction_ethereal absent from SpherePath), D8 (cell-struct transform never evicted). Optional: a terrain-pass-through note for the D7 streaming-gap (clarify it's #135/#138, not a collision bug) so nobody re-files it.
|
|
||||||
|
|
||||||
## Apparatus for the implementation phase (reuse, don't rebuild)
|
|
||||||
`ACDREAM_PROBE_BUILDING` (`[entity-source]` BSP-vs-Cylinder classification per static — directly shows which objects got the D1 synthetic cylinder), `ACDREAM_CAPTURE_RESOLVE`, `Issue147ArwicBuildingsDumpTests` (dat-dump pattern), the `CellarUp*`/`HouseExitWalk*`/`CornerFlood*` replay harnesses, and a new `CSphere::intersects_sphere` conformance fixture for D3.
|
|
||||||
|
|
||||||
---
|
|
||||||
*Generated by a 19-agent verified workflow (`wf_b2f8da74-9fa`): 10 readers → synthesis → 8 adversarial verifiers. 6/8 candidate deviations survived adversarial verification; 2 were refuted (D6, D7) with the retail evidence corrected.*
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
# Handoff — collision-object-detection vs retail: a unifying audit ("1 fix for all collision") — 2026-06-24
|
|
||||||
|
|
||||||
**Branch/worktree:** `claude/thirsty-goldberg-51bb9b`. Named-retail + references live in the MAIN repo
|
|
||||||
`C:\Users\erikn\source\repos\acdream\`.
|
|
||||||
|
|
||||||
## The ask (user, 2026-06-24)
|
|
||||||
|
|
||||||
> "Deep-dive in how we detect what objects have collision compared to retail. I expect/know there are
|
|
||||||
> deviations vs retail. Thorough checking of how retail checks what objects have collision and how it
|
|
||||||
> deviates from our implementation. This should be **1 fix for all collision**."
|
|
||||||
|
|
||||||
So: **NOT another whack-a-mole symptom fix.** Build the retail model of *"which objects collide, and how
|
|
||||||
the engine decides that"*, map acdream's model against it, enumerate every deviation, and design **one
|
|
||||||
unified, retail-faithful collision-inclusion mechanism** that replaces acdream's scattered per-channel
|
|
||||||
hand-rolled filters. This is a research → brainstorm → multi-commit phase. **Brainstorm-gate it**
|
|
||||||
(`superpowers:brainstorming`) before writing the port. **No guess-patches** — this is the DO-NOT-RETRY
|
|
||||||
collision area; grep named-retail FIRST, decompile, pseudocode, port, conformance-test.
|
|
||||||
|
|
||||||
## Why now — the pattern the 2026-06-24 session exposed
|
|
||||||
|
|
||||||
This session fixed three collision bugs, and **two of them were the *same shape*: an ad-hoc per-channel
|
|
||||||
filter that diverges from retail's unified model.**
|
|
||||||
|
|
||||||
| Commit | Issue | The deviation it revealed |
|
|
||||||
|---|---|---|
|
|
||||||
| `afd5f2a` | #138-B | Avatar relocated into a dead landblock during PortalSpace (lifecycle, not inclusion) |
|
|
||||||
| `49d743f` | #146 | **Building collision shell cached at a stale `_liveCenter` frame** — terrain re-bases per apply, the building cache didn't. Streaming-relative-frame staleness. |
|
|
||||||
| `9743537` | #147 | **Portal-less buildings (city/perimeter walls) skipped** by `if (building.Portals.Count == 0) continue;` — a *transit-feature* filter that wrongly gated *collision*. We used "has a doorway" to decide "is solid". |
|
|
||||||
|
|
||||||
#147 is the smoking gun for the user's intuition: **acdream decides "does this object collide" with
|
|
||||||
per-channel attributes that have nothing to do with retail's actual predicate.** There are almost
|
|
||||||
certainly more of these. The goal is to replace the lot with retail's real rule.
|
|
||||||
|
|
||||||
## The retail model — what to research (the oracle)
|
|
||||||
|
|
||||||
Retail (`docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep by `class::method`) decides
|
|
||||||
collision in two layers. Map BOTH precisely:
|
|
||||||
|
|
||||||
### A. The inclusion predicate — "does this object participate in collision at all?"
|
|
||||||
- **`PhysicsState` flags** on `CPhysicsObj` (grep `acclient.h` for the `PhysicsState` enum). The
|
|
||||||
load-bearing ones:
|
|
||||||
- **`ETHEREAL`** → no collision (pass-through). Confirm acdream honors this on EVERY channel, not just
|
|
||||||
interaction-pick. (See `[[project_interaction_pipeline]]` ETHEREAL-alone exemption — that's
|
|
||||||
*selection*, not *collision*; verify the collision side.)
|
|
||||||
- **`HAS_PHYSICS_BSP` / `HAS_PHYSICS_BSP_PS`** → the object collides via its BSP; otherwise via a
|
|
||||||
cylinder/sphere. This is the binary dispatch (`[[feedback_retail_binary_dispatch]]`,
|
|
||||||
`BspOnlyDispatch` flag `0x00010000`).
|
|
||||||
- Other gates to check: `STATIC`, `REPORT_COLLISIONS`, `IGNORE_COLLISIONS`, `MISSILE`, `NODRAW` (NODRAW
|
|
||||||
is render-only — confirm it does NOT gate collision), `SCRIPTED_COLLISION`.
|
|
||||||
- **The object's physics shape**: does it have a `CPhysicsBSP` (collision BSP) vs a `CylSphere`? Retail
|
|
||||||
uses whatever the dat/weenie says — it does NOT fabricate collision from a render mesh AABB. (acdream's
|
|
||||||
`IsPhantomGfxObjSource` / mesh-AABB-fallback is a known divergence — #101.)
|
|
||||||
|
|
||||||
### B. The registration / dispatch model — "where does the engine look for colliders?"
|
|
||||||
- **`CPhysicsObj::FindObjCollisions`** (the per-tick collider) — picks BSP-only vs cyl+sphere on the
|
|
||||||
flag above, iterates the **per-cell `shadow_object_list`**.
|
|
||||||
- **`CObjCell::add_shadows_to_cells` / `find_cell_list` / `calc_cross_cells`** — an object registers a
|
|
||||||
*shadow* into every cell its sphere overlaps; the per-cell list is the broad phase. (acdream ported
|
|
||||||
this as the A6.P4/BR-7 `ShadowObjectRegistry` flood — see `[[project_physics_collision_digest]]`.)
|
|
||||||
- **`CBuildingObj::find_building_collisions`** (`0x006b5300`) — the building **shell** BSP, tested on
|
|
||||||
`part_array->parts[0]`, **independent of the portal list**. (This session's #147 fix aligned acdream
|
|
||||||
to this.) `CLandBlock::init_buildings` (`0x0052fd80`) builds one building per origin landcell.
|
|
||||||
- **Terrain**: `CLandCell::find_env_collisions` (terrain polys) + the eye-side cull.
|
|
||||||
- **EnvCell (indoor)**: `CEnvCell::find_env_collisions` (`0x0052c100`) — the cell's own BSP; **no
|
|
||||||
building leg** (acdream matches this at `TransitionTypes.cs:2807`).
|
|
||||||
|
|
||||||
**Deliverable from this layer:** a single table — for each object class (terrain, building shell,
|
|
||||||
EnvCell shell, static stab, scenery, server weenie/door/NPC/item, missile) — *what makes it collide in
|
|
||||||
retail* (which flags, which shape, which registration), with the named-retail function + address.
|
|
||||||
|
|
||||||
## The acdream model — the code map to audit against the table
|
|
||||||
|
|
||||||
acdream has **separate channels with different inclusion filters**. Walk each and record its de-facto
|
|
||||||
"does it collide" predicate, then diff against retail's:
|
|
||||||
|
|
||||||
1. **Terrain** — `PhysicsEngine.SampleTerrainWalkable` / `TransitionTypes.cs:2229` (`// no terrain loaded
|
|
||||||
→ allow pass-through`, `:2247`). Re-bases per apply via `AddLandblock(origin)` (so no #146-style
|
|
||||||
staleness). Filter: terrain residency only.
|
|
||||||
2. **Buildings** — `TransitionTypes.FindBuildingCollisions` (`:2805`), `PhysicsDataCache.CacheBuilding`
|
|
||||||
(`:441`), the cache loop (`GameWindow.cs` ~`:6958`). Post-session: re-bases per apply (#146) +
|
|
||||||
includes portal-less (#147). Remaining filter: `ModelId == 0 → inert` (`:2813`); the `[bldg-channel]`
|
|
||||||
probe (`ACDREAM_PROBE_BUILDING`, logs `bldOrigin`) is the live lens.
|
|
||||||
3. **Shadow objects** (statics, scenery, server weenies) — `Core/Physics/ShadowObjectRegistry.cs`
|
|
||||||
(per-cell flood, BR-7). Registration sites: `GameWindow.cs` `ShadowObjects.Register*` (~`:7196–7526`),
|
|
||||||
`RefloodLandblock` (`:7595`), `RegisterMultiPart` (`:3835`). Server weenies register via
|
|
||||||
`OnLiveEntitySpawnedLocked` → Register. **Filters to scrutinize for divergence:**
|
|
||||||
- `LandblockLoader.IsSupported` (GfxObj/Setup mask only) — does it match retail's "has a physics
|
|
||||||
shape"? What about `0x0D`/other types?
|
|
||||||
- **mesh-AABB fallback** / `IsPhantomGfxObjSource` (#101) — acdream fabricates a collision box from
|
|
||||||
the render mesh when no physics BSP exists. **Retail does not.** Prime deviation.
|
|
||||||
- cyl-vs-BSP dispatch (A6.P7, `Transition.BspOnlyDispatch`) — verify it matches `HAS_PHYSICS_BSP_PS`.
|
|
||||||
- editor-marker degrade-hide (#136) — those are render-hidden; confirm collision treatment matches.
|
|
||||||
- ETHEREAL: does the live-spawn path exclude ETHEREAL weenies from shadow registration?
|
|
||||||
4. **EnvCell (indoor)** — cell BSP via the transition; `CellTransit` per-cell iteration.
|
|
||||||
5. **The streaming-relative frame** — #146 proved a cached collision transform can go stale on recenter.
|
|
||||||
**Audit every cached collision transform** (shadow objects? EnvCell? portal planes?) for the same
|
|
||||||
staleness; or fold into the #145 cell-relative-frame port so collision frames can NEVER go stale.
|
|
||||||
|
|
||||||
## Suspected deviations to confirm/refute (seed list)
|
|
||||||
|
|
||||||
- **Attribute-as-filter category errors** (the #147 pattern): grep the registration/collision paths for
|
|
||||||
any `continue`/skip gated on a NON-collision property (portals, name, render flags, degrade, item
|
|
||||||
type). Each is a candidate divergence.
|
|
||||||
- **mesh-AABB phantom collision** (#101) — retail-absent; should it exist at all?
|
|
||||||
- **ETHEREAL not honored on the collision side** of one or more channels.
|
|
||||||
- **`IsSupported`/type filters** excluding objects retail collides with (or including ones it doesn't).
|
|
||||||
- **Stale cached collision transforms** beyond buildings (#146 class).
|
|
||||||
- **Static vs dynamic registration mismatch** — landblock stabs/scenery vs server weenies taking
|
|
||||||
different inclusion paths with different rules.
|
|
||||||
|
|
||||||
## The unifying target ("1 fix for all collision")
|
|
||||||
|
|
||||||
One retail-faithful **collision descriptor per object**, derived once from its `PhysicsState` + physics
|
|
||||||
shape (BSP vs cyl, or none = ethereal/no-collide), and **one registration rule** (overlap-based per-cell
|
|
||||||
shadow lists) used by **every** channel — replacing the per-channel ad-hoc filters. Concretely the
|
|
||||||
output is likely: (a) a single `ObjectCollides(state, shape)` predicate matching retail's flag logic,
|
|
||||||
(b) a single registration path all object sources funnel through, (c) deletion of the mesh-AABB phantom
|
|
||||||
fallback and the attribute-as-filter skips. Expect multi-commit; gate with brainstorming; conformance-
|
|
||||||
test against named-retail golden values + the existing replay harnesses (`CellarUp*`, `HouseExitWalk*`,
|
|
||||||
`CornerFlood*`, the new `Issue147ArwicBuildingsDumpTests` fixture).
|
|
||||||
|
|
||||||
## Method (mandatory — CLAUDE.md workflow)
|
|
||||||
|
|
||||||
1. `superpowers:brainstorming` to scope the phase + agree the unified model BEFORE code.
|
|
||||||
2. **Grep named-retail FIRST** for every function in the retail-model table; decompile via Ghidra MCP
|
|
||||||
(`patchmem.gpr`, port 8081) for any single function that's noisy in the bulk text.
|
|
||||||
3. Cross-reference **ACE** `Physics/Common` (PhysicsObj, ObjCell, shadow lists) + **ACViewer**
|
|
||||||
`Physics/Common` + **holtburger** `spatial/physics.rs` for the client-side read.
|
|
||||||
4. Write pseudocode (`docs/research/*_pseudocode.md`), port faithfully, conformance-test, then one
|
|
||||||
comprehensive user visual gate.
|
|
||||||
|
|
||||||
## Apparatus already in place (use it, don't rebuild)
|
|
||||||
- `ACDREAM_PROBE_BUILDING` → `[bldg-channel]` (now logs `bldOrigin`) + `[entity-source]` (BSP vs Cylinder
|
|
||||||
classification per static — directly shows what collision shape each object got).
|
|
||||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` → per-player-resolve JSONL (grounded / contactPlane / collisionNormal
|
|
||||||
per frame). Heavy (FPS) — short targeted runs only; the `analyze_*` streaming-tally pattern works
|
|
||||||
(255k records in seconds).
|
|
||||||
- `ACDREAM_PROBE_ENT` / `EntityVanishProbe` → avatar draw-set lifecycle (#138-B).
|
|
||||||
- `Issue147ArwicBuildingsDumpTests` → the dat-dump pattern for `LandBlockInfo.Buildings`/`.Objects`
|
|
||||||
(portal counts, model ids); clone it for any landblock/object-class inventory.
|
|
||||||
- Live client: user `notan` / `MittSnus81!`, ACE `127.0.0.1:9000`; graceful close before rebuild.
|
|
||||||
|
|
||||||
## Context — what shipped this session (the motivation)
|
|
||||||
`afd5f2a` #138-B avatar-vanish, `49d743f` #146 building-frame re-base, `9743537` #147 portal-less-wall
|
|
||||||
collision. ISSUES #146 DONE, #147 walls DONE (terrain-3%-grounded residual re-scoped LOW — verify it's
|
|
||||||
even real before acting; see #147). The deep #145 cell-relative-frame port is still its own future phase
|
|
||||||
and is the natural home for "collision frames can never go stale."
|
|
||||||
|
|
||||||
## Paste-ready prompt for the next session
|
|
||||||
|
|
||||||
> Deep-dive + unifying fix: **how retail decides which objects have collision, vs acdream.** Read this
|
|
||||||
> handoff (`docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md`) and the
|
|
||||||
> physics digest (`claude-memory/project_physics_collision_digest.md`) FIRST. Goal: build retail's
|
|
||||||
> collision-inclusion model (the `PhysicsState`/ETHEREAL/HAS_PHYSICS_BSP predicate + the per-cell
|
|
||||||
> shadow-list registration + the building-shell/terrain/EnvCell channels — grep named-retail, cite
|
|
||||||
> addresses), map acdream's per-channel filters against it (terrain / FindBuildingCollisions /
|
|
||||||
> ShadowObjectRegistry / EnvCell / mesh-AABB phantom / IsSupported / the cyl-BSP dispatch), enumerate
|
|
||||||
> every deviation, and propose ONE unified retail-faithful collision-inclusion mechanism to replace the
|
|
||||||
> ad-hoc filters. **Report-first / brainstorm-gated, no guess-patches** (DO-NOT-RETRY collision area).
|
|
||||||
> Recent precedent (the per-channel filter divergences this is meant to end): #146 (`49d743f`),
|
|
||||||
> #147 (`9743537`).
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
# obstruction_ethereal — set / clear / consume contract
|
|
||||||
|
|
||||||
**Research date:** 2026-06-24
|
|
||||||
**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
|
||||||
**Phase:** Task 3 of the collision-inclusion verbatim-retail port
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Gate 1 — `CPhysicsObj::FindObjCollisions` (pc:276782 / 0x0050f050)
|
|
||||||
|
|
||||||
```
|
|
||||||
// pc:276782
|
|
||||||
if (state & 4) AND (state & 0x10): // ETHEREAL_PS | IGNORE_COLLISIONS_PS
|
|
||||||
return 1 // OK_TS — instant-skip, no further work
|
|
||||||
// else fall through: ETHEREAL-alone goes into the block below
|
|
||||||
|
|
||||||
// pc:276802
|
|
||||||
int32_t var_c;
|
|
||||||
if ((state_2 & 4) != 0 // target ETHEREAL
|
|
||||||
|| (ebx->object_info.ethereal != 0 // mover is ethereal
|
|
||||||
&& (state_2 & 1) == 0)): // AND target not REPORTS_COLLISIONS
|
|
||||||
var_c = 1
|
|
||||||
if (sphere_path.step_down == 0):
|
|
||||||
goto label_50f0c9 // set obstruction_ethereal = 1 and continue
|
|
||||||
else:
|
|
||||||
var_c = 0
|
|
||||||
// label_50f0c9:
|
|
||||||
ebx->sphere_path.obstruction_ethereal = var_c // pc:276806
|
|
||||||
// ... continue with shape dispatch (FindObjCollisions body: BSP / Sphere / Cyl) ...
|
|
||||||
|
|
||||||
// At the END of the FindObjCollisions body (after all shape tests):
|
|
||||||
// pc:276989 / 0x0050f31e:
|
|
||||||
ebx->sphere_path.obstruction_ethereal = 0 // clear after each object's test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Translation (C#):**
|
|
||||||
- `ShouldSkip` returns `true` ONLY when `(targetState & 0x4) != 0 && (targetState & 0x10) != 0`.
|
|
||||||
- ETHEREAL-alone falls through. Before the shape dispatch (BSP/Sphere/Cyl call), set
|
|
||||||
`sp.ObstructionEthereal = true` when `(targetState & 0x4) != 0`.
|
|
||||||
- After the shape dispatch, retail clears the flag (`= 0`). In acdream we clear it at the
|
|
||||||
END of the per-target loop iteration (mirrors the per-object clear at pc:276989).
|
|
||||||
|
|
||||||
**Note on `step_down` gate:** retail's `var_c` assignment also checks `step_down == 0`
|
|
||||||
before jumping to `label_50f0c9`. When `step_down != 0`, var_c stays 1 but hits the
|
|
||||||
`else var_c = 0` branch at pc:276804 (which sets var_c = 0). Reading the full
|
|
||||||
branching tree: the ONLY path that sets `obstruction_ethereal = 1` is when
|
|
||||||
`(state_2 & 4) != 0` (target ETHEREAL) AND `step_down == 0`. For acdream's
|
|
||||||
transitional player insert, `step_down` is false at the outer loop entry, so the flag
|
|
||||||
fires when target is ETHEREAL. We match retail exactly by gating on target ETHEREAL
|
|
||||||
(the `(obj.State & 0x4) != 0` check in the loop).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. D5 clear — `CEnvCell::find_env_collisions` (pc:309580 / 0x0052c144)
|
|
||||||
|
|
||||||
```
|
|
||||||
// 0x0052c144:
|
|
||||||
arg2->sphere_path.obstruction_ethereal = 0;
|
|
||||||
// ... then: BSP dispatch for the ENV cell walls (not objects) ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Translation (C#):** At the top of `FindEnvCollisions`, clear `sp.ObstructionEthereal = false`
|
|
||||||
before any BSP dispatch. The ENV path clears it because ENV walls are always solid — the
|
|
||||||
flag only applies to object (CPhysicsObj) tests. This prevents a stale flag from a
|
|
||||||
prior object loop from weakening ENV wall tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Consume site 1 — `CSphere::intersects_sphere` (pc:321692 / 0x00537ae4)
|
|
||||||
|
|
||||||
```
|
|
||||||
// pc:321692:
|
|
||||||
if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT):
|
|
||||||
// sphere_intersects_solid test — allows passage if sphere is NOT inside solid
|
|
||||||
if (collides_with_sphere(center, pos, radius_sum)):
|
|
||||||
return 2 // COLLIDED — solid containment
|
|
||||||
// else: sphere is NOT inside solid, passage allowed — no push-back
|
|
||||||
else if (step_down == 0):
|
|
||||||
// normal walkable / check_walkable / slide path (the BLOCKING path)
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**What this means:** when `obstruction_ethereal` (target is passable-ethereal) or
|
|
||||||
`PLACEMENT_INSERT`, the test only fails if the sphere FULLY OVERLAPS the solid region
|
|
||||||
of the sphere target. In practice for a Sphere-type entity with no BSP this means
|
|
||||||
you can walk through it as long as your sphere center isn't inside its solid — which
|
|
||||||
is almost never true during normal movement. The blocker path (slide/push-back) is
|
|
||||||
bypassed entirely.
|
|
||||||
|
|
||||||
**acdream scope note:** `SphereCollision` in `TransitionTypes.cs` does NOT currently
|
|
||||||
implement this gate; it always does the slide path. That is a separate task (Task 5 or
|
|
||||||
equivalent). For Task 3 scope: BSP objects are the priority for opened doors; the
|
|
||||||
Sphere path is a separate port.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Consume site 2 — `BSPTREE::find_collisions` (pc:323742 / 0x0053a496)
|
|
||||||
|
|
||||||
```
|
|
||||||
// pc:323742:
|
|
||||||
if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0):
|
|
||||||
// sphere_intersects_solid with bldg_check center_solid flag
|
|
||||||
ebp_4 = 1
|
|
||||||
if (bldg_check != 0):
|
|
||||||
ebp_4 = (hits_interior_cell == 0) // center_solid weakens inside buildings
|
|
||||||
if (root_node->sphere_intersects_solid(localspace_sphere, ebp_4)):
|
|
||||||
return 2 // COLLIDED
|
|
||||||
if (num_sphere > 1):
|
|
||||||
if (root_node->sphere_intersects_solid(localspace_sphere[1], ebp_4)):
|
|
||||||
return 2
|
|
||||||
else:
|
|
||||||
// check_walkable / step_down / normal blocking path
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**This is the BSPQuery Path 1 at `BSPQuery.cs:1717`.** The existing code:
|
|
||||||
```csharp
|
|
||||||
if (path.InsertType == InsertType.Placement || obj.Ethereal)
|
|
||||||
```
|
|
||||||
`obj.Ethereal` is `ObjectInfo.Ethereal` (the MOVER's ethereal flag) which is NEVER
|
|
||||||
set — it's always false. The correct translation of `obstruction_ethereal` is
|
|
||||||
`path.ObstructionEthereal` (on `SpherePath`).
|
|
||||||
|
|
||||||
**Fix:** change to:
|
|
||||||
```csharp
|
|
||||||
if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the ONLY consume site for Task 3 scope (BSP objects). For the BSP path:
|
|
||||||
when the target is ETHEREAL, `path.ObstructionEthereal` is set before calling
|
|
||||||
`BSPQuery.FindCollisions`, so Path 1 fires instead of the normal blocking path.
|
|
||||||
Path 1 uses `sphere_intersects_solid` which only returns COLLIDED if the player
|
|
||||||
is inside a BSP solid leaf — during forward movement toward a door (which has no
|
|
||||||
solid wall when open), the BSP finds no solid intersection, returns OK, player passes
|
|
||||||
through.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Consume site 3 — `CCylSphere::intersects_sphere` (pc:324573 / 0x0053b4a0)
|
|
||||||
|
|
||||||
```
|
|
||||||
// pc:324573:
|
|
||||||
if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0):
|
|
||||||
// collides_with_sphere test (3D overlap only)
|
|
||||||
if (CCylSphere::collides_with_sphere(this, global_sphere, ...)):
|
|
||||||
return // early-out, no Collided return — void return here
|
|
||||||
// else: no collision, return (void — passable)
|
|
||||||
else:
|
|
||||||
// step_down / check_walkable / normal blocking path
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**acdream implementation (Task 3 follow-up, 2026-06-24):** `CylinderCollision` in
|
|
||||||
`TransitionTypes.cs` now implements this gate as `if (sp.ObstructionEthereal) return OK`
|
|
||||||
at the top of the method — mirroring exactly how `SphereCollision` (consume site 1) is
|
|
||||||
handled. The retail function is `void __thiscall`, so all returns in the ethereal branch
|
|
||||||
are void (= OK). An ethereal Cylinder (e.g. an NPC ghost) is now fully passable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3b. Consume site 1 — `CSphere::intersects_sphere` (pc:321692) — full contract
|
|
||||||
|
|
||||||
The full decomp at pc:321692 / 0x537ae4 confirms:
|
|
||||||
|
|
||||||
```
|
|
||||||
// pc:321692:
|
|
||||||
if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT):
|
|
||||||
// distSq check — if sphere NOT overlapping: return (void = passable)
|
|
||||||
if (distSq < radiusSumSq): // overlapping:
|
|
||||||
// calls collides_with_sphere on sphere[1] if num_sphere > 1
|
|
||||||
// returns void — no COLLIDED
|
|
||||||
return // void = passable in all cases
|
|
||||||
else:
|
|
||||||
// step_down / check_walkable / slide blocking paths
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key:** the `CSphere::intersects_sphere` function is `void __thiscall` — there is NO
|
|
||||||
`return 2` (COLLIDED) from the ethereal branch. The inner `collides_with_sphere` call
|
|
||||||
(for the penetrating case) also produces no blocking result (it may contribute to a
|
|
||||||
side-channel contact; it does NOT stop the player). The player is passable in ALL
|
|
||||||
sub-cases of the ethereal branch.
|
|
||||||
|
|
||||||
**acdream implementation (Task 3 follow-up, 2026-06-24):** `SphereCollision` implements
|
|
||||||
`if (sp.ObstructionEthereal) return OK` at the top. The inner overlap/slide path is
|
|
||||||
bypassed — matches the void-return semantics of the retail ethereal branch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary: set / clear / consume flow (complete after Task 3 follow-up)
|
|
||||||
|
|
||||||
```
|
|
||||||
FindObjCollisionsLoop():
|
|
||||||
for each obj in cell.shadow_entries:
|
|
||||||
// Gate 1: instant-skip needs BOTH bits
|
|
||||||
if obj.State has ETHEREAL AND IGNORE_COLLISIONS:
|
|
||||||
continue // pass through, no obstruction_ethereal
|
|
||||||
|
|
||||||
// Set flag for ETHEREAL-alone
|
|
||||||
sp.ObstructionEthereal = (obj.State & 0x4) != 0
|
|
||||||
|
|
||||||
// Shape dispatch — ALL three shapes now consume the flag:
|
|
||||||
// BSP: BSPQuery.FindCollisions Path 1 → sphere_intersects_solid (passable)
|
|
||||||
// Sphere: SphereCollision → if (ObstructionEthereal) return OK
|
|
||||||
// Cylinder: CylinderCollision → if (ObstructionEthereal) return OK
|
|
||||||
|
|
||||||
// Clear after per-object test (retail pc:276989)
|
|
||||||
sp.ObstructionEthereal = false
|
|
||||||
|
|
||||||
FindEnvCollisions():
|
|
||||||
sp.ObstructionEthereal = false // D5 clear — ENV walls are always solid
|
|
||||||
// BSP dispatch against cell walls ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Contracts (complete):**
|
|
||||||
- ETHEREAL-alone BSP target (e.g. open door): `ShouldSkip` = false → flag set → BSP
|
|
||||||
Path 1 fires → `sphere_intersects_solid` → player walks through open door. Passable.
|
|
||||||
- ETHEREAL-alone Sphere target (e.g. ghost NPC with sphere shape): flag set →
|
|
||||||
`SphereCollision` returns OK immediately. Passable.
|
|
||||||
- ETHEREAL-alone Cylinder target (e.g. ghost NPC with cyl shape): flag set →
|
|
||||||
`CylinderCollision` returns OK immediately. Passable.
|
|
||||||
- Non-ethereal wall/object: flag = false → normal blocking paths. Blocked.
|
|
||||||
- ENV cell walls: flag cleared before BSP dispatch → ENV BSP always blocking. Blocked.
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
# CSphere::intersects_sphere — swept-sphere-vs-sphere pseudocode
|
|
||||||
|
|
||||||
**Date:** 2026-06-24
|
|
||||||
**Task:** Task 2 — true sphere collision primitive (collision-inclusion phase)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Oracles consulted
|
|
||||||
|
|
||||||
1. **Named-retail decomp** `acclient_2013_pseudo_c.txt`:
|
|
||||||
- `CSphere::collides_with_sphere` @ `0x005369E0` — static overlap test
|
|
||||||
- `CSphere::intersects_sphere` (primary) @ `0x00537A80` — the full 6-path dispatcher
|
|
||||||
- `CSphere::intersects_sphere` (Position variant) @ `0x00537FD0`
|
|
||||||
|
|
||||||
2. **ACE C# port** `references/ACE/Source/ACE.Server/Physics/Sphere.cs`:
|
|
||||||
- `CollidesWithSphere(Vector3 otherSphere, float radsum)` — static overlap
|
|
||||||
- `FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)` — swept solve
|
|
||||||
- `IntersectsSphere(Vector3 center, float radius, Transition transition, bool isCreature)` — 6-path dispatcher
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CSphere::collides_with_sphere (static overlap test)
|
|
||||||
|
|
||||||
Retail @ `0x005369E0`:
|
|
||||||
|
|
||||||
```
|
|
||||||
collides_with_sphere(this, disp_vec3, radsum_float):
|
|
||||||
lenSq = disp_vec3.x² + disp_vec3.y² + disp_vec3.z²
|
|
||||||
if radsum² > lenSq: // i.e. lenSq < radsum²
|
|
||||||
return 1 (true — overlapping)
|
|
||||||
return 0 (false)
|
|
||||||
```
|
|
||||||
|
|
||||||
ACE equivalent: `disp.LengthSquared() <= radsum * radsum`
|
|
||||||
Note: retail uses `>` (strictly greater-than radsum²), ACE uses `<=`. These are the same predicate — the
|
|
||||||
retail FPU instruction emits "collides" when radsum² is NOT less than lenSq, which is `lenSq <= radsum²`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FindTimeOfCollision (swept quadratic, from ACE)
|
|
||||||
|
|
||||||
ACE `Sphere.FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)`:
|
|
||||||
|
|
||||||
Interprets "mover starts at origin, travels by `movement`; target is at `spherePos` relative to mover".
|
|
||||||
|
|
||||||
```
|
|
||||||
distSq = |movement|² // if < EPSILON: no sweep (degenerate), return -1
|
|
||||||
nonCollide = |spherePos|² - radSum² // if < EPSILON: already overlapping → no forward collision needed, return -1
|
|
||||||
similar = -dot(spherePos, movement) // projection of separation onto movement direction
|
|
||||||
disc = similar² - nonCollide * distSq // discriminant of quadratic
|
|
||||||
if disc < 0: return -1 // no real intersection
|
|
||||||
cDist = sqrt(disc)
|
|
||||||
if similar - cDist < 0:
|
|
||||||
return -(cDist + similar) / distSq
|
|
||||||
else:
|
|
||||||
return -(similar - cDist) / distSq
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns a time in the range [0, 1] for the first contact.
|
|
||||||
A return of -1 means no hit (miss or already overlapping).
|
|
||||||
Values > 1 mean the sweep doesn't reach the target within the movement step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SweptSphereHitsSphere — our primitive (pure function)
|
|
||||||
|
|
||||||
Wraps `FindTimeOfCollision` with a clean bool/out API for the narrow-phase dispatch:
|
|
||||||
|
|
||||||
```
|
|
||||||
SweptSphereHitsSphere(moverCenter, moverRadius, sweepDelta, targetCenter, targetRadius, out float t):
|
|
||||||
movement = sweepDelta // vector the mover travels
|
|
||||||
spherePos = targetCenter - moverCenter // target relative to mover's start
|
|
||||||
radSum = moverRadius + targetRadius
|
|
||||||
t = (float) FindTimeOfCollision(movement, spherePos, radSum)
|
|
||||||
return t > 0 && t <= 1
|
|
||||||
```
|
|
||||||
|
|
||||||
`t` is the parametric fraction of `sweepDelta` at which surfaces first touch.
|
|
||||||
`t <= 0`: target is behind or already overlapping (use static test separately).
|
|
||||||
`t > 1`: sweep misses (target too far in this step).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Retail dispatch order for Sphere objects
|
|
||||||
|
|
||||||
From `CSphere::intersects_sphere @ 0x00537A80` — the same 6-path structure as for CylSpheres:
|
|
||||||
|
|
||||||
1. `obstruction_ethereal || insert_type == PLACEMENT_INSERT`:
|
|
||||||
Static overlap test only (`collides_with_sphere`). Return Collided or OK.
|
|
||||||
|
|
||||||
2. `step_down != 0`:
|
|
||||||
Delegates to `step_sphere_down` (for non-creature movers).
|
|
||||||
|
|
||||||
3. `check_walkable != 0`:
|
|
||||||
Static overlap test. Return Collided or OK.
|
|
||||||
|
|
||||||
4. `collide == 0`:
|
|
||||||
Sub-dispatch on `object_info.state & 3` (Contact/OnWalkable):
|
|
||||||
- Contact: step_sphere_up or slide_sphere
|
|
||||||
- PathClipped: collide_with_point
|
|
||||||
- Default: land_on_sphere or collide_with_point
|
|
||||||
|
|
||||||
5. `collide != 0` + `isCreature`:
|
|
||||||
Return OK (creatures don't block each other via sphere-sphere in this path).
|
|
||||||
|
|
||||||
6. `collide != 0` + not creature:
|
|
||||||
Full swept quadratic. Set contact plane, adjust check_pos.
|
|
||||||
|
|
||||||
For our narrow-phase dispatch in `FindObjCollisionsInCell`, the "narrow-phase Sphere branch"
|
|
||||||
maps directly to ACE's `IntersectsSphere` — which acdream already implements for Cylinder objects
|
|
||||||
via `CylinderCollision`. The sphere primitive just provides the swept check without the cylinder's
|
|
||||||
height clipping.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acdream adaptation note
|
|
||||||
|
|
||||||
The `SweptSphereHitsSphere` primitive is PURE (no Transition state). The actual 6-path dispatch
|
|
||||||
(step-up, land-on, slide, etc.) is handled by the existing `CylinderCollision` infrastructure —
|
|
||||||
for Sphere-typed shadow entries we call through the same dispatcher after the overlap check,
|
|
||||||
using 3-D distance for the broad-phase (not XY-only cylinder distance).
|
|
||||||
|
|
||||||
The primitive's narrow phase: `static overlap` (`CollidesWithSphere`) is the gate; the swept
|
|
||||||
quadratic from `FindTimeOfCollision` resolves the time-of-contact for the walkable landing path.
|
|
||||||
|
|
||||||
For the initial ship (Task 2), we implement the static overlap test in the dispatch
|
|
||||||
(matching the `obstruction_ethereal`/`check_walkable`/`Contact` paths that don't use the swept
|
|
||||||
form), plus `SweptSphereHitsSphere` for the swept narrow-phase. The full 6-path wiring for
|
|
||||||
sphere objects mirrors the cylinder path already in `CylinderCollision`, extended to use 3-D
|
|
||||||
distance instead of XY-only.
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
# Handoff — two teleport-OUT issues (no-collision-after-death, char-missing-after-portal) — 2026-06-24
|
|
||||||
|
|
||||||
**Branch/worktree:** observed on `claude/thirsty-goldberg-51bb9b`
|
|
||||||
(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
|
|
||||||
References / named-retail live in the MAIN repo `C:\Users\erikn\source\repos\acdream\`.
|
|
||||||
|
|
||||||
> **These are NOT regressions from the dense-town FPS work.** That work (commits
|
|
||||||
> `290e731` cell-object batching, `9f51a4d` cell-particle consolidation, `a9d06a6`
|
|
||||||
> apparatus strip, `02578dd` docs) is **finalized + committed** and touched
|
|
||||||
> **render files ONLY** — zero physics / collision / streaming / movement /
|
|
||||||
> membership code (verified: `git show --name-only 290e731 9f51a4d a9d06a6`). Do
|
|
||||||
> NOT suspect those commits. The only indirect link: higher FPS makes the same
|
|
||||||
> few-tick transient render across more frames, so it's more *noticeable*; the
|
|
||||||
> underlying bug predates the FPS work (and collision is FPS-independent — physics
|
|
||||||
> ticks at a fixed rate).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The two issues (user-observed, 2026-06-24)
|
|
||||||
|
|
||||||
Both are on the **teleport-OUT-to-outdoor** path and are almost certainly the same
|
|
||||||
known **placed-but-unstreamed streaming gap** (#135/#138 family). ISSUES §51 says a
|
|
||||||
streaming-gap fix "would fix **both** symptoms." They're split into two prompts only
|
|
||||||
so they can be tackled independently — whoever picks one should read both.
|
|
||||||
|
|
||||||
### Issue A — no collision after death → portaled to Holtburg
|
|
||||||
- **Symptom:** after dying and being teleported to Holtburg, the player has **no
|
|
||||||
collision** (falls through / clips).
|
|
||||||
- **Root (known, NOT this session's work):** the *placed-but-unstreamed gap* — on a
|
|
||||||
teleport the player is placed immediately, but the destination landblock streams
|
|
||||||
in a few ticks later; during that gap the physics resolve runs against an **empty
|
|
||||||
world** (no terrain/collision meshes loaded), so collision finds nothing.
|
|
||||||
- **ISSUES refs:** [`docs/ISSUES.md:51`](../ISSUES.md) (the far-town runaway/march +
|
|
||||||
Z free-fall, "same root as #135/#138 placed-but-unstreamed gap"), and
|
|
||||||
[`docs/ISSUES.md:138`](../ISSUES.md) (acceptance: "the outdoor world fully
|
|
||||||
streamed, **collision working**"). Whether the death lands in a dungeon-exit
|
|
||||||
(#138 proper) or an outdoor respawn-to-lifestone, the placement→stream→resolve
|
|
||||||
timing is the same.
|
|
||||||
|
|
||||||
### Issue B — char missing sometimes after portal to outside
|
|
||||||
- **Symptom:** after portaling outside, the **character (and other entities) are
|
|
||||||
missing for a moment**, then pop in.
|
|
||||||
- **Root (known = #138):** teleport-OUT re-hydrates world entities with **fresh
|
|
||||||
monotonic Ids** a few ticks after arrival (`GameWindow` ~`:3251`,
|
|
||||||
`Id = _liveEntityIdCounter++`); during that gap they aren't in the draw set, so
|
|
||||||
they appear late ("objects come back"). The player is one of them.
|
|
||||||
- **ISSUES refs:** [`docs/ISSUES.md:265`](../ISSUES.md) (#138 — "Teleport OUT of a
|
|
||||||
dungeon loads the outdoor world incompletely + position desync"),
|
|
||||||
[`docs/ISSUES.md:299`](../ISSUES.md) (the fresh-Id re-hydration detail + the
|
|
||||||
cache-invalidation side-finding).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠ This is the DELICATE area — read before touching
|
|
||||||
|
|
||||||
1. **DO NOT guess-patch.** The teleport/streaming runaway burned **~5 failed
|
|
||||||
attempts** (#145). Build the apparatus FIRST (capture → replay), per
|
|
||||||
`memory/feedback_apparatus_for_physics_bugs.md`.
|
|
||||||
2. **A "streaming HOLD" was tried and REVERTED.** Freezing the per-tick resolve
|
|
||||||
until the landblock loads produced a ~10 s freeze; the user called it "shaky and
|
|
||||||
bandaid" (`feedback_no_holds_for_slow_foundation` in memory). The real fix is
|
|
||||||
**fix the foundation** — make the destination stream *with* placement (the async
|
|
||||||
equivalent of retail's synchronous load), not a gate that masks a slow stream.
|
|
||||||
3. **Capture first.** Reproduce a real teleport and capture the
|
|
||||||
placement→stream→resolve sequence before proposing anything.
|
|
||||||
|
|
||||||
## Where to start reading (in order)
|
|
||||||
- `claude-memory/project_physics_collision_digest.md` — physics / collision /
|
|
||||||
cell-membership SSOT + the DO-NOT-RETRY table. **START HERE.**
|
|
||||||
- `docs/ISSUES.md` #135, #138, #107, #145 (the teleport/streaming family).
|
|
||||||
- `memory/reference_two_tier_streaming.md` — the A.5 streaming architecture
|
|
||||||
(Region → Controller → LandblockStreamer → GpuWorldState; near/far radii).
|
|
||||||
- The teleport/placement + streaming code: grep `GameWindow` for the snap/placement
|
|
||||||
path (`[snap]`, `EXIT-expand`, `_liveEntityIdCounter`), the streaming
|
|
||||||
`dungeon EXIT-expand → (x,y)` logs, and `PlayerMovementController` / `PhysicsEngine`
|
|
||||||
arrival resolve.
|
|
||||||
|
|
||||||
## Capture apparatus (already in the tree)
|
|
||||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` — JSON-Lines of every player-side
|
|
||||||
`PhysicsEngine.ResolveWithTransition` (inputs + body before/after + result).
|
|
||||||
Run it across a death→Holtburg teleport to see the resolve running against the
|
|
||||||
empty world during the gap.
|
|
||||||
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per player cell change
|
|
||||||
(old→new, reason). Low volume; shows the post-teleport cell march.
|
|
||||||
- `ACDREAM_PROBE_RESOLVE=1` — per-resolve line (input/target/output, grounded,
|
|
||||||
contact-plane, responsible entity). Heavy.
|
|
||||||
- Launch (PowerShell): user `notan` / `MittSnus81!`, local ACE `127.0.0.1:9000`.
|
|
||||||
See CLAUDE.md "Running the client against the live server".
|
|
||||||
|
|
||||||
## Likely fix shape (hypothesis, NOT confirmed — capture first)
|
|
||||||
Make the teleport destination's near landblocks **stream synchronously (or
|
|
||||||
placement waits on the first stream tick)** so the arrival resolve never runs
|
|
||||||
against an empty world, AND the entity re-hydration completes before/with the first
|
|
||||||
drawn frame — rather than a per-tick HOLD. Both symptoms (no-collision, char-missing)
|
|
||||||
collapse to "the world isn't there yet when we place + resolve + draw." Confirm the
|
|
||||||
exact gap timing from a capture before committing to a shape.
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# Character window — faithful element spec (LayoutDesc 0x2100002E)
|
|
||||||
|
|
||||||
Decomp-grounded blueprint for the Character window, produced to REDO the guessed pilot.
|
|
||||||
Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named decomp),
|
|
||||||
the user UI dump `docs/research/2026-06-25-retail-ui-layout-dump.json`, and acdream's own
|
|
||||||
importer-resolved tree (dumped live in the studio). **No guessing** — every row cites a
|
|
||||||
decomp address or a confirmed `FindElement` result.
|
|
||||||
|
|
||||||
## What 0x2100002E actually is
|
|
||||||
|
|
||||||
A **tabbed Attributes / Skills / Titles window** (300×600, root panel `0x10000227`, Type 8).
|
|
||||||
It is NOT a text report. Three tab-content slots each mount a sub-layout via
|
|
||||||
`BaseElement`+`BaseLayoutId` inheritance (acdream's `ShouldMountBaseChildren` path):
|
|
||||||
|
|
||||||
| Slot | BaseElement | BaseLayoutId | gm*UI | Register |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `0x1000022B` (Attributes) | `0x10000225` | `0x2100002C` | gmAttributeUI (root type 0x1000002A) | 0x0049db50 |
|
|
||||||
| `0x1000022C` (Skills) | `0x1000022E` | `0x2100002D` | gmSkillUI | 0x0049adb0 |
|
|
||||||
| `0x10000539` (Titles) | `0x1000052D` | `0x2100005E` | gmCharacterTitleUI | 0x0049aba0 |
|
|
||||||
|
|
||||||
Both Attributes + Skills tabs share a header strip filled by the base class
|
|
||||||
**`gmStatManagementUI`** (`UpdateCharacterInfo` 0x004f0770, `UpdateExperience` 0x004f0a70,
|
|
||||||
`UpdatePKStatus` 0x004f00a0). The tab sub-layout `0x2100002C` nests `0x10000226` which
|
|
||||||
chains again into `0x21000045` — the real header/list content is **two inheritance levels deep**.
|
|
||||||
|
|
||||||
The **text report** ("birth/age/deaths, innate attributes, augmentations, load") the earlier
|
|
||||||
pilot guessed is a SEPARATE sub-panel: LayoutDesc `0x2100001A`, `gmCharacterInfoUI`
|
|
||||||
(register 0x004b8c90), whose `m_pMainText` (`0x1000011d`) is created at RUNTIME. That window
|
|
||||||
is handled by `CharacterController` (create-the-element path) — different layout, not 0x2100002E.
|
|
||||||
|
|
||||||
## Importer reality (dumped live, 2026-06-25)
|
|
||||||
|
|
||||||
- `dump-vitals-layout` prints the RAW dat (no inheritance) → the content elements look "missing".
|
|
||||||
That is an artifact. The **importer** mounts them. Confirmed by `FindElement` on the studio's
|
|
||||||
imported 0x2100002E:
|
|
||||||
- `0x10000231` name → `UiText` ✓
|
|
||||||
- `0x10000232` heritage → `UiText` ✓
|
|
||||||
- `0x10000233` PK status → `UiText` ✓
|
|
||||||
- `0x1000023B` level → `UiText` ✓
|
|
||||||
- `0x10000235` total XP → `UiText` ✓
|
|
||||||
- `0x10000236` XP-to-level meter → `UiMeter` ✓
|
|
||||||
- `0x1000023D` list box → `UiDatElement` (generic container) ✓
|
|
||||||
- `0x10000238` XP-to-level text → **NULL** (nested variant; not mounted — minor)
|
|
||||||
- `UiElement.EventId` is NOT the dat element id (root shows 0x10000001, not 0x10000227). The dat
|
|
||||||
id lives in `ImportedLayout._byId` (set from `info.Id`, LayoutImporter:107). Bind via
|
|
||||||
`FindElement(id)`, never by reading `widget.EventId`.
|
|
||||||
|
|
||||||
## Header element map (gmStatManagementUI)
|
|
||||||
|
|
||||||
| Id | Field | Content source | Rect (in sub-layout) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `0x10000231` | m_pNameText | player full name | 0,25,230,20 |
|
|
||||||
| `0x10000232` | m_pHeritageText | gender+heritage display (InqGenderHeritageDisplay) | 0,45,230,15 |
|
|
||||||
| `0x10000233` | m_pPKStatusText | PK status string (StringTable) | 0,60,230,15 |
|
|
||||||
| `0x1000023B` | m_pLevelText | PropertyInt 0x19 (level) | 235,60,65,50 |
|
|
||||||
| `0x10000234` | (label) | "Total Experience:" | 0,95,130,18 |
|
|
||||||
| `0x10000235` | m_pTotalXPText | PropertyInt64 1 | 130,95,100,18 |
|
|
||||||
| `0x10000236` | m_pXPToLevelMeter | (cur−base)/(cap−base), img 0x060011A6 | 0,113,230,17 |
|
|
||||||
| `0x10000237` | (label) | "To next level:" | 0,113,130,17 |
|
|
||||||
| `0x10000238` | m_pXPToLevelText | XP remaining | 130,113,100,17 |
|
|
||||||
| `0x1000023D` | m_pListBox | attribute/skill rows (runtime) | 0,137,300,398 |
|
|
||||||
|
|
||||||
Attribute list rows: gmAttributeUI builds `AttributeInfoRegion` rows (name + base/buffed value +
|
|
||||||
raise button). Canonical AC display order: Strength, Endurance, Coordination, Quickness, Focus, Self.
|
|
||||||
|
|
||||||
## StringTable
|
|
||||||
|
|
||||||
`DatReaderWriter` CAN read `StringTable` (CLI uses `GetAllIdsOfType<StringTable>`). The decomp's
|
|
||||||
`StringInfo::SetStringIDandTableEnum(…, stringId, 0x10000001)` references table enum 0x10000001 →
|
|
||||||
the game string table dat (0x31000001 family); StringId=0 is a placeholder overwritten by a
|
|
||||||
preceding `compute_str_hash("ID_…")`. To reproduce exact wording: read the StringTable + a
|
|
||||||
StringInfo helper (hash → id → lookup, `AddVariable_*` substitution). NOT yet ported — current
|
|
||||||
controller uses canonical AC labels.
|
|
||||||
|
|
||||||
## Status (this session)
|
|
||||||
|
|
||||||
- **DONE (commit 0e644b5):** `CharacterStatController` binds name/heritage/PK/level/total-XP +
|
|
||||||
the XP meter fill + the six attributes into the real elements. Studio renders the real panel.
|
|
||||||
- **Follow-ups (known sources):** (1) tab-button active/inactive STATE so the 3 tabs draw their
|
|
||||||
sprites (0x06005D92-97); (2) StringTable wording; (3) full AttributeInfoRegion row template
|
|
||||||
(column-aligned values + per-attribute raise buttons); (4) wire `0x10000234`/`0x10000237`
|
|
||||||
labels + the right-side level area placement.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,45 +0,0 @@
|
||||||
# UI Studio — dump-panel render sweep (2026-06-25)
|
|
||||||
|
|
||||||
All 26 windows in `docs/research/2026-06-25-retail-ui-layout-dump.json` were rendered through
|
|
||||||
the studio's dump LayoutSource (`AcDream.App ui-studio --dump <slug> --screenshot <png>`) and
|
|
||||||
read back as PNGs. Goal: verify the generic dump source renders every retail window, identify
|
|
||||||
real render bugs vs. expected gaps.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
The generic dump source renders **all 26 panels with no crash**. Render fidelity tracks how
|
|
||||||
much of a panel is **static chrome** (present in the dump, so it draws) vs **runtime content**
|
|
||||||
(slots, stats, appraise text — NOT in the dump, so it can't draw). This is the dump's nature,
|
|
||||||
not a studio bug.
|
|
||||||
|
|
||||||
## Per-panel status (read from `studio-shots/`)
|
|
||||||
|
|
||||||
| Status | Panels |
|
|
||||||
|--------|--------|
|
|
||||||
| **Full / great** (frame + static content) | `vitals` (3 bars + heart/glyphs), `side_by_side_vitals`, `toolbar` (all spell/combat icons + backpack), `map`, `main_panel`, `radar` (disc), `character` (frame+tabs+scrollbar), `combat`, `powerbar`, `negative_effects`, `positive_effects`, `options`, `quests`, `social`, `spellbook`, `main_chat`, `floating_chat_1..4`, `environment_panel` |
|
|
||||||
| **Partial** (chrome only; runtime content absent) | `inventory` — stone backdrop + top frame render, but the nested sub-window chrome (gm3DItemsUI / gmBackpackUI / paperdoll) is sparse and the slots are runtime-filled. **The one panel with a possible real gap — investigate the nested-layout / sub-window node handling.** |
|
|
||||||
| **Blank** (runtime-only window, no static sprites in dump) | `examine` (appraise window — populated only on examine), and likely the small `indicators` / `smartbox` / `vitae` |
|
|
||||||
|
|
||||||
## Tooling shipped this sweep
|
|
||||||
|
|
||||||
- `--screenshot <path>` headless mode (`f778b10`): render the loaded panel (dump or dat) to a
|
|
||||||
PNG off-screen via `PanelFbo` + `glReadPixels` + ImageSharp, then exit. Hidden window
|
|
||||||
(`IsVisible=false`). The autonomous self-verification primitive.
|
|
||||||
- Contact-sheet montage via PowerShell `System.Drawing` (one image, 26 thumbs) — the cheap
|
|
||||||
per-sweep overview (`studio-shots/_contact.png`, gitignored).
|
|
||||||
|
|
||||||
## Rect basis (confirmed in `DumpLayout`)
|
|
||||||
|
|
||||||
Dump `rect` fields are ABSOLUTE retail screen coords; `DumpLayout` converts to parent-relative
|
|
||||||
(`child.Left = child.Rect.X - parent.Rect.X`) and places the root at (0,0). Verified correct by
|
|
||||||
the toolbar (icons land precisely) + vitals.
|
|
||||||
|
|
||||||
## Next (loop)
|
|
||||||
|
|
||||||
1. **Inventory nested chrome** — determine whether the sub-window content is missing because
|
|
||||||
(a) those nodes are `Group`s with no static sprite (expected), or (b) `DumpLayout` isn't
|
|
||||||
expanding a `base_layout_id` sub-layout reference (a fixable bug). Dump the inventory panel's
|
|
||||||
node `widget_kind`/`rect`/`image_id` list and compare to what rendered.
|
|
||||||
2. Spot-confirm the remaining blanks (`indicators`/`smartbox`/`vitae`) are runtime-only.
|
|
||||||
3. The dump source is functionally complete as a static previewer; richer (live-data) previews
|
|
||||||
are the dat-import + fixture path (the parked Task 4 bags/equip), not the dump path.
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
# ACE vs 2013 Retail Motion Command Gap
|
|
||||||
|
|
||||||
Date: 2026-06-26
|
|
||||||
|
|
||||||
## Why this matters
|
|
||||||
|
|
||||||
The movement/animation parity work cannot use one command catalog blindly.
|
|
||||||
|
|
||||||
The 2013 `acclient.exe` decomp has a `command_ids[0x198]` table and a matching command-name table, but ACE's `MotionCommand` enum is based on a later client catalog. The local DAT motion tables also contain later-client command keys. If AcDream reconstructs incoming ACE wire commands through the 2013 table only, some server-triggered animations will disappear.
|
|
||||||
|
|
||||||
The concrete example is lifestone recall:
|
|
||||||
|
|
||||||
- 2013 retail decomp names `LifestoneRecall` as `0x10000150`.
|
|
||||||
- Current ACE names `LifestoneRecall` as `0x10000153`.
|
|
||||||
- Local DAT `MotionTable` links include both values, but many later recall/offhand commands only exist under the ACE-shifted value.
|
|
||||||
|
|
||||||
## Sources checked
|
|
||||||
|
|
||||||
- 2013 retail decomp:
|
|
||||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
|
||||||
- `command_ids[0x198]` at `0x007c73e8`
|
|
||||||
- command-name table around `0x008041ec..0x0080444c`
|
|
||||||
- ACE current master:
|
|
||||||
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/MotionCommand.cs`
|
|
||||||
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/CommandMasks.cs`
|
|
||||||
- Local DATs:
|
|
||||||
- `C:\Users\erikn\Documents\Asheron's Call\client_portal.dat`
|
|
||||||
- scanned 436 `MotionTable` records with `Chorizite.DatReaderWriter 2.1.7`
|
|
||||||
- Current AcDream resolver:
|
|
||||||
- `src/AcDream.Core/Physics/MotionCommandResolver.cs`
|
|
||||||
|
|
||||||
## Catalog mismatch
|
|
||||||
|
|
||||||
Parsed inventory:
|
|
||||||
|
|
||||||
- 2013 retail command names parsed: `406`
|
|
||||||
- ACE command names parsed: `409`
|
|
||||||
- Common names: `400`
|
|
||||||
- Common names with different values: `130`
|
|
||||||
|
|
||||||
The important mismatch is a contiguous low-word `+3` shift beginning after the targeting UI block:
|
|
||||||
|
|
||||||
| Name | 2013 retail | ACE current |
|
|
||||||
|---|---:|---:|
|
|
||||||
| `SnowAngelState` | `0x43000115` | `0x43000118` |
|
|
||||||
| `MeditateState` | `0x43000119` | `0x4300011C` |
|
|
||||||
| `Pickup5` | `0x40000133` | `0x40000136` |
|
|
||||||
| `HouseRecall` | `0x10000137` | `0x1000013A` |
|
|
||||||
| `SitState` | `0x4300013A` | `0x4300013D` |
|
|
||||||
| `HaveASeat` | `0x1300014F` | `0x13000152` |
|
|
||||||
| `LifestoneRecall` | `0x10000150` | `0x10000153` |
|
|
||||||
| `MarketplaceRecall` | `0x10000163` | `0x10000166` |
|
|
||||||
| `AllegianceHometownRecall` | `0x1000016E` | `0x10000171` |
|
|
||||||
| `PKArenaRecall` | `0x1000016F` | `0x10000172` |
|
|
||||||
| `OffhandSlashHigh` | `0x10000170` | `0x10000173` |
|
|
||||||
|
|
||||||
ACE's enum comments show the branch point:
|
|
||||||
|
|
||||||
- `SkillHealSelf = 0x1000010e`
|
|
||||||
- `SkillHealOther = 0x1000010f`
|
|
||||||
- duplicate/commented legacy slots around `0x010f..0x0111`
|
|
||||||
- `SnowAngelState = 0x43000118`
|
|
||||||
|
|
||||||
2013 retail instead has:
|
|
||||||
|
|
||||||
- `SkillHealSelf = 0x0900010E`
|
|
||||||
- `NextMonster = 0x0900010F`
|
|
||||||
- `PreviousMonster = 0x09000110`
|
|
||||||
- `ClosestMonster = 0x09000111`
|
|
||||||
- `NextPlayer = 0x09000112`
|
|
||||||
- `PreviousPlayer = 0x09000113`
|
|
||||||
- `ClosestPlayer = 0x09000114`
|
|
||||||
- `SnowAngelState = 0x43000115`
|
|
||||||
|
|
||||||
So this is a later-client catalog divergence, not just a single bad enum value.
|
|
||||||
|
|
||||||
## MotionTable availability
|
|
||||||
|
|
||||||
I scanned all 436 local DAT `MotionTable` records for the selected 2013 and ACE values. Counts below are link target hits; recall/action commands are stored in `Links`, not `Cycles`.
|
|
||||||
|
|
||||||
| Command | 2013 value hits | ACE value hits | Interpretation |
|
|
||||||
|---|---:|---:|---|
|
|
||||||
| `HouseRecall` | `317` | `24` | both exist; the old value is very common |
|
|
||||||
| `LifestoneRecall` | `28` | `19` | both exist; ACE `/ls` value can animate |
|
|
||||||
| `MarketplaceRecall` | `0` | `19` | only ACE-shifted value exists |
|
|
||||||
| `AllegianceHometownRecall` | `0` | `19` | only ACE-shifted value exists |
|
|
||||||
| `PKArenaRecall` | `0` | `18` | only ACE-shifted value exists |
|
|
||||||
| `OffhandSlashHigh` | `0` | `31` | only ACE-shifted value exists |
|
|
||||||
|
|
||||||
This means the local DATs are not pure 2013-command-table data. They include later-client action keys that match ACE/DatReaderWriter.
|
|
||||||
|
|
||||||
## Current AcDream resolver behavior
|
|
||||||
|
|
||||||
Current `MotionCommandResolver.ReconstructFullCommand` results:
|
|
||||||
|
|
||||||
| Wire low | Current full command |
|
|
||||||
|---:|---:|
|
|
||||||
| `0x0137` | `0x40000137` |
|
|
||||||
| `0x013A` | `0x1000013A` |
|
|
||||||
| `0x0150` | `0x13000150` |
|
|
||||||
| `0x0153` | `0x10000153` |
|
|
||||||
| `0x0163` | `0x09000163` |
|
|
||||||
| `0x0166` | `0x10000166` |
|
|
||||||
| `0x016E` | `0x1000016E` |
|
|
||||||
| `0x016F` | `0x1000016F` |
|
|
||||||
| `0x0170` | `0x10000170` |
|
|
||||||
| `0x0171` | `0x10000171` |
|
|
||||||
| `0x0172` | `0x10000172` |
|
|
||||||
| `0x0173` | `0x10000173` |
|
|
||||||
|
|
||||||
Implications:
|
|
||||||
|
|
||||||
- ACE `/ls` wire `0x0153` currently reconstructs to `0x10000153`, which is good for ACE and local DATs.
|
|
||||||
- 2013 retail lifestone wire `0x0150` would currently reconstruct as `ScanHorizon` chat emote, not `LifestoneRecall`.
|
|
||||||
- The override comment in `MotionCommandResolver` is wrong: the mismatch does not start at `AllegianceHometownRecall`; it starts earlier around `SnowAngelState`.
|
|
||||||
- The override range `0x016E..0x0197` maps `0x016E/0x016F/0x0170` to action-class commands even though ACE names those low words as UI commands. This probably does not hurt normal ACE animation broadcasts, but it is not a clean catalog model.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Do not replace ACE/DatReaderWriter reconstruction with 2013 `command_ids` globally.
|
|
||||||
|
|
||||||
Use two catalogs explicitly:
|
|
||||||
|
|
||||||
1. `Retail2013CommandCatalog`
|
|
||||||
- For proving decomp behavior and reproducing the 2013 client command table.
|
|
||||||
- Useful for old-client conformance tests.
|
|
||||||
|
|
||||||
2. `AceModernCommandCatalog`
|
|
||||||
- For reconstructing ACE `InterpretedMotionState` wire `u16` commands into the full 32-bit motion commands that match local DAT motion tables.
|
|
||||||
- Should be the runtime default while talking to ACE.
|
|
||||||
|
|
||||||
Then add a resolver mode/test matrix:
|
|
||||||
|
|
||||||
- ACE mode:
|
|
||||||
- `0x0153 -> 0x10000153` (`LifestoneRecall`)
|
|
||||||
- `0x0166 -> 0x10000166` (`MarketplaceRecall`)
|
|
||||||
- `0x0171 -> 0x10000171` (`AllegianceHometownRecall`)
|
|
||||||
- `0x0173 -> 0x10000173` (`OffhandSlashHigh`)
|
|
||||||
- 2013 retail mode:
|
|
||||||
- `0x0150 -> 0x10000150` (`LifestoneRecall`)
|
|
||||||
- `0x0163 -> 0x10000163` (`MarketplaceRecall`)
|
|
||||||
- `0x016E -> 0x1000016E` (`AllegianceHometownRecall`)
|
|
||||||
- `0x0170 -> 0x10000170` (`OffhandSlashHigh`)
|
|
||||||
|
|
||||||
Animation lookup should be tested against the DAT motion tables too, not just enum names. A command is visually usable only if the entity's `MotionTable` has a `Links` or `Modifiers` entry for that full command.
|
|
||||||
|
|
||||||
## Bottom line
|
|
||||||
|
|
||||||
For ACE interop, the lifestone recall animation is not missing from the local DAT motion tables. It is missing only if we force ACE's wire `0x0153` through the 2013 retail command catalog.
|
|
||||||
|
|
||||||
The gap is real and broader than lifestone recall: the later-client/ACE command catalog diverges from the 2013 decomp for 130 common names, and several important animations only exist under the ACE-shifted IDs in the local DATs.
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
# Character window (Attributes tab) — retail reference + polish targets
|
|
||||||
|
|
||||||
Transcribed from two user-provided retail screenshots (2026-06-26): the **not-selected** state and the
|
|
||||||
**Strength-selected** state. The PNGs live in the user's chat; this doc is the durable target spec for
|
|
||||||
the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x2100002E). "✗" = acdream gap.
|
|
||||||
|
|
||||||
## State 1 — nothing selected
|
|
||||||
|
|
||||||
- **Tabs:** Attributes (active, gold label), Skills, Titles.
|
|
||||||
- **Name:** "Horan" — **WHITE** (✗ acdream renders the name gold).
|
|
||||||
- **Heritage line:** "Female Aluvian Adventurer" — white.
|
|
||||||
- **PK line:** "Non-Player Killer" — white.
|
|
||||||
- **Level area (right):** small caption "Character Level" (two lines: "Character" / "Level"), then the
|
|
||||||
number **"240" as LARGE GOLD SPRITE DIGITS** — a number-sprite font, not plain text (✗ acdream draws
|
|
||||||
"126" as plain gold text, and the caption truncates to "Character Le").
|
|
||||||
- **"Total Experience (XP):"** caption + value "100,000,017,999" (✗ caption missing in acdream).
|
|
||||||
- **"XP for next level:"** caption + value "99,738,801" with the red fill bar behind it (✗ caption +
|
|
||||||
value missing — the value element is consumed by the UiMeter at import).
|
|
||||||
- **Attribute list — 9 rows**, each = icon + name + right-aligned value:
|
|
||||||
- Row text is **LARGER** (roughly the icon height ~24px) and rows are **TIGHTER** (less vertical gap)
|
|
||||||
than acdream's current small-text / wide-spacing.
|
|
||||||
- Order: Strength 200, Endurance 10, Coordination 10, Quickness 200, Focus 10, Self 10,
|
|
||||||
Health 5/5, Stamina 10/10, Mana 10/10.
|
|
||||||
- **Footer:** "Select an Attribute to Improve" (title) / "Skill Credits Available: 96" /
|
|
||||||
"Unassigned Experience: 87,757,321,741". (acdream now matches — confirm the title says **Attribute**,
|
|
||||||
not skill.)
|
|
||||||
|
|
||||||
## State 2 — Strength selected
|
|
||||||
|
|
||||||
- Same header.
|
|
||||||
- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's
|
|
||||||
selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace
|
|
||||||
with the dark-bar sprite.)
|
|
||||||
- **Footer flips to:**
|
|
||||||
- Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color).
|
|
||||||
- "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a
|
|
||||||
numeric/"maxed" placeholder — show "Infinity!" when the attribute is at max).
|
|
||||||
- "Unassigned Experience:" + "87,757,321,741".
|
|
||||||
- Raise buttons (≜10 + triangle) shown at the selected row's right.
|
|
||||||
|
|
||||||
## Polish checklist (acdream)
|
|
||||||
|
|
||||||
1. [ ] Name color → white.
|
|
||||||
2. [ ] Level → gold sprite digits (find the number-sprite font/ids); caption "Character"/"Level" 2-line.
|
|
||||||
3. [ ] Add "Total Experience (XP):" caption.
|
|
||||||
4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside).
|
|
||||||
5. [ ] Row text larger (≈icon height) + rows tighter.
|
|
||||||
6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint.
|
|
||||||
7. [ ] Selected footer title → white.
|
|
||||||
8. [ ] Maxed attribute → "Experience To Raise: Infinity!".
|
|
||||||
9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute).
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Handoff — the full multi-window UI mockup stage (2026-06-26)
|
|
||||||
|
|
||||||
For the next agent. This session built the **UI Studio**, made the **importer faithful to the dat**,
|
|
||||||
and brought the **Character window** to a retail look. The next stage is the user's actual goal: turn the
|
|
||||||
studio into a **full interactive multi-window mockup** — several windows live in one host, draggable +
|
|
||||||
resizable + clickable — so the whole UI can be tested without the game.
|
|
||||||
|
|
||||||
Read this, then `claude-memory/project_ui_studio.md` (the SSOT) and `project_d2b_retail_ui.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Where we are (what's done + works)
|
|
||||||
|
|
||||||
### The UI Studio (the dev tool)
|
|
||||||
`AcDream.App ui-studio <datDir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` — a standalone
|
|
||||||
Silk.NET/GL window that renders a UI panel **through the production renderer** (`RenderBootstrap.Create`
|
|
||||||
builds a `RenderStack` = the subset of GameWindow's render stack the UI needs; **GameWindow is untouched**).
|
|
||||||
- Code: `src/AcDream.App/Studio/` (`StudioWindow`, `FixtureProvider`, `PanelFbo`, `DumpLayout`, `UiDumpModel`,
|
|
||||||
`StudioInspector`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`.
|
|
||||||
- **Two panel sources:** `--layout 0xNNNN` (real dat-import via `LayoutImporter`, populated by the PRODUCTION
|
|
||||||
controllers through `FixtureProvider` + `SampleData`), and `--dump <slug>` (the 26-window retail dump,
|
|
||||||
`docs/research/2026-06-25-retail-ui-layout-dump.json`, static structure only).
|
|
||||||
- **Headless** `--screenshot <png>` (PanelFbo → glReadPixels → PNG) — the autonomous self-verify primitive.
|
|
||||||
Use it constantly.
|
|
||||||
- **Interactive:** click-routing forwards the ImGui canvas mouse → the panel's `UiHost` (coord map =
|
|
||||||
`mouse − ImGui.GetItemRectMin()`, no extra Y-flip). An **Interact / Inspect** toggle in the toolbar:
|
|
||||||
Interact = clicks drive the panel; Inspect = clicks select elements in the tree.
|
|
||||||
|
|
||||||
### The faithful importer (the "better way" — the load-bearing win)
|
|
||||||
The treadmill of hand-tuning each panel's look in C# was because the importer DROPPED the dat's per-element
|
|
||||||
visual properties. The dat carries them in `StateDesc.Properties`: **0x1A FontDID**, **0x14/0x15** H/V
|
|
||||||
justification, **0x1B FontColor**. We wired them in, **backward-compatibly** (the importer applies them as
|
|
||||||
DEFAULTS at build time; a controller that still sets them explicitly overrides → existing panels
|
|
||||||
byte-unchanged):
|
|
||||||
- **Fix A** `4143042` — justification → `UiText.Centered/RightAligned/VerticalJustify`.
|
|
||||||
- **Fix B** `6e0be4b` — FontColor → `UiText.DefaultColor` (character colors proved RUNTIME — the dat carries none).
|
|
||||||
- **Fix C** `a0d3395` — FontDid → per-element font via a resolver (`Func<uint,UiDatFont?>`) threaded through
|
|
||||||
`LayoutImporter.Import` → `DatWidgetFactory`. **STUDIO path only**; GameWindow passes null (issue **#157**).
|
|
||||||
- **Fix 5** `ad4ed51` — `LayoutImporter.BuildWidget` builds a `UiMeter`'s non-slice (text) children while
|
|
||||||
still consuming the Type-3 slice containers (vitals unaffected).
|
|
||||||
- **Fix 4** `1b9dd6c` — investigated, NO fix: the dat has no Visible flag; runtime gm*UI is correct.
|
|
||||||
|
|
||||||
**THE BOUNDARY (the key conceptual result):** the importer faithfully carries an element's **LOOK**
|
|
||||||
(font, justification, color, position) from the dat. **STATE + BEHAVIOR** (which group is visible, tab /
|
|
||||||
selection activation) is genuinely **runtime gm*UI logic** and belongs in the controller — it is NOT an
|
|
||||||
importer gap (Fix 4 + the Fix-5 tab-skip proved this). Don't try to push state/visibility into the importer.
|
|
||||||
|
|
||||||
### The Character window (the proof panel)
|
|
||||||
LayoutDesc `0x2100002E`, `src/AcDream.App/UI/Layout/CharacterStatController.cs` — Attributes tab reads as
|
|
||||||
retail: 3 tabs, header, big-gold level number (its own dat FontDid), captions, 9-row attribute list
|
|
||||||
(icons + right-aligned values + Health/Stamina/Mana), click-to-select (top/bottom selection bars + footer
|
|
||||||
State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered
|
|
||||||
footer. **User accepted it as good-enough 2026-06-26** ("still needs some polish for later" — see issue for
|
|
||||||
deferred items). Specs: `docs/research/2026-06-25-character-window-faithful-spec.md`,
|
|
||||||
`2026-06-25-attributes-tab-interactive-spec.md`, `2026-06-26-character-window-retail-reference.md`.
|
|
||||||
|
|
||||||
### Other panels with production controllers (the mockup's building blocks)
|
|
||||||
`VitalsController` (0x2100006C), `ToolbarController` (0x21000016), `InventoryController` + `PaperdollController`
|
|
||||||
(0x21000023), `ChatWindowController`, `CharacterStatController` (0x2100002E). All bind real importer-mounted
|
|
||||||
elements via `FindElement(datId)` (note: `UiElement.EventId` ≠ the dat id — the dat id lives in
|
|
||||||
`ImportedLayout._byId`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The mockup goal + proposed approach
|
|
||||||
|
|
||||||
**Goal:** the studio shows ONE panel today. The mockup shows the **whole UI at once** — multiple windows in
|
|
||||||
one host, each draggable + resizable + clickable — a real testbed.
|
|
||||||
|
|
||||||
**Why it's reachable:** the production `UiHost` (`src/AcDream.App/UI/UiHost.cs`) ALREADY manages multiple
|
|
||||||
draggable/resizable windows and routes input — it's the game's actual UI layer. The studio just needs to host
|
|
||||||
the full thing instead of one FBO'd panel. (Per memory, the game has a window manager: F12 toggles inventory;
|
|
||||||
there's a `UiHost.ToggleWindow` / `WindowNames` API.)
|
|
||||||
|
|
||||||
**Proposed path (a new studio "mockup mode"):**
|
|
||||||
1. Load SEVERAL panels into ONE `UiHost` (not one panel per FBO) — a "desktop." Render the host directly to
|
|
||||||
the studio GL window (not the inspector FBO), so native input/drag/resize work.
|
|
||||||
2. Window open/close/arrange (reuse the game's window-manager API).
|
|
||||||
3. Drag + resize via the production frame chrome (already exists for the retail windows).
|
|
||||||
4. Keep the inspector available as an optional overlay.
|
|
||||||
The studio is the THIN host; the production `UiHost` is the engine. Don't reimplement window management —
|
|
||||||
drive the production one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Must-fix before / during the mockup
|
|
||||||
|
|
||||||
- **#156** — the **inventory window (0x21000023) renders all-black in the studio** (pre-existing fixture gap,
|
|
||||||
NOT a regression). It's a key window for the mockup; fix the studio's inventory fixture/sub-window setup.
|
|
||||||
- **#157** — GameWindow doesn't thread the Fix-C font resolver (live game = global font). Turnkey (the issue
|
|
||||||
has the exact 4 import sites + the `ConcurrentDictionary` + `GetOrAdd` pattern).
|
|
||||||
- **Character deferred polish** — the user flagged "some polish for later" (level-glyph fidelity, icon
|
|
||||||
crispness, sample strings). Filed as an issue; the user will enumerate.
|
|
||||||
|
|
||||||
## 4. Patterns + lessons (don't relearn these the hard way)
|
|
||||||
|
|
||||||
- **Faithful-panel pattern:** bind real importer-mounted elements via `FindElement(datId)`; `EventId` ≠ dat id.
|
|
||||||
- **Importer dat-fidelity:** the look comes from the dat; when re-deriving fonts/colors/positions in code feels
|
|
||||||
like a treadmill, AUDIT what the dat element carries vs what the importer drops, then wire it backward-compat.
|
|
||||||
- **The boundary:** look = importer; state/behavior = runtime controller. Don't fight it.
|
|
||||||
- **Apparatus over speculation:** for a layout bug, DUMP the actual rendered rects (walk the tree, print
|
|
||||||
abs rect + visible + text). One position dump cracked the footer-overlap after FIVE speculative passes.
|
|
||||||
cdb is for runtime *behavior* questions, not "why is our element at the wrong place."
|
|
||||||
- **Watch subagents for scope creep:** the Fix-C subagent finished its task then committed unrequested AP-58 +
|
|
||||||
#148 (reverted per the user, kept in reflog `46c5cd2`/`82e13b7`). Constrain dispatches to the assigned task.
|
|
||||||
- **Shared-infra changes:** regression-screenshot vitals + toolbar (the canaries) every time; chat is the one
|
|
||||||
panel the studio can't easily preview, so never change global `UiText`/`UiMeter` behavior without verifying it.
|
|
||||||
|
|
||||||
## 5. Reference map
|
|
||||||
|
|
||||||
- Studio spec/plan: `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md`,
|
|
||||||
`docs/superpowers/plans/2026-06-25-ui-studio-previewer.md`; dump sweep `docs/research/2026-06-25-studio-dump-panel-sweep.md`.
|
|
||||||
- Character window: the three specs above + the retail reference.
|
|
||||||
- Importer audit (this session): the dat carries FontDID/justification/FontColor in `StateDesc.Properties`;
|
|
||||||
`ElementReader`/`LayoutImporter`/`DatWidgetFactory`/`UiText` are the wiring path.
|
|
||||||
- The retail UI dump: `docs/research/2026-06-25-retail-ui-layout-dump.json` (26 windows, real ids + rects + sprites).
|
|
||||||
- Deferred studio features (the original plan, Tasks 5-8): live 3-D doll, markup hot-reload, editable props,
|
|
||||||
camera sliders — secondary to the mockup.
|
|
||||||
- Memory SSOT: `claude-memory/project_ui_studio.md`, `project_d2b_retail_ui.md`.
|
|
||||||
|
|
||||||
## 6. First concrete steps for the next agent
|
|
||||||
|
|
||||||
1. Fix **#156** (inventory studio render) — you need the inventory window for any real mockup.
|
|
||||||
2. Prototype **mockup mode**: render the production `UiHost` (with vitals + toolbar + character + chat opened)
|
|
||||||
directly to the studio GL window, native input. Confirm drag + resize + click work through the production host.
|
|
||||||
3. Then iterate window open/close/arrange + the desktop layout.
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
# Movement and Animation Retail Parity Audit
|
|
||||||
|
|
||||||
Date: 2026-06-26
|
|
||||||
|
|
||||||
## Verdict on approach
|
|
||||||
|
|
||||||
The approach is good if it stays decomp-first and test-first. For this area, "verbatim" needs to mean:
|
|
||||||
|
|
||||||
- Outbound wire bytes match retail packers for the same input state.
|
|
||||||
- Incoming movement packets are parsed into the same semantic state retail would unpack.
|
|
||||||
- Local, remote player, monster, NPC, and force-walk paths flow through retail-equivalent motion state transitions before animation selection.
|
|
||||||
- Any behavior that cannot be proven from retail decomp, Ghidra, or retail captures is marked unresolved instead of tuned by feel.
|
|
||||||
|
|
||||||
The approach is not good if it begins as a broad rewrite or animation polish pass. The current code has several comments and tests that already encode approximations. Those need to become failing parity tests first, then implementation slices.
|
|
||||||
|
|
||||||
## Sources checked
|
|
||||||
|
|
||||||
Primary oracle:
|
|
||||||
|
|
||||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
|
||||||
- `docs/research/named-retail/acclient.h`
|
|
||||||
- `docs/research/named-retail/acclient.c`
|
|
||||||
|
|
||||||
Secondary oracle:
|
|
||||||
|
|
||||||
- `docs/research/acclient_decompiled.c`
|
|
||||||
- Ghidra HTTP bridge verified on `http://127.0.0.1:8081`; `methods?limit=3` responded and decompile-by-address worked during the audit. Codex tool discovery did not expose a first-class Ghidra MCP namespace in this thread, so direct HTTP is the usable path here.
|
|
||||||
|
|
||||||
Current implementation surfaces:
|
|
||||||
|
|
||||||
- `src/AcDream.Core.Net/Messages/MoveToState.cs`
|
|
||||||
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`
|
|
||||||
- `src/AcDream.Core.Net/Messages/JumpAction.cs`
|
|
||||||
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`
|
|
||||||
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs`
|
|
||||||
- `src/AcDream.Core.Net/Messages/CreateObject.cs`
|
|
||||||
- `src/AcDream.Core.Net/WorldSession.cs`
|
|
||||||
- `src/AcDream.Core/Physics/MotionInterpreter.cs`
|
|
||||||
- `src/AcDream.Core/Physics/AnimationSequencer.cs`
|
|
||||||
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs`
|
|
||||||
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`
|
|
||||||
- `src/AcDream.App/Input/PlayerMovementController.cs`
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs`
|
|
||||||
|
|
||||||
## Retail source map
|
|
||||||
|
|
||||||
### Outbound movement to ACE
|
|
||||||
|
|
||||||
- `CommandInterpreter::ShouldSendPositionEvent` at `0x006b45e0`, pseudo line around `700233`.
|
|
||||||
- `CommandInterpreter::SendMovementEvent` at `0x006b4680`, pseudo line around `700274`.
|
|
||||||
- `CommandInterpreter::SendPositionEvent` at `0x006b4770`, pseudo line around `700316`.
|
|
||||||
- `CM_Movement::Event_AutonomousPosition` at `0x006af790`, sub-opcode `0xf753`.
|
|
||||||
- `CM_Movement::Event_Jump` at `0x006afa70`, sub-opcode `0xf61b`.
|
|
||||||
- `CM_Movement::Event_MoveToState` at `0x006afc00`, sub-opcode `0xf61c`.
|
|
||||||
- `MoveToStatePack::Pack` at `0x005168f0`, pseudo line around `284694`.
|
|
||||||
- `AutonomousPositionPack::Pack` at `0x00516af0`, pseudo line around `284795`.
|
|
||||||
- `JumpPack::Pack` at `0x00516d10`, pseudo line around `284915`.
|
|
||||||
- `RawMotionState::Pack` at `0x0051ed10`, pseudo line around `293761`.
|
|
||||||
|
|
||||||
Retail `RawMotionState` defaults:
|
|
||||||
|
|
||||||
- `current_holdkey = HoldKey.None`
|
|
||||||
- `current_style = 0x8000003d`
|
|
||||||
- `forward_command = 0x41000003`
|
|
||||||
- `forward_holdkey = HoldKey.Invalid`
|
|
||||||
- `forward_speed = 1.0`
|
|
||||||
- `sidestep_command = 0`
|
|
||||||
- `sidestep_holdkey = HoldKey.Invalid`
|
|
||||||
- `sidestep_speed = 1.0`
|
|
||||||
- `turn_command = 0`
|
|
||||||
- `turn_holdkey = HoldKey.Invalid`
|
|
||||||
- `turn_speed = 1.0`
|
|
||||||
|
|
||||||
Retail `RawMotionState::Pack` compares against these defaults. It does not set a field bit merely because the caller supplied a value. For example, `forward_speed == 1.0f` is omitted.
|
|
||||||
|
|
||||||
### Incoming movement and force movement
|
|
||||||
|
|
||||||
- `MovementManager::PerformMovement` at `0x005240d0`, pseudo line around `300194`, routes movement types `1-5` to `CMotionInterp` and `6-9` to `MoveToManager`.
|
|
||||||
- `MovementManager::unpack_movement` at `0x00524440`, pseudo line around `300563`, unpacks interpreted motion plus `MoveToObject`, `MoveToPosition`, `TurnToObject`, and `TurnToHeading`.
|
|
||||||
- `MovementParameters::UnPackNet` at `0x0052ac70`, pseudo line around `308118`.
|
|
||||||
- `MovementParameters::get_command` at `0x0052aa00`, pseudo line around `307946`.
|
|
||||||
- `MoveToManager::_DoMotion`, `MoveToPosition`, and related queue logic are around pseudo lines `306351`, `307187`, and `307521`.
|
|
||||||
|
|
||||||
Retail movement types from `acclient.h`:
|
|
||||||
|
|
||||||
- `0`: interpreted motion in inbound packet context
|
|
||||||
- `1`: raw motion
|
|
||||||
- `2`: interpreted motion
|
|
||||||
- `3`: stop raw motion
|
|
||||||
- `4`: stop interpreted motion
|
|
||||||
- `5`: stop completely
|
|
||||||
- `6`: move to object
|
|
||||||
- `7`: move to position
|
|
||||||
- `8`: turn to object
|
|
||||||
- `9`: turn to heading
|
|
||||||
|
|
||||||
### Animation and motion interpretation
|
|
||||||
|
|
||||||
- `CMotionInterp::apply_run_to_command` at `0x00527be0`, pseudo line around `305062`.
|
|
||||||
- `CMotionInterp::get_state_velocity` at `0x00527d50`.
|
|
||||||
- `CMotionInterp::adjust_motion` at `0x00528010`, pseudo line around `305343`.
|
|
||||||
- `CMotionInterp::apply_raw_movement` at `0x005287e0`.
|
|
||||||
- `CMotionInterp::DoInterpretedMotion` around pseudo line `305575`.
|
|
||||||
- `CMotionInterp::apply_current_movement` around pseudo line `305713`.
|
|
||||||
- `CMotionTable::GetObjectSequence` around pseudo line `298636`.
|
|
||||||
- `CSequence` append/advance/update logic around pseudo lines `301622`, `301777`, `301839`, and `302425`.
|
|
||||||
|
|
||||||
Key retail normalization:
|
|
||||||
|
|
||||||
- `SideStepLeft` becomes `SideStepRight` with negative speed.
|
|
||||||
- `SideStepRight` speed becomes `(3.11999989 / 1.25) * 0.5 * speed`.
|
|
||||||
- `TurnLeft` becomes `TurnRight` with negative speed.
|
|
||||||
- `WalkBackward` becomes `WalkForward` with `-0.649999976 * speed`.
|
|
||||||
- Hold-key `Run` upgrades forward walk to run, multiplies turn speed by `1.5`, and multiplies sidestep by run rate with an absolute clamp of `3.0`.
|
|
||||||
|
|
||||||
## Confirmed divergences
|
|
||||||
|
|
||||||
### D1: MoveToState raw flags are not retail
|
|
||||||
|
|
||||||
Current `MoveToState.Build` sets raw-motion flags from nullable/presence inputs. Retail sets flags by comparing the complete `RawMotionState` against default values. This means the current client can over-send:
|
|
||||||
|
|
||||||
- `CurrentHoldKey = None`
|
|
||||||
- `ForwardSpeed = 1.0`
|
|
||||||
- `SidestepSpeed = 1.0`
|
|
||||||
- `TurnSpeed = 1.0`
|
|
||||||
- likely per-axis hold keys when they are default `Invalid`
|
|
||||||
|
|
||||||
Existing `MoveToStateTests.Build_WalkForward_IncludesForwardCommandInFlags` currently asserts the non-retail behavior by expecting a `ForwardSpeed` flag for speed `1.0`.
|
|
||||||
|
|
||||||
Impact: outbound movement bytes can differ from retail for normal running, walking, sidestepping, and turning.
|
|
||||||
|
|
||||||
### D2: RawMotionState action-list and style packing are incomplete
|
|
||||||
|
|
||||||
Retail bitfield layout uses 11 one-bit flags plus `num_actions : 5` at bits `11-15`. Current comments imply the command list length is broader than retail. Current builder does not cover full action-list packing or current-style scenarios.
|
|
||||||
|
|
||||||
Impact: action/modifier movement states cannot be proven retail-equivalent.
|
|
||||||
|
|
||||||
### D3: MoveToState longjump bit is not modeled
|
|
||||||
|
|
||||||
Retail `MoveToStatePack::Pack` writes one trailing byte:
|
|
||||||
|
|
||||||
- bit `0x01`: contact
|
|
||||||
- bit `0x02`: `standing_longjump`
|
|
||||||
|
|
||||||
Current `GameWindow` passes only contact `0/1` into a generic byte. The source of `standing_longjump` is not wired as a named state.
|
|
||||||
|
|
||||||
Impact: standing longjump movement-state updates can differ from retail.
|
|
||||||
|
|
||||||
### D4: JumpAction packet layout is retail-incompatible
|
|
||||||
|
|
||||||
Retail `JumpPack` packs:
|
|
||||||
|
|
||||||
- `float extent`
|
|
||||||
- `Vector3 velocity`
|
|
||||||
- full `Position`
|
|
||||||
- four `u16` update timestamps
|
|
||||||
- alignment
|
|
||||||
|
|
||||||
Current `JumpAction.Build` packs extent and velocity, then timestamps, then extra `objectGuid` and `spellId`, and does not pack `Position`.
|
|
||||||
|
|
||||||
Impact: local jump movement update is a high-confidence wire mismatch.
|
|
||||||
|
|
||||||
### D5: Position heartbeat is close but not fully proven
|
|
||||||
|
|
||||||
Retail `ShouldSendPositionEvent` gates autonomous position updates by active state, autonomy level, player smartbox, 1.0 second interval, cell/frame change, and contact-plane change. Current `PlayerMovementController` has a similar cadence.
|
|
||||||
|
|
||||||
Open proof point: retail `SendMovementEvent` visibly stamps `last_sent_position_time`; retail `SendPositionEvent` stamps time, position, and contact plane. Current `GameWindow` calls `NotePositionSent` after both MTS and AP, stamping all three.
|
|
||||||
|
|
||||||
Impact: the client may suppress or reorder autonomous position sends differently after movement-state sends.
|
|
||||||
|
|
||||||
### D6: MotionInterpreter lacks canonical retail raw-to-interpreted normalization
|
|
||||||
|
|
||||||
Retail `CMotionInterp::apply_raw_movement` copies raw state, calls `adjust_motion` for forward/sidestep/turn, applies run-hold behavior, then applies interpreted movement. Current `MotionInterpreter.DoMotion` stores commands directly and applies velocity directly.
|
|
||||||
|
|
||||||
Specific visible risks:
|
|
||||||
|
|
||||||
- `SideStepLeft` may produce no velocity because velocity code only recognizes `SideStepRight`.
|
|
||||||
- backward and sidestep speeds are patched elsewhere instead of normalized at the retail source point.
|
|
||||||
- turn run-speed scaling is not faithfully represented.
|
|
||||||
- local jump lateral velocity relies on comments that say this is temporary until `adjust_motion` is ported.
|
|
||||||
|
|
||||||
Impact: running, slow walking, slow side walking, backward movement, turning, and jump lateral motion are not retail-proven.
|
|
||||||
|
|
||||||
### D7: Animation application is split away from retail motion flow
|
|
||||||
|
|
||||||
Retail movement application sequences style, forward or falling, sidestep start/stop, turn start/stop, and actions through `CMotionInterp` and `CMotionTable::GetObjectSequence`. Current `AnimationSequencer` implements useful pieces, but `MotionInterpreter.apply_current_movement` is mostly velocity-oriented and does not drive animation state through the same retail order.
|
|
||||||
|
|
||||||
Impact: animations can look plausible while being state-machine divergent.
|
|
||||||
|
|
||||||
### D8: Force-walk and MoveTo are approximations
|
|
||||||
|
|
||||||
Retail `MoveToManager` is a queued command system over `CMotionInterp`, with pre-turn, move, aux turn, final heading, sticky targeting, progress failure, and `MovementParameters::get_command` walk/run/hold-key selection.
|
|
||||||
|
|
||||||
Current `ServerControlledLocomotion`, `RemoteMoveToDriver`, and `PlayerMovementController.BeginServerAutoWalk` approximate visible steering and cycle selection.
|
|
||||||
|
|
||||||
Impact: ACE force walking and monster/NPC MoveTo behavior are not retail-equivalent.
|
|
||||||
|
|
||||||
### D9: Inbound movement types 8 and 9 are dropped
|
|
||||||
|
|
||||||
Retail inbound movement supports:
|
|
||||||
|
|
||||||
- `8`: `TurnToObject`
|
|
||||||
- `9`: `TurnToHeading`
|
|
||||||
|
|
||||||
Current `UpdateMotion` handles interpreted state and MoveTo types `6/7`, but not `8/9`.
|
|
||||||
|
|
||||||
Impact: incoming server turn commands for characters, monsters, and NPCs are ignored.
|
|
||||||
|
|
||||||
### D10: Spawn-time movement state is weaker than live movement state
|
|
||||||
|
|
||||||
`CreateObject` can detect movement types and some flags, but it does not preserve full MoveTo target/origin/threshold data the way live `UpdateMotion` does.
|
|
||||||
|
|
||||||
Impact: monsters/NPCs spawned while already moving cannot be driven retail-equivalently until a later movement packet arrives.
|
|
||||||
|
|
||||||
### D11: Sequence/autonomy data is parsed then discarded
|
|
||||||
|
|
||||||
Retail carries movement sequence, server-control sequence, autonomous state, motion flags, and position sequence into movement application. Current events expose only a subset.
|
|
||||||
|
|
||||||
Impact: ordering, stale update rejection, and force-control transitions cannot match retail.
|
|
||||||
|
|
||||||
### D12: Jump/falling/contact gates are simplified
|
|
||||||
|
|
||||||
Retail allows specific movement while falling/dead and has separate jump checks for posture, stamina, constraints, pending motion, contact, and leave/hit-ground reapplication. Current code blocks or simplifies several of these paths.
|
|
||||||
|
|
||||||
Impact: airborne movement, falling animation, dead/fallen movement commands, and jump eligibility are not retail-proven.
|
|
||||||
|
|
||||||
## Priority plan
|
|
||||||
|
|
||||||
### Phase 0: Lock the oracle
|
|
||||||
|
|
||||||
1. Create small retail-reference helpers/tests that encode `RawMotionState::Pack`, `MoveToStatePack::Pack`, `AutonomousPositionPack::Pack`, `JumpPack::Pack`, `MovementParameters::UnPackNet`, and `MovementParameters::get_command`.
|
|
||||||
2. Every helper must cite the decomp function and address in the test name or comments.
|
|
||||||
3. Any unclear branch gets a TODO with address and blocked status, not an approximation.
|
|
||||||
|
|
||||||
Exit criteria: failing tests describe current divergences without changing runtime behavior yet.
|
|
||||||
|
|
||||||
### Phase 1: Fix outbound wire parity
|
|
||||||
|
|
||||||
1. Change `MoveToState` packing from presence-based to retail default-difference packing.
|
|
||||||
2. Add explicit raw-state model with defaults, current style, action list count, and per-axis hold keys.
|
|
||||||
3. Add explicit `contact` and `standingLongjump` parameters for MTS trailing byte.
|
|
||||||
4. Replace `JumpAction` with retail `JumpPack`: extent, velocity, full position, four timestamps, align; remove extra object/spell fields unless another retail opcode proves they belong elsewhere.
|
|
||||||
5. Verify timestamp order: instance `[8]`, server-control `[5]`, teleport `[4]`, force-position `[6]`.
|
|
||||||
6. Audit whether MTS should stamp only last-send time or also last-send position/contact plane.
|
|
||||||
|
|
||||||
Exit criteria: golden byte tests pass for walk, run, sidestep, turn, slow walk/toggle-run, jump, contact, longjump, and AP heartbeat cases.
|
|
||||||
|
|
||||||
### Phase 2: Port retail raw/interpreted motion core
|
|
||||||
|
|
||||||
1. Introduce retail-shaped `RawMotionState` and `InterpretedMotionState` in core physics.
|
|
||||||
2. Port `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, and `apply_raw_movement`.
|
|
||||||
3. Route local player input through this path instead of compensating in `PlayerMovementController`.
|
|
||||||
4. Preserve run rate and hold-key semantics, including toggle-run slow-walk behavior.
|
|
||||||
|
|
||||||
Exit criteria: movement state tests prove backward, sidestep left/right, run turn, run sidestep clamp, forward run, and slow-walk semantics match retail math.
|
|
||||||
|
|
||||||
### Phase 3: Reconnect animation to retail movement application
|
|
||||||
|
|
||||||
1. Port enough of `CMotionInterp::apply_current_movement` to sequence style, forward/falling, sidestep stop/start, turn stop/start, and actions in retail order.
|
|
||||||
2. Keep existing `AnimationSequencer` where it already matches retail, but move normalization out of sequencer-only code and into motion interpretation.
|
|
||||||
3. Add parity tests for reverse playback, link fallback, modifier state, action sequencing, and root-motion composition using real retail motion table data.
|
|
||||||
|
|
||||||
Exit criteria: local and remote animation selection flows from the same interpreted state that drives velocity.
|
|
||||||
|
|
||||||
### Phase 4: Port inbound movement and force-walk behavior
|
|
||||||
|
|
||||||
1. Extend `UpdateMotion` to parse and surface types `8` and `9`.
|
|
||||||
2. Preserve sequence/autonomy/motion flags through `WorldSession` events.
|
|
||||||
3. Preserve full spawn-time MoveTo state in `CreateObject`.
|
|
||||||
4. Replace approximate server-driven locomotion with a retail-shaped `MoveToManager` queue over `CMotionInterp`.
|
|
||||||
5. Port `MovementParameters::get_command`, including walk/run threshold, `can_run`, force-walk, hold-key application, fail distance, sticky/target following, and final heading.
|
|
||||||
|
|
||||||
Exit criteria: incoming interpreted motion, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading, and ACE force-walk packets animate and move through retail-equivalent state transitions.
|
|
||||||
|
|
||||||
### Phase 5: Integration captures and regression guard
|
|
||||||
|
|
||||||
1. Capture retail-equivalent fixtures for normal run, slow walk, sidestep, slow sidestep, backward, turn, jump, forced walk, NPC MoveTo, monster chase/flee, and remote player interpolation.
|
|
||||||
2. Assert wire bytes for outbound messages.
|
|
||||||
3. Assert parsed semantic state for inbound messages.
|
|
||||||
4. Assert animation command sequence and velocity for local and remote entities.
|
|
||||||
5. Run full test suite plus a targeted in-app smoke pass.
|
|
||||||
|
|
||||||
Exit criteria: no movement or animation path is accepted without a retail-addressed test.
|
|
||||||
|
|
||||||
## Initial test inventory to add or replace
|
|
||||||
|
|
||||||
- `MoveToState` golden tests: default raw state, walk forward speed `1.0`, run forward, backward, sidestep right/left, turn right/left, current style, action count, contact/longjump byte.
|
|
||||||
- `JumpAction` golden tests: retail `JumpPack` layout with full position and no object/spell fields.
|
|
||||||
- `AutonomousPosition` tests: timestamp order and contact byte.
|
|
||||||
- `PlayerMovementController` tests: forward walk/run, slow walk/toggle-run, backward, sidestep, turn-only run, jump release, server auto-walk suppression.
|
|
||||||
- `ShouldSendPositionEvent` tests: pre-interval cell change, pre-interval contact-plane change, interval frame change, idle no-send, airborne AP suppression.
|
|
||||||
- `UpdateMotion` tests: interpreted, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading.
|
|
||||||
- `CreateObject` tests: spawn-time MoveTo preservation.
|
|
||||||
- `WorldSession` tests: sequence/autonomy propagation.
|
|
||||||
- `MotionInterpreter` tests: `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, contact gates, falling fallback, jump eligibility.
|
|
||||||
- `AnimationSequencer` tests: retail sequence order, stop/start transitions, reverse playback, link fallback, modifier combine/subtract behavior.
|
|
||||||
- `MoveToManager` tests: pre-turn, move, aux turn, final heading, target following, fail distance, force-walk walk/run choice.
|
|
||||||
|
|
||||||
## Implementation rule
|
|
||||||
|
|
||||||
No code path in this system should remain justified by "feels like retail", "ACE-compatible", or "close enough". If the decomp is unclear, use Ghidra or captures to resolve it. If it still cannot be resolved, leave the behavior behind a clearly named unresolved test or blocked TODO rather than guessing.
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
# D6 — CMotionInterp motion-normalization port: pseudocode + integration map
|
|
||||||
|
|
||||||
Date: 2026-07-01
|
|
||||||
Phase: **L.1 / L.2b follow-up (D6)** — port retail's raw→interpreted motion
|
|
||||||
normalization so local velocity for backward/strafe-left comes from the retail
|
|
||||||
source instead of hand-mirrored controller code.
|
|
||||||
Standard: **decomp-verbatim.** Retail decomp is the oracle; ACE
|
|
||||||
`MotionInterp.cs` is the secondary oracle and was confirmed to match retail
|
|
||||||
**byte-for-byte** on every constant and branch (understand-phase workflow,
|
|
||||||
2026-07-01).
|
|
||||||
|
|
||||||
Oracle anchors (all in `docs/research/named-retail/acclient_2013_pseudo_c.txt`):
|
|
||||||
- `CMotionInterp::adjust_motion` — `0x00528010`, lines 305343-305400
|
|
||||||
- `CMotionInterp::apply_run_to_command` — `0x00527be0`, lines 305062-305123
|
|
||||||
- `CMotionInterp::get_state_velocity` — `0x00527d50`, lines 305160-305204
|
|
||||||
- `CMotionInterp::apply_raw_movement` — `0x005287e0`, lines 305817-305834
|
|
||||||
Secondary oracle: `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`
|
|
||||||
(constants L26-32; `adjust_motion` L394-428; `apply_run_to_command` L525-562;
|
|
||||||
`get_state_velocity` L678-700; `get_leave_ground_velocity` L654-663;
|
|
||||||
`apply_raw_movement` L506-523).
|
|
||||||
|
|
||||||
## Constants (retail = ACE, verified)
|
|
||||||
|
|
||||||
| name | value | retail anchor |
|
|
||||||
|---|---|---|
|
|
||||||
| BackwardsFactor | `0.649999976` | `007c8910`; WalkBackwards speed `*= -BackwardsFactor` |
|
|
||||||
| WalkAnimSpeed | `3.11999989` | `007c891c`; forward `v.Y = WalkAnimSpeed * fwdSpeed` |
|
|
||||||
| RunAnimSpeed | `4.0` | RunForward `v.Y`; also `maxSpeed = RunAnimSpeed * rate` |
|
|
||||||
| SidestepAnimSpeed | `1.25` | sidestep `v.X`; adjust_motion divisor |
|
|
||||||
| SidestepFactor | `0.5` | adjust_motion sidestep scale |
|
|
||||||
| RunTurnFactor | `1.5` | apply_run_to_command TurnRight |
|
|
||||||
| MaxSidestepAnimRate | `3.0` | apply_run_to_command SideStepRight clamp |
|
|
||||||
|
|
||||||
MotionCommand hex: `0x45000005`=WalkForward, `0x45000006`=WalkBackwards,
|
|
||||||
`0x44000007`=RunForward, `0x6500000d`=TurnRight, `0x6500000e`=TurnLeft,
|
|
||||||
`0x6500000f`=SideStepRight, `0x65000010`=SideStepLeft.
|
|
||||||
|
|
||||||
## The four functions (faithful pseudocode)
|
|
||||||
|
|
||||||
### apply_raw_movement (orchestrator) — 0x005287e0
|
|
||||||
```
|
|
||||||
if physics_obj == null: return
|
|
||||||
// copy 7 raw fields into interpreted
|
|
||||||
interp.current_style = raw.current_style
|
|
||||||
interp.forward_command = raw.forward_command ; interp.forward_speed = raw.forward_speed
|
|
||||||
interp.sidestep_command = raw.sidestep_command; interp.sidestep_speed = raw.sidestep_speed
|
|
||||||
interp.turn_command = raw.turn_command ; interp.turn_speed = raw.turn_speed
|
|
||||||
// normalize EACH channel with ITS OWN per-channel hold key
|
|
||||||
adjust_motion(ref interp.forward_command, ref interp.forward_speed, raw.forward_holdkey)
|
|
||||||
adjust_motion(ref interp.sidestep_command, ref interp.sidestep_speed, raw.sidestep_holdkey)
|
|
||||||
adjust_motion(ref interp.turn_command, ref interp.turn_speed, raw.turn_holdkey)
|
|
||||||
apply_interpreted_movement(arg2, arg3) // acdream: apply velocity via get_state_velocity
|
|
||||||
```
|
|
||||||
|
|
||||||
### adjust_motion(ref cmd, ref speed, holdKey) — 0x00528010
|
|
||||||
```
|
|
||||||
if weenie != null and !weenie.IsCreature(): return // non-creature: no adjust
|
|
||||||
switch cmd:
|
|
||||||
RunForward: return // already normalized — NO holdkey path
|
|
||||||
WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor // -0.649999976
|
|
||||||
TurnLeft: cmd = TurnRight; speed *= -1
|
|
||||||
SideStepLeft: cmd = SideStepRight; speed *= -1
|
|
||||||
// after remap, sidestep anim-rate scale:
|
|
||||||
if cmd == SideStepRight:
|
|
||||||
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // (3.12/1.25)*0.5 ≈ 1.24799995
|
|
||||||
// hold-key (runs for everything EXCEPT the RunForward early-return):
|
|
||||||
if holdKey == Invalid: holdKey = raw.current_holdkey
|
|
||||||
if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
|
|
||||||
```
|
|
||||||
GOTCHA: RunForward early-returns and does NOT run the hold-key path. The
|
|
||||||
sidestep negate happens BEFORE the ×1.248 scale (so SideStepLeft = -1.248*speed).
|
|
||||||
|
|
||||||
### apply_run_to_command(ref cmd, ref speed) — 0x00527be0
|
|
||||||
```
|
|
||||||
speedMod = 1.0
|
|
||||||
if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate
|
|
||||||
switch cmd:
|
|
||||||
WalkForward:
|
|
||||||
if speed > 0: cmd = RunForward // promote ONLY when moving forward (sign gate)
|
|
||||||
speed *= speedMod // UNCONDITIONAL — applies to backward too (negative speed)
|
|
||||||
TurnRight:
|
|
||||||
speed *= RunTurnFactor // *1.5
|
|
||||||
SideStepRight:
|
|
||||||
speed *= speedMod
|
|
||||||
if abs(speed) > MaxSidestepAnimRate: // clamp to ±3.0 (ACE resolves the {test ah,0x5} polarity)
|
|
||||||
speed = speed > 0 ? MaxSidestepAnimRate : -MaxSidestepAnimRate
|
|
||||||
```
|
|
||||||
GOTCHA: WalkForward `speed *= speedMod` is unconditional — so **backward
|
|
||||||
DOES get run-scaled** (resolves the UN-5 "backward cites nothing" doubt in
|
|
||||||
acdream's favor). The walk→run promotion is sign-gated: backward keeps a
|
|
||||||
negative speed so it stays WalkForward (not promoted).
|
|
||||||
|
|
||||||
### get_state_velocity() -> local Vector3 — 0x00527d50
|
|
||||||
```
|
|
||||||
v = (0,0,0)
|
|
||||||
if interp.sidestep_command == SideStepRight: v.X = SidestepAnimSpeed * interp.sidestep_speed // 1.25*s
|
|
||||||
if interp.forward_command == WalkForward: v.Y = WalkAnimSpeed * interp.forward_speed // 3.12*s
|
|
||||||
else if interp.forward_command == RunForward: v.Y = RunAnimSpeed * interp.forward_speed // 4.0*s
|
|
||||||
v.Z = 0
|
|
||||||
rate = MyRunRate; if weenie != null: weenie.InqRunRate(ref rate)
|
|
||||||
maxSpeed = RunAnimSpeed * rate // 4.0 * rate
|
|
||||||
if v.Length() > maxSpeed: v = normalize(v) * maxSpeed // PORT ACE'S FORM — decomp operand
|
|
||||||
// names are wrong (x87 register aliasing)
|
|
||||||
```
|
|
||||||
GOTCHA: backward/strafe-left produce velocity here ONLY because adjust_motion
|
|
||||||
already converted them to WalkForward/SideStepRight with negated speed. Z (jump)
|
|
||||||
is added by the caller `get_leave_ground_velocity`, AFTER this clamp.
|
|
||||||
|
|
||||||
## acdream current state (what D6 replaces)
|
|
||||||
|
|
||||||
Two divergent velocity paths (verified in source):
|
|
||||||
- **PATH A** — `MotionInterpreter.get_state_velocity` (`MotionInterpreter.cs:599-648`)
|
|
||||||
is faithful but only handles WalkForward/RunForward/SideStepRight; returns 0
|
|
||||||
for WalkBackward + SideStepLeft because `adjust_motion` is **not ported** (the
|
|
||||||
InterpretedState holds the un-normalized command).
|
|
||||||
- **PATH B** — the controller bypass (`PlayerMovementController.cs`): grounded
|
|
||||||
velocity block builds body-local velocity by hand — forward `localY=stateVel.Y`
|
|
||||||
(`:986`), backward `localY=-(WalkAnimSpeed*0.65*runMul)` (`:988`), strafe
|
|
||||||
`localX=±SidestepAnimSpeed*runMul` (`:994/:996`); `runMul = InqRunRate if Run
|
|
||||||
else 1.0` (`:981-983`). The **same formulas are re-copied in the jump block**
|
|
||||||
(`:1072`, `:1076-1079`) because LeaveGround→get_state_velocity would zero
|
|
||||||
backward/strafe. This duplication is register rows **TS-22** + **UN-5**.
|
|
||||||
|
|
||||||
Integration seams:
|
|
||||||
- Input enters at `PlayerMovementController.Update(dt, MovementInput)` (`:838`);
|
|
||||||
section 2 (`:911-1000`) maps input → `_motion.DoMotion`/`DoInterpretedMotion`
|
|
||||||
+ the hand-built velocity.
|
|
||||||
- Velocity is consumed by `_body.set_local_velocity` (`:998`, jump `:1090`) →
|
|
||||||
physics integration (`:1096-1130`) → `ResolveWithTransition` (`:1136-1160`).
|
|
||||||
- The **outbound wire** (section 6, `:1329-1399` → MovementResult → GameWindow
|
|
||||||
`:8264-8388`) is a SEPARATE construction (the L.2b RawMotionState); it sends
|
|
||||||
the RAW commands (WalkForward+HoldKey.Run, backward/strafe @1.0) and ACE/the
|
|
||||||
observer runs adjust_motion. D6 must NOT change these wire bytes.
|
|
||||||
|
|
||||||
## Behavior changes D6 introduces (retail-faithful; flag for smoke test)
|
|
||||||
|
|
||||||
1. **Strafe ~20% faster.** Current `1.25×runRate`; retail `1.25 × 1.248
|
|
||||||
(adjust_motion sidestep scale) × runRate = 1.56×runRate`, then clamped via
|
|
||||||
`SideStepSpeed ≤ 3.0` (so v.X ≤ 3.75). acdream is currently missing the 1.248
|
|
||||||
scale + the 3.0 clamp.
|
|
||||||
2. **Sidestep ±3.0 clamp becomes active** at high run rates.
|
|
||||||
3. **Backward unchanged** for typical run rates (already retail-faithful:
|
|
||||||
`3.12×0.65×runRate`), now sourced from the real pipeline.
|
|
||||||
4. **Backward/strafe-left no longer zero** out of `get_state_velocity` (the
|
|
||||||
TS-22 bug class is retired at the source, not hand-patched).
|
|
||||||
|
|
||||||
## Out of scope for D6 (follow-ups)
|
|
||||||
|
|
||||||
- **Turn**: `get_state_velocity` does not produce turn velocity (turn is
|
|
||||||
angular). acdream drives Yaw directly (`RemoteMoveToDriver.TurnRateFor`,
|
|
||||||
~90°/135° per sec). D6 keeps direct-Yaw turn; routing turn through interpreted
|
|
||||||
angular velocity is a separate slice. `adjust_motion` on the turn channel is
|
|
||||||
still ported (for completeness + the wire), but it does not change turn feel.
|
|
||||||
- **RawMotionState unification**: retail uses ONE raw state for both the wire
|
|
||||||
and the velocity pipeline. D6 keeps the L.2b wire construction separate and
|
|
||||||
builds/normalizes a velocity-side raw state; unifying the two is a later slice.
|
|
||||||
- `apply_current_movement` full sequence (style/forward/sidestep/turn/action
|
|
||||||
ordering, `CMotionTable::GetObjectSequence`) — the understand-phase extractor
|
|
||||||
for this one hit the structured-output cap; re-extract when the animation
|
|
||||||
sequencing slice needs it. Not required for the velocity port.
|
|
||||||
|
|
||||||
## Design decisions (locked with the user 2026-07-01)
|
|
||||||
|
|
||||||
1. **Full unification.** ONE `AcDream.Core.Physics.RawMotionState` (the L.2b
|
|
||||||
type) is built from `MovementInput` and drives BOTH the outbound wire packing
|
|
||||||
AND the local velocity/turn pipeline (`apply_raw_movement` →
|
|
||||||
`get_state_velocity` + turn omega). The separate GameWindow wire-construction
|
|
||||||
is retired; `MovementResult` carries the raw state, GameWindow packs it.
|
|
||||||
2. **`forward_speed = 1.0` on run — CONFIRMED viable (echo-test 2026-07-01).**
|
|
||||||
The echo-test settled it: acdream sends `forward_speed=1.0`, ACE broadcasts
|
|
||||||
back `RunForward @ runRate` (not `1.0`), and a retail observer saw +Acdream run
|
|
||||||
at full pace — so **ACE recomputes the broadcast run speed from run skill**
|
|
||||||
(the `MovementData.cs`-citing "ACE relays" assumption was wrong). The wire now
|
|
||||||
sends the retail-faithful raw `1.0` (D6.2b). Original reasoning below stands:
|
|
||||||
The raw `forward_speed`
|
|
||||||
must be `1.0` when running, because `apply_run_to_command` multiplies by
|
|
||||||
run-rate — carrying `runRate` in the raw state would double-scale to
|
|
||||||
`runRate²`. So the wire now sends `forward_speed=1.0` (omitted by
|
|
||||||
default-difference) + `HoldKey.Run`, NOT the current `runRate`. This is the
|
|
||||||
retail-faithful encoding (observer/server derives the run speed). **Acceptance
|
|
||||||
= the smoke echo:** send `1.0`, confirm the `UM` echo still shows the player
|
|
||||||
at `~runRate` (⇒ ACE recomputes from run skill ⇒ correct). If the echo shows
|
|
||||||
`~1.0`, ACE relays literally and the wire half reverts. The L.2b golden tests
|
|
||||||
update to the faithful encoding (forward_speed omitted on run).
|
|
||||||
3. **Turn ported to interpreted, feel unchanged.** Local turn is driven from the
|
|
||||||
interpreted `turn_speed` (`adjust_motion`: TurnLeft→TurnRight `×-1`, `×1.5`
|
|
||||||
run) via `omega.Z = ±(π/2) × turn_speed` — the SAME formula the remote path
|
|
||||||
already uses (`GameWindow.cs:4845`). Numerically identical to today's
|
|
||||||
`TurnRateFor` for the local player (both `π/2 × RunTurnFactor`), so no
|
|
||||||
turn-feel change; the fixed `TurnRateFor` direct-Yaw is replaced by the
|
|
||||||
pipeline. **AP-9 stays** (the `π/2` base rate is still an approximation of the
|
|
||||||
per-creature TurnSpeed; wiring that is a separate follow-up).
|
|
||||||
4. **References caveat.** Only `references/WorldBuilder` is checked out in this
|
|
||||||
worktree; ACE/holtburger are not. The retail decomp
|
|
||||||
(`docs/research/named-retail/`) is the sole in-repo oracle and was verified
|
|
||||||
directly for all four functions. ACE values quoted here are training-memory
|
|
||||||
cross-checks that happen to match the decomp — do not cite ACE file:lines.
|
|
||||||
|
|
||||||
**Retires / touches:** register **TS-22** deleted (adjust_motion ported;
|
|
||||||
get_state_velocity no longer returns 0 for backward/strafe-left). **UN-5**
|
|
||||||
resolved (run-scaling backward IS retail — `apply_run_to_command` unconditional
|
|
||||||
`*speedMod`). **AP-9** stays (turn base rate). A wire-encoding change row is
|
|
||||||
added if the echo test confirms `forward_speed=1.0`.
|
|
||||||
|
|
||||||
**Implementation slices:**
|
|
||||||
- **D6.1 (delegated, bounded):** port `adjust_motion` + `apply_run_to_command`
|
|
||||||
into `MotionInterpreter` + conformance tests, tests-first, against this doc.
|
|
||||||
- **D6.2 (lead, delicate):** `apply_raw_movement` orchestrator + build the
|
|
||||||
unified `RawMotionState` in `PlayerMovementController` from `MovementInput`,
|
|
||||||
route grounded + jump velocity through `get_state_velocity`, drive turn omega
|
|
||||||
from interpreted `turn_speed`, thread the raw state through `MovementResult`
|
|
||||||
to the GameWindow packer, delete the hand-mirrors (`:988/:994/:996` + jump
|
|
||||||
`:1072/:1076-1079`) and the `TurnRateFor` direct-Yaw, update the L.2b golden
|
|
||||||
tests. Then smoke (echo + strafe/backward/turn/jump feel).
|
|
||||||
|
|
||||||
## Conformance-test targets
|
|
||||||
|
|
||||||
- `adjust_motion`: each of the 7 command transforms + the sidestep 1.248 scale +
|
|
||||||
the backward -0.65 + the negate-before-scale order + the RunForward no-op +
|
|
||||||
the non-creature early return + per-channel holdKey inheritance.
|
|
||||||
- `apply_run_to_command`: WalkForward promote-when-speed>0 + unconditional
|
|
||||||
`*speedMod`; TurnRight ×1.5; SideStepRight ×speedMod + ±3.0 clamp (test the
|
|
||||||
boundary at runRate that pushes |speed| past 3.0).
|
|
||||||
- `get_state_velocity`: backward + strafe-left now non-zero (the TS-22 fix);
|
|
||||||
the maxSpeed normalize clamp; strafe = 1.56×runRate clamped to ≤3.75.
|
|
||||||
- `apply_raw_movement`: 3-channel copy + per-channel adjust + the run-promotion
|
|
||||||
of a running-forward raw state.
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
# Inbound motion deviation map — remote-entity animation + position vs retail
|
|
||||||
|
|
||||||
Date: 2026-07-02
|
|
||||||
Mode: **/investigate report — no code changes made.** This doc + the four mapper
|
|
||||||
reports under `2026-07-02-inbound-motion-maps/` are the only writes (uncommitted).
|
|
||||||
Branch: `claude/vigorous-joliot-f0c3ad`. Follows the handoff
|
|
||||||
`2026-07-02-inbound-motion-verbatim-port-handoff.md`.
|
|
||||||
|
|
||||||
## Symptom (acceptance oracle — user's live observation, axiom)
|
|
||||||
|
|
||||||
Watching a remote player in acdream: walk↔run transitions WITHOUT stopping react
|
|
||||||
too slowly in the motion interpreter, throwing animation + position off afterward
|
|
||||||
and compounding through continued running/turning; plus general sliding + position
|
|
||||||
errors on stop.
|
|
||||||
|
|
||||||
## Headline findings
|
|
||||||
|
|
||||||
1. **The premise behind acdream's current walk↔run mechanism is refuted.** The
|
|
||||||
#39-era UP-velocity refinement layer (`ApplyPlayerLocomotionRefinement`,
|
|
||||||
0.2 s UM-grace + 5–10 Hz UP cadence + 5.5/4.5 m/s hysteresis) was built on the
|
|
||||||
2026-05-06 finding "the wire goes silent on Shift-toggle." Both oracles now
|
|
||||||
contradict that finding:
|
|
||||||
- **Retail sends.** `CommandInterpreter`'s HoldRun command handler
|
|
||||||
(`0x85000001`, pseudo-C `acclient_2013_pseudo_c.txt:699322-699328`, addr
|
|
||||||
0x006b37a8→0x006b3852) calls `SetHoldRun` then `SendMovementEvent()`
|
|
||||||
**whenever the player is not standing still** — i.e. a Shift toggle while W
|
|
||||||
is held emits a fresh MoveToState. (Same for HoldSidestep `0x85000002`.)
|
|
||||||
- **ACE relays.** `GameActionMoveToState.cs:36` calls
|
|
||||||
`BroadcastMovement(moveToState)` **unconditionally** for every inbound
|
|
||||||
MoveToState (`Player_Networking.cs:364-365` → `GameMessageUpdateMotion`
|
|
||||||
broadcast, with the WalkForward+HoldKey.Run→RunForward upgrade and
|
|
||||||
run-skill speed recompute).
|
|
||||||
So an observer **should** receive a UM with ForwardCommand flipping
|
|
||||||
Walk↔Run on every Shift toggle. Why the 2026-05-06 clean test saw only
|
|
||||||
Ready↔Run `[FWD_WIRE]` transitions is the one open empirical question —
|
|
||||||
settle it with probe S0 below before touching the refinement layer.
|
|
||||||
2. **Retail never adapts animation from observed pace.** Three independent decomp
|
|
||||||
dives + the ACE port cross-check all converge: the inbound position path
|
|
||||||
(0xF748/0xF619 → `SmartBox::HandleReceivedPosition` → `MoveOrTeleport` →
|
|
||||||
`InterpolationManager` chase) has **zero writes into `CMotionInterp`** — it
|
|
||||||
only *reads* `get_adjusted_max_speed()` (×2.0) as the chase-speed cap. The
|
|
||||||
wire's velocity field is a **dead parameter** in `MoveOrTeleport`
|
|
||||||
(pseudo-C:284304 — passed, never dereferenced). Animation changes come only
|
|
||||||
from motion events (0xF74C → `MovementManager::unpack_movement`).
|
|
||||||
acdream's pace→cycle inference layer has **no retail equivalent** (DEV-2)
|
|
||||||
and is an **unregistered deviation**.
|
|
||||||
3. **acdream's instant DR-velocity snap on walk↔run is retail-correct; the
|
|
||||||
pose hard-cut is the real deviation.** Adversarial verification refuted the
|
|
||||||
initial "retail paces velocity through the link clip" claim: in
|
|
||||||
`GetObjectSequence`'s link-transition branch, `add_motion(new cycle)` is the
|
|
||||||
LAST synchronous call and `CSequence::set_velocity` is a hard overwrite —
|
|
||||||
the sequence velocity snaps to the new cycle's value the same tick
|
|
||||||
(pseudo-C:298437-298468, 300798-300814; ACE `MotionTable.cs:144-168`,
|
|
||||||
`Sequence.cs:127-130`). What acdream's Fix B loses is only the link **pose**
|
|
||||||
(plus its authored root-motion frames): retail plays the authored
|
|
||||||
walk↔run link clip to completion before the new cycle's animation starts.
|
|
||||||
4. **Retail's remote pipeline is one atomic funnel; acdream's is three loosely
|
|
||||||
coupled writes.** That structural split (DEV-1) is what lets every gap
|
|
||||||
(DEV-2/3/5/6) desync pose from velocity with nothing to reconcile them.
|
|
||||||
|
|
||||||
## The retail inbound model (what "verbatim" means here)
|
|
||||||
|
|
||||||
Two decoupled inbound paths, meeting only at the per-tick frame combiner:
|
|
||||||
|
|
||||||
**Motion events (0xF74C / 0xF619-embedded)** —
|
|
||||||
`ACSmartBox::DispatchSmartBoxEvent` (pseudo-C:357117) →
|
|
||||||
`CPhysics::SetObjectMovement` (271370, staleness-gated on `update_times[8]`) →
|
|
||||||
`MovementManager::unpack_movement` (300563, **10-way jump table**: case 0 =
|
|
||||||
InterpretedMotionState, 6/7 = MoveTo, 8/9 = TurnToObject/TurnToHeading) →
|
|
||||||
`CMotionInterp::move_to_interpreted_state` (305936: flat `copy_movement_from`
|
|
||||||
overwrite of `interpreted_state`, caches `my_run_rate` from RunForward's
|
|
||||||
forward_speed) → `apply_interpreted_movement` (305713: per-slot
|
|
||||||
`DoInterpretedMotion`/`StopInterpretedMotion` dispatch in retail order — style,
|
|
||||||
forward, sidestep-or-stop, turn-or-stop, idle-stop enqueue) →
|
|
||||||
`CMotionTable::GetObjectSequence` (298636), which decides:
|
|
||||||
- **same substate + same speed sign** → fast path: `change_cycle_speed`
|
|
||||||
(playback rate × newSpeed/oldSpeed) + `subtract_motion`/`combine_motion`
|
|
||||||
velocity swap — same node keeps playing, **no restart** (acdream's SCFAST
|
|
||||||
fast-path is the equivalent);
|
|
||||||
- **substate change (walk↔run!)** → link path: `get_link` (double-hop through
|
|
||||||
the style default when no direct link / sign flip), `clear_physics` +
|
|
||||||
`remove_cyclic_anims` (keeps a mid-playback link, drops the old loop),
|
|
||||||
`add_motion(link)` + `add_motion(cycle)`, `re_modify` re-applies active
|
|
||||||
modifiers. Velocity = the **cycle's** (last overwrite, instant); pose = link
|
|
||||||
first, then cycle.
|
|
||||||
Bookkeeping: `MotionTableManager::add_to_queue`/`remove_redundant_links`/
|
|
||||||
`CheckForCompletedMotions` (290645-290854) + `CMotionInterp::pending_motions`/
|
|
||||||
`MotionDone` (305032/305238) reconcile completed commands at anim boundaries.
|
|
||||||
|
|
||||||
**Position events (0xF748/0xF619)** — `SmartBox::HandleReceivedPosition`
|
|
||||||
(92896): for remotes, `MoveOrTeleport` (284304) only — staleness-gated
|
|
||||||
(`POSITION_TS`/`TELEPORT_TS`), >96 units from player → plain snap, else
|
|
||||||
`InterpolationManager::InterpolateTo` node queue (cap 20). Per tick,
|
|
||||||
`adjust_offset` (353071) moves the full remaining distance capped at
|
|
||||||
`2 × get_adjusted_max_speed()` (fallback 7.5 m/s), stall detection (<30%
|
|
||||||
progress per 5 frames → fail counter >3 → hard snap). **acdream's
|
|
||||||
`InterpolationManager` is already a verbatim port of this (L.3).**
|
|
||||||
|
|
||||||
**Per tick** (`UpdateObjectInternal` 283611): `CPartArray::Update` →
|
|
||||||
`CSequence::update` fills the frame delta from **the animation frames' baked
|
|
||||||
per-frame deltas** (anim-node path) or `velocity×dt` (`apply_physics` fallback);
|
|
||||||
`PositionManager::adjust_offset` **overwrites** it when a chase node is active
|
|
||||||
(last-writer-wins, NOT additive — matches acdream's `ComputeOffset` REPLACE
|
|
||||||
dichotomy); then the **full collision sweep** (`transition`) runs for remotes
|
|
||||||
too (acdream deliberately skips it for grounded player remotes — DEV-10).
|
|
||||||
|
|
||||||
**Stop** — one path for every stop: `CMotionTable::StopSequenceMotion` (298954)
|
|
||||||
re-enters `GetObjectSequence` targeting the style default with force=1:
|
|
||||||
`clear_physics` zeroes velocity/omega **at transition start**, the idle-link's
|
|
||||||
authored deceleration plays, `add_to_queue(READY)` + `RemoveMotion` bookkeeping.
|
|
||||||
Modifier stop = `subtract_motion` only. No pace-derived stop inference.
|
|
||||||
|
|
||||||
## Ranked deviation map
|
|
||||||
|
|
||||||
All 10 deviations were adversarially verified through two lenses (refute vs
|
|
||||||
current acdream code / refute vs retail decomp+ACE). 9 CONFIRMED, 1 REFUTED
|
|
||||||
(DEV-4's velocity half — corrected below). Full verify evidence:
|
|
||||||
`…/tasks/waqdgyk0m.output` (session temp) — the load-bearing citations were
|
|
||||||
additionally re-read first-hand in this session.
|
|
||||||
|
|
||||||
| # | Sev | Deviation | Symptom link |
|
|
||||||
|---|-----|-----------|--------------|
|
|
||||||
| DEV-1 | HIGH | Remote inbound bypasses the CMotionInterp funnel: bulk-copy + direct `SetCycle` instead of `move_to_interpreted_state`/`apply_interpreted_movement` | Structural root: pose + DR velocity are separate writes; every gap desyncs them with no reconciliation |
|
|
||||||
| DEV-2 | HIGH | Non-retail grace-window + hysteresis pace→cycle inference for player remotes (`UmGraceSeconds=0.2`, promote 5.5 / demote 4.5 m/s) | The direct "reacts too slowly": ≥0.2 s + UP cadence + hysteresis-crossing before the gait flips; backlog builds meanwhile. **Unregistered deviation**; premise refuted (headline 1) |
|
|
||||||
| DEV-3 | HIGH | Stop is three unwired mechanisms (UM-Ready overwrite / dead UP-velocity StopCompletely / landing); no `StopSequenceMotion` path; nothing zeroes body velocity or reconciles the queue | Direct cause of stop-slide: pose goes Ready while the body drains pre-stop waypoints at up to 2× maxSpeed |
|
|
||||||
| DEV-4 | MED (corrected) | Fix B drops the authored walk↔run link **pose** (hard visual cut + lost link root motion). ~~Velocity snap~~ — REFUTED: retail snaps velocity instantly too | Visual-only hard cut at each toggle; NOT a position-error source |
|
|
||||||
| DEV-5 | HIGH | Inbound movement types 8/9 (TurnToObject/TurnToHeading) dropped → misapplied as `SetCycle(Ready)` | Mid-locomotion turn command becomes a spurious full stop — error injected exactly during "compounding through running/turning" |
|
|
||||||
| DEV-6 | MED | Sequence-number staleness gate missing (movementSequence/serverControlSequence/isAutonomous parsed-then-discarded; retail gates on `update_times[8]`) | A reordered stale UM re-applies the old gait or un-stops a stop (extends register TS-26 to UM) |
|
|
||||||
| DEV-7 | MED | `pending_motions`/`pending_animations`/`MotionDone`/`remove_redundant_links` lifecycle absent | Enabler: missing `remove_redundant_links` is what made rapid toggles restart links forever, forcing Fix B; without MotionDone nothing clears per-transition bookkeeping |
|
|
||||||
| DEV-8 | MED | Remote DR velocity synthesized from hardcoded gait constants at SetCycle time vs retail's MotionData-accumulated CSequence velocity (+ baked per-frame anim deltas) | Any per-gait speed mismatch vs ACE's integration = constant-rate error that fills the interp backlog → discharges as slide/snap |
|
|
||||||
| DEV-9 | MED | Modifier layer missing: no `re_modify`, single-cycle selection, omega formula-seeded instead of combine/subtract | Turn contribution approximated independently of the motion table → heading-rate error while running+turning |
|
|
||||||
| DEV-10 | LOW | Bifurcated position-drive paths; grounded player remotes skip the per-tick collision sweep (retail sweeps every entity) | None for this symptom directly; parity + pass-through-geometry hazard |
|
|
||||||
|
|
||||||
### Key anchors per deviation
|
|
||||||
|
|
||||||
- **DEV-1** — retail: pseudo-C 305936 (`move_to_interpreted_state` 0x005289c0),
|
|
||||||
305713 (`apply_interpreted_movement` 0x00528600), 298636 (`GetObjectSequence`
|
|
||||||
0x00522860), 293301 (`copy_movement_from` 0x0051e750). acdream:
|
|
||||||
`GameWindow.cs:4590/4598` (bulk-copy), `:4769` (separate SetCycle), `:4517`
|
|
||||||
(comment-only funnel ref); D6.2 raw-input funnel is local-player-only
|
|
||||||
(`PlayerMovementController.cs:761-1014`). Nuance (verify correction): the
|
|
||||||
sidestep/turn axes DO route through `DoInterpretedMotion`/`StopInterpretedMotion`
|
|
||||||
(`GameWindow.cs:4794-4857`); the forward axis — the crux — is the bulk-copy.
|
|
||||||
- **DEV-2** — acdream: `GameWindow.cs:5095/5104/5110` (constants),
|
|
||||||
`:5112-5300` (both methods), `:5128-5134` (the unconfirmed-premise comment).
|
|
||||||
Retail: no equivalent exists (searched: InterpolationManager/PositionManager
|
|
||||||
ranges 352000-353400 — zero `CMotionInterp` writes; `MoveOrTeleport` velocity
|
|
||||||
param dead at 284304).
|
|
||||||
- **DEV-3** — retail: 298954 (`StopSequenceMotion` 0x00522fc0), 305635
|
|
||||||
(`StopInterpretedMotion` 0x00528470), 301194 (`clear_physics` 0x00524d50).
|
|
||||||
acdream: `GameWindow.cs:4364-4367` (UM-Ready), `:5711-5731` (dead UP-velocity
|
|
||||||
stop — ACE player UPs carry velocity=null per `:5700-5710`), `:5527-5553`
|
|
||||||
(landing = only `Interp.Clear` site); plus the acdream-invented 300 ms
|
|
||||||
stop-detection window (`GameWindow.cs:4862-4890` + TickAnimations) — also
|
|
||||||
retail-less, part of this cluster.
|
|
||||||
- **DEV-4** — retail: 298636 link branch; 298552 (`get_link`); 301777
|
|
||||||
(`append_animation`); velocity-overwrite refutation: 298437-298468
|
|
||||||
(`add_motion` 0x005224b0) + 300798-300814 (`set_velocity`/`set_omega`
|
|
||||||
overwrites), ACE `MotionTable.cs:144-168` + `Sequence.cs:127-130/221-230`.
|
|
||||||
acdream: `AnimationSequencer.cs:585-635` (Fix B), `:686-754` (velocity
|
|
||||||
synthesis — retail-equivalent in timing).
|
|
||||||
- **DEV-5** — retail: 300563/300707 (jump table cases 8/9 →
|
|
||||||
`MoveToManager::TurnToObject/TurnToHeading`). acdream:
|
|
||||||
`UpdateMotion.cs:136-238` (no case 8/9), `GameWindow.cs:4364-4367`
|
|
||||||
(null-command → Ready misfire). Confirmed live earlier: `mt=0x09` arrives,
|
|
||||||
acdream plays Ready (2026-06-26 audit D9).
|
|
||||||
- **DEV-6** — retail: 271370 (`SetObjectMovement` staleness gate), 357224
|
|
||||||
(`update_times[8]` compare). acdream: `UpdateMotion.cs:89-106`,
|
|
||||||
`UpdatePosition.cs:29-31/156` (parsed-then-discarded).
|
|
||||||
- **DEV-7** — retail: 305238 (`MotionDone` 0x00527ec0), 290854
|
|
||||||
(`MotionTableManager::add_to_queue` 0x0051bfe0), 290771
|
|
||||||
(`remove_redundant_links` 0x0051bf20), 290645 (`CheckForCompletedMotions`).
|
|
||||||
acdream: `AnimationSequencer.cs:401` (always-synchronous SetCycle),
|
|
||||||
`:1128-1129` (`AnimationDoneSentinel` exists, unconsumed by any motion
|
|
||||||
bookkeeping).
|
|
||||||
- **DEV-8** — retail: 298437-298492 (`add_motion`/`combine_motion`/
|
|
||||||
`subtract_motion`), 302402 (`CSequence::update` — anim-frame deltas are the
|
|
||||||
primary drive), 305160 (`get_state_velocity` constants 3.12/4.0 — the
|
|
||||||
*logical* values acdream synthesizes). acdream: `AnimationSequencer.cs:686-754`.
|
|
||||||
- **DEV-9** — retail: 298636 (`re_modify` at rebuild tail), 298954 (modifier
|
|
||||||
`subtract_motion` branch), 305713 (per-slot dispatch). acdream:
|
|
||||||
`GameWindow.cs:4648-4683` (single-cycle pick), `:4841-4847` (ObservedOmega
|
|
||||||
formula seed), `:10097-10106` (manual omega integration).
|
|
||||||
- **DEV-10** — retail: 280817/283611 (sweep for all entities). acdream:
|
|
||||||
`GameWindow.cs:9717-9722` (fork), `:9883-9890` (sweep skipped, player-remote
|
|
||||||
path), `:10156-10181` (sweep present, NPC path), `:5657` (NPC UP hard-snap).
|
|
||||||
|
|
||||||
## Recommended port campaign (for approval — no code written yet)
|
|
||||||
|
|
||||||
Ordered so each slice is independently landable, tests-first, minimal-surgery
|
|
||||||
(no sequencer rewrite; `SetCycle` internals stay). Every slice updates
|
|
||||||
`docs/architecture/retail-divergence-register.md` in the same commit.
|
|
||||||
|
|
||||||
- **S0 — the wire probe (no code).** Live capture, acdream as observer,
|
|
||||||
actor driven from retail: `ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1`,
|
|
||||||
structured protocol with the walk↔run Shift toggle as the centerpiece;
|
|
||||||
optionally WireMCP on loopback :9000 for the raw S→C bytes. Expected per the
|
|
||||||
oracles: a UM with ForwardCommand flipping Walk↔Run on every toggle
|
|
||||||
(`[UM_RAW]` is the arbiter — the 2026-05-06 test read `[FWD_WIRE]`, which
|
|
||||||
only fires on interpreted-command *change* after acdream's own resolution).
|
|
||||||
Outcome decides DEV-2's disposition. *(Needs the user driving the retail
|
|
||||||
client — this is the one stop-and-wait step.)*
|
|
||||||
- **S1 — DEV-6 staleness gate.** ~30 isolated lines: capture the two u16s into
|
|
||||||
`UpdateMotion.Parsed`, retail wraparound-newer compare per guid, drop stale.
|
|
||||||
Unit-testable against the 0x7fff wraparound rule. Safe first commit.
|
|
||||||
- **S2 — DEV-1 funnel (the centerpiece), absorbing DEV-3 + DEV-9 dispatch.**
|
|
||||||
Port `move_to_interpreted_state` + `apply_interpreted_movement` into
|
|
||||||
`MotionInterpreter`, with `DoInterpretedMotion` routing to the existing
|
|
||||||
`AnimationSequencer.SetCycle` as the `GetObjectSequence` backend.
|
|
||||||
`OnLiveMotionUpdated`'s remote SubState branch collapses to one
|
|
||||||
`move_to_interpreted_state(ims)` call; stop flows through
|
|
||||||
`StopInterpretedMotion` (no `Interp.Clear()` band-aid — the queue's final
|
|
||||||
waypoints ARE the true stop position); sidestep/turn become per-slot retail
|
|
||||||
dispatch. Parity test first: replay a captured UM stream through old + new
|
|
||||||
paths, assert identical SetCycle sequence + InterpretedState trace; then a
|
|
||||||
stop-slide acceptance case.
|
|
||||||
- **S3 — DEV-7 bookkeeping.** `MotionTableManager` companion (pending_animations,
|
|
||||||
`add_to_queue`, `remove_redundant_links`, `CheckForCompletedMotions`) wiring
|
|
||||||
the existing `AnimationDoneSentinel`; `pending_motions`/`MotionDone` in
|
|
||||||
`MotionInterpreter` second. Additive, decomp-semantics unit tests first.
|
|
||||||
- **S4 — DEV-4 corrected: retire Fix B.** Restore the link-pose enqueue for
|
|
||||||
locomotion↔locomotion now that S3's `remove_redundant_links` removes Fix B's
|
|
||||||
original justification (link-restart spam). Velocity stays instant
|
|
||||||
(retail-correct). Acceptance: user visual side-by-side.
|
|
||||||
- **S5 — DEV-2 disposition per S0.** If UMs arrive: delete the refinement layer
|
|
||||||
(dead weight + latency + feedback loop) and let the S2 funnel drive. If the
|
|
||||||
wire is genuinely silent: keep a minimal layer as an explicitly registered
|
|
||||||
ACE-adaptation row (a retail client on the same wire would show the stale
|
|
||||||
gait too — matching retail display means NOT inferring), and file the gap
|
|
||||||
against ACE. Either way DEV-2's machinery stops being an unregistered
|
|
||||||
deviation.
|
|
||||||
- **S6 — remainder.** DEV-5 (parse types 8/9 → heading target; cross-check field
|
|
||||||
order vs Chorizite + holtburger first), DEV-8 (evidence first: dump Humanoid
|
|
||||||
MotionTable Walk/Run `MotionData.Velocity`+Flags from the dat, diff synthesized
|
|
||||||
magnitudes vs ACE's integration), DEV-10 deferred (fold-in after S2; NPC
|
|
||||||
hard-snap replacement needs explicit user approval per the reverted-campaign
|
|
||||||
precedent).
|
|
||||||
|
|
||||||
## What we've ruled out
|
|
||||||
|
|
||||||
- "acdream lacks any transition machinery" — false; `SetCycle` resolves authored
|
|
||||||
links (`GetLink`) and has the retail same-substate fast path (SCFAST ≈
|
|
||||||
`change_cycle_speed`).
|
|
||||||
- "Retail paces the walk↔run *velocity* change through the link clip" — refuted
|
|
||||||
(headline 3); instant velocity snap is retail-correct.
|
|
||||||
- "Retail refines the remote's cycle from UP-derived velocity" (#39's premise)
|
|
||||||
— refuted; no such mechanism exists in the decomp (two independent dives +
|
|
||||||
ACE cross-check).
|
|
||||||
- The `InterpolationManager` chase itself — already a verbatim port (L.3);
|
|
||||||
constants re-verified this session (2× adjusted max speed, 0.05 desired
|
|
||||||
distance, 30%/5-frame stall, fail>3 blip, 20-node cap, 96-unit gate upstream).
|
|
||||||
|
|
||||||
## What this is NOT
|
|
||||||
|
|
||||||
This is NOT a "port the whole CSequence/CPartArray stack" campaign — the
|
|
||||||
sequencer's node/link/queue mechanics are close enough that the funnel (DEV-1),
|
|
||||||
bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically;
|
|
||||||
and it is NOT a dead-reckoning-tuning problem — the chase is already retail,
|
|
||||||
it's the inputs (cycle + velocity source + stop signal) that deviate.
|
|
||||||
|
|
||||||
## S0 wire-probe RESULTS (2026-07-02, post-S1 live capture)
|
|
||||||
|
|
||||||
Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured
|
|
||||||
protocol incl. Shift toggles. 280 UMs captured (`launch-s0-wireprobe.log`).
|
|
||||||
|
|
||||||
1. **The wire is NOT silent — DEV-2's premise is dead.** Walk↔run toggles
|
|
||||||
arrive explicitly: `fwd=0x0005 fwdSpd=null(=1.0)` ↔ `fwd=0x0007
|
|
||||||
fwdSpd=2.85`. Root cause of the 2026-05-06 misreading: retail's
|
|
||||||
default-difference packing baselines `forward_command` against **Ready**
|
|
||||||
(`RawMotionState.Default`, our own D6.2b verbatim port) — W-held is
|
|
||||||
non-default and always packed, so ACE (`MovementData.cs:104-119`) always
|
|
||||||
re-emits it with the holdKey upgrade + run-skill speed.
|
|
||||||
2. **flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE**
|
|
||||||
— they are genuine keys-released / heading-only MoveToState relays
|
|
||||||
(all-default raw state packs nothing; ACE `RawMotionState.Read` leaves
|
|
||||||
absent = Invalid; `MovementData` emits nothing). Retail semantics
|
|
||||||
(verified: `InterpretedMotionState` ctor 0x0051e8d0 defaults
|
|
||||||
`forward_command=Ready`; `MovementManager::unpack_movement` case 0
|
|
||||||
UnPacks into a default-constructed state;
|
|
||||||
`CMotionInterp::move_to_interpreted_state` 0x005289c0 does a FLAT
|
|
||||||
`copy_movement_from` + `apply_current_movement`): an empty UM = a real
|
|
||||||
full stop. acdream already maps it to Ready — but the DEV-2 refinement
|
|
||||||
then re-promotes the cycle from UP pace after the 0.2 s grace, producing
|
|
||||||
a Ready↔Run thrash against legitimate stop signals. That thrash is the
|
|
||||||
observed flicker/rubber-band component.
|
|
||||||
3. **S1 gate validated live**: 0 `[UM_STALE]` drops across 280 UMs;
|
|
||||||
movementSeq advances +1 per UM; byte-level layout confirmed
|
|
||||||
(`instanceSeq|movementSeq|serverControlSeq|isAutonomous` exactly where
|
|
||||||
the parser reads them).
|
|
||||||
4. Also confirmed in `unpack_movement` (0x00524440) for the S2 port: the
|
|
||||||
outer style u16 routes through `command_ids[]` and applies via
|
|
||||||
`DoMotion` ONLY when it differs from the current style; motionFlags
|
|
||||||
carries sticky-object (0x100 → `stick_to_object`) and standing_longjump
|
|
||||||
(0x200); the actions list is consumed under a 15-bit
|
|
||||||
`server_action_stamp` wraparound compare
|
|
||||||
(`move_to_interpreted_state` 0x005289c0, lines 305953-305989).
|
|
||||||
|
|
||||||
**Disposition: S5 = delete the player pace-inference layer** (executed in the
|
|
||||||
same session; NPC `PlanFromVelocity` path untouched — its unification is S6).
|
|
||||||
|
|
||||||
## Provenance
|
|
||||||
|
|
||||||
- Workflow `wf_b92a5670-283`: 4 read-only mappers (retail decomp / ACE port /
|
|
||||||
acdream code / prior-research claims — full reports preserved in
|
|
||||||
`2026-07-02-inbound-motion-maps/`) → synthesis → 2-lens adversarial verify
|
|
||||||
per deviation (25 agents, ~3.0 M tokens). The ACE-port mapper crashed on
|
|
||||||
structured output AFTER writing its report file; synthesis read all four
|
|
||||||
files. One verify lens (DEV-4 retail side) delivered the REFUTED verdict
|
|
||||||
incorporated above.
|
|
||||||
- Targeted agents: retail remote-anim adaptation dive (+2 children:
|
|
||||||
InterpolationManager decomp dump, ACE InterpolationManager cross-check).
|
|
||||||
- First-hand session reads: `OnLiveMotionUpdated` (GameWindow 4230-4930),
|
|
||||||
DR tick path (9700-9920), `AnimationSequencer.SetCycle` (340-790),
|
|
||||||
`ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement`
|
|
||||||
(5077-5300), `ServerControlledLocomotion`, `PositionManager`,
|
|
||||||
`InterpolationManager`, `UpdatePosition` parser, ACE `Player_Tick.cs` /
|
|
||||||
`Player_Networking.cs` / `GameActionMoveToState.cs`, decomp
|
|
||||||
`SendMovementEvent`/HoldRun sites (699274-700312), 2026-06-04 deep-dive
|
|
||||||
divergence list, ISSUES #39, divergence register scan.
|
|
||||||
- Corrections applied to the raw workflow output: DEV-1 anchor fix
|
|
||||||
(`MotionInterpreter.cs:399/412/558` reference different retail functions —
|
|
||||||
the only funnel comment-refs are `GameWindow.cs:4517` +
|
|
||||||
`PlayerMovementController.cs:593`); DEV-4 rewritten per its refutation.
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
# acdream CURRENT inbound remote-entity motion/animation pipeline — as-is map
|
|
||||||
|
|
||||||
Repo: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
|
|
||||||
Scope: REPORT-ONLY. Describes what the code does TODAY, not what it should do.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. File inventory (all line numbers verified against current worktree)
|
|
||||||
|
|
||||||
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (333 lines) — inbound 0xF74C parser.
|
|
||||||
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs` (177 lines) — inbound 0xF748 parser.
|
|
||||||
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (108 lines) — **OUTBOUND ONLY** (0xF61C, client→server). Not part of the inbound path; included by the task's starting-point list but has no inbound role.
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs` (13,759 lines) — `OnLiveMotionUpdated` (4230–4967), `OnLivePositionUpdated` (5334–~5773), `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` (5112–5333), `TickAnimations` (9673–~10267+), `RemoteMotion` class (401–~592).
|
|
||||||
- `src/AcDream.Core/Physics/AnimationSequencer.cs` (1583 lines) — `SetCycle` (401–788), `Advance` (862–957), helpers.
|
|
||||||
- `src/AcDream.Core/Physics/MotionInterpreter.cs` (1255 lines) — `DoInterpretedMotion`/`StopInterpretedMotion`/`StopCompletely`/`get_state_velocity`/`adjust_motion`/`apply_raw_movement`/`apply_current_movement`/`GetMaxSpeed`.
|
|
||||||
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines) — per-tick steering for MoveToObject/MoveToPosition (movementType 6/7) remotes.
|
|
||||||
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines) — velocity→cycle classifier for non-player remotes (`PlanFromVelocity`) and MoveTo-start seed (`PlanMoveToStart`).
|
|
||||||
- `src/AcDream.Core/Physics/InterpolationManager.cs` (389 lines) — retail `CPhysicsObj` position-waypoint queue port (FIFO cap 20, catch-up, stall detection, blip-to-tail).
|
|
||||||
- `src/AcDream.Core/Physics/PositionManager.cs` (108 lines) — per-frame REPLACE combiner (queue-catchup vs anim-root-motion), used only for grounded, non-airborne PLAYER remotes.
|
|
||||||
|
|
||||||
**No `pending_motions` / `MotionDone` lifecycle exists anywhere in the codebase.** `grep -rn "pending_motion|MotionDone|PendingMotion" src/` returns zero matches. Confirmed as a genuine gap (matches the animation-sequencer-deep-dive research doc's "missing pending_motions HIGH" finding).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q1 — INBOUND ENTRY: wire → motion interpreter, for a REMOTE object
|
|
||||||
|
|
||||||
1. **Wire parse.** `AcDream.Core.Net.Messages.UpdateMotion.TryParse` (`UpdateMotion.cs:70-253`) decodes opcode `0xF74C` into `Parsed(uint Guid, CreateObject.ServerMotionState MotionState)`. Extracts: `Guid`, `currentStyle` (Stance), and — when `movementType == 0` (InterpretedMotionState) — optional `ForwardCommand`/`ForwardSpeed`/`SideStepCommand`/`SideStepSpeed`/`TurnCommand`/`TurnSpeed` plus a `Commands` list (actions/emotes), gated by a 7-bit flags dword (`UpdateMotion.cs:145-226`). When `movementType is 6 or 7` (MoveToObject/MoveToPosition), it instead parses a `MoveToPathData` payload via `TryParseMoveToPayload` (`UpdateMotion.cs:255-331`): target guid (type 6 only), Origin cell+xyz, `MovementParameters` dword, distance/min/fail distances, speed, walkRunThreshold, desiredHeading, runRate.
|
|
||||||
- **Fields parsed then discarded / not used for pose**: `instanceSequence` (u16, `UpdateMotion.cs:85-87`, "tracked but not used for pose" — actually not even stored, just skipped), `movementSequence`+`serverControlSequence` (u16+u16 inside the 6-byte MovementData header, `UpdateMotion.cs:89-106`, skipped wholesale via `pos += 6`), `isAutonomous` (u8, same 6-byte skip — never separately read), `_motionFlags` (u8, read into a local but only used for the diagnostic dump at `UpdateMotion.cs:111,115-122`, otherwise discarded), `distanceToObject`/`failDistance`/`walkRunThreshold`/`desiredHeading` inside `TryParseMoveToPayload` (parsed at `UpdateMotion.cs:305-306,309,313,315` into locals, never placed on `MoveToPathData` — only `distanceToObject`(as `DistanceToObject`), `minDistance`, `failDistance`(discarded — not passed to the record), `walkRunThreshold` and `desiredHeading` ARE passed to `MoveToPathData` ctor at `UpdateMotion.cs:319-329` but `MoveToPathData` doesn't expose `FailDistance` in the earlier local list — check record fields separately if exact retention matters).
|
|
||||||
- Similarly, `UpdatePosition.TryParse` (`UpdatePosition.cs:77-175`) decodes 0xF748 into guid, `ServerPosition` (cellId+xyz+quat), optional `Velocity`, optional `PlacementId`, `IsGrounded` flag, and three of four trailing u16 sequence numbers (`instSeq`, `teleSeq`, `forceSeq` — the second one, "positionSequence", is explicitly skipped: `UpdatePosition.cs:156` comment "not tracked by movement"). None of `instSeq`/`teleSeq`/`forceSeq` are consulted anywhere downstream for freshness/ordering (comment at `UpdatePosition.cs:29-31`: "We don't currently check these for freshness but we must consume them to walk the buffer correctly").
|
|
||||||
|
|
||||||
2. **Session dispatch → GameWindow event.** (Not directly inspected this pass, but referenced via `_liveSession.MotionUpdated += OnLiveMotionUpdated;` at `GameWindow.cs:2633`.) The parsed `UpdateMotion.Parsed` becomes an `AcDream.Core.Net.WorldSession.EntityMotionUpdate` and fires the `MotionUpdated` event.
|
|
||||||
|
|
||||||
3. **`GameWindow.OnLiveMotionUpdated`** (`GameWindow.cs:4230`) is the sole consumer.
|
|
||||||
- Early-outs: `_dats is null` (4232), entity not found by `update.Guid` in `_entitiesByServerGuid` (4233), or no `AnimatedEntity` for it in `_animatedEntities` (4234).
|
|
||||||
- Stamps `rmStateForUm.LastUMTime` on the `_remoteDeadReckon[update.Guid]` entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 / `UmGraceSeconds`).
|
|
||||||
- Reads `stance = update.MotionState.Stance` and `command = update.MotionState.ForwardCommand` (nullable) (4259-4260).
|
|
||||||
- **Sequencer path** (the only path exercised in practice — `ae.Sequencer is not null` at 4327) reconstructs a full 32-bit `fullMotion` command from the wire's 16-bit `ForwardCommand` via `MotionCommandResolver.ReconstructFullCommand` (4372-4378), OR seeds a MoveTo-derived motion via `ServerControlledLocomotion.PlanMoveToStart` when `IsServerControlledMoveTo` and no forward command (4356-4363), OR falls back to `0x41000003` (Ready) when the command is null/0 and not a MoveTo (4364-4367 — "retail stop signal").
|
|
||||||
- **Branches on `update.Guid == _playerServerGuid`**: the LOCAL player's own echoed UM is NOT applied to the sequencer (comment 4401-4416: "UpdatePlayerAnimation is the authoritative driver ... skip the wire-echo SetCycle"). Everything from here on (Q2-Q7) describes the REMOTE (`else` branch at 4493) path.
|
|
||||||
- For a genuine remote: bulk-copies `fullMotion`/`speedMod` into `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` UNCONDITIONALLY (4572-4598) — this is the direct feed into `MotionInterpreter.get_state_velocity()` (Q5). Then classifies via `AnimationCommandRouter.Classify(fullMotion)` into `forwardIsOverlay` (Action/Modifier/ChatEmote — routed via `RouteFullCommand`, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637 `else` block — this is Q2/Q3/Q4's territory).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q2 — TRANSITION: walk↔run while already moving (no stop)
|
|
||||||
|
|
||||||
Frame-by-frame, for the SubState (non-overlay) branch at `GameWindow.cs:4637-4859`:
|
|
||||||
|
|
||||||
1. **Cycle selection** (4648-4683): `animCycle = fullMotion`, `animSpeed = speedMod` by default. If the forward command is NOT run/walk/walkback (`fwdIsRunWalk` false), fall back to sidestep or turn cycle. **For a plain walk↔run toggle, `fullMotion` IS run/walk, so this stays the forward cycle** — no fallback triggered.
|
|
||||||
2. **Airborne guard** (4696): if `remoteIsAirborne`, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle.
|
|
||||||
3. **Cycle-existence fallback chain** (4728-4757): if the MotionTable lacks `(fullStyle, cycleToPlay)`, falls back RunForward→WalkForward→Ready→(no-op, leave sequencer alone). Only matters if the creature's table is missing the target cycle; for the player Humanoid table both Walk and Run exist.
|
|
||||||
4. **`ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)`** is called (4769) — this is the entry into `AnimationSequencer.SetCycle` (`AnimationSequencer.cs:401`).
|
|
||||||
|
|
||||||
### Inside `SetCycle` (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk:
|
|
||||||
|
|
||||||
- `adjust_motion` remap (407-423): TurnLeft/SideStepLeft/WalkBackward get mirrored to their positive counterpart with negated speed. RunForward(0x07)/WalkForward(0x05) pass through unchanged (`adjustedMotion = motion`).
|
|
||||||
- **Fast-path check** (440-468): fires only if `CurrentStyle == style && CurrentMotion == motion` (same exact motion command) AND sign of speedMod matches. **On an actual Walk→Run toggle, `CurrentMotion` (0x45000005 WalkForward) != `motion` (0x44000007 RunForward), so the fast path does NOT fire** — this is a FULL REBUILD, not a framerate-only rescale. (The fast path only fires when the SAME command re-arrives with a different speed, e.g. a run-rate echo while already running.)
|
|
||||||
- **Full-rebuild path** (470-788):
|
|
||||||
- `GetLink(style, CurrentMotion=WalkForward, CurrentSpeedMod, adjustedMotion=RunForward, adjustedSpeed)` looks up a transition-link MotionData from the MotionTable's `Links` dict (470-480, method at 1159-1203). Both speeds positive here, so it uses the forward-direction lookup: `Links[(style<<16)|WalkForward][RunForward]`.
|
|
||||||
- `ClearCyclicTail()` (511) — removes the OLD cycle's looping node(s) from the queue; non-cyclic (link) frames not yet played are left alone.
|
|
||||||
- `ClearPhysics()` (529) — zeroes `CurrentVelocity`/`CurrentOmega` before rebuild.
|
|
||||||
- Enqueues the link's MotionData (non-looping) THEN the new cycle's MotionData (looping) via `EnqueueMotionData` (547-554).
|
|
||||||
- **Direct cyclic→cyclic transition special-case** (585-635, "Fix B" 2026-05-06): if BOTH the previous motion (WalkForward) AND the new motion (RunForward) are recognized locomotion low-bytes (`IsLocomotionCycleLowByte`, checks against 0x05/0x06/0x07/0x0F/0x10 — `AnimationSequencer.cs:1509-1513`), the code DROPS the just-enqueued transition link from the queue entirely (629-632) and forces `_currNode = _firstCyclic` directly onto the new RunForward cycle (633-634), skipping the link animation and its frame-position start entirely. This is explicitly a divergence from the general link-transition mechanism, justified by a live cdb trace (609-618 comment) showing retail's `add_to_queue` sequence for a Walk→Run direct transition never fires `truncate_animation_list` and just appends the new cycle directly — i.e., **acdream's current behavior for walk↔run is: NO visible link/blend animation, instant hard-cut cycle swap to the new cycle's start frame** (`_framePosition = _firstCyclic.Value.GetStartFramePosition()`, 634).
|
|
||||||
- If NOT both locomotion (e.g. Ready→WalkForward), the link IS played (`_currNode = firstNew` at 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run.
|
|
||||||
- `CurrentStyle`/`CurrentMotion`/`CurrentSpeedMod` updated (682-684).
|
|
||||||
- **Velocity synthesis** (686-754): because retail's Humanoid MotionData ships `Flags=0x00` (no `HasVelocity`) for locomotion cycles, `CurrentVelocity` is unconditionally re-synthesized here from hardcoded constants (`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f` — 793-795) times `adjustedSpeed`, based on the NEW motion's low byte. **This happens regardless of whether the link or the cycle is what's currently playing** — i.e. `CurrentVelocity` snaps to the new cycle's steady-state speed the instant `SetCycle` returns, even in the general (non-locomotion-direct) case where a link animation is still visually playing.
|
|
||||||
- Omega synthesis (756-787) similarly, only for turn commands (not relevant to walk/run).
|
|
||||||
|
|
||||||
### Frame-by-frame summary for Walk→Run toggle while moving:
|
|
||||||
- **Frame N (UM arrives)**: `OnLiveMotionUpdated` fires → `SetCycle(style, RunForward, speedMod)` called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped, `_currNode` snapped directly to Run cycle's start frame, `CurrentVelocity` synthesized to `RunAnimSpeed × speedMod` — ALL of this happens on the SAME UM-processing call, before the next `Advance()`/tick.
|
|
||||||
- **Frame N (same tick, later in loop)**: `TickAnimations` (`GameWindow.cs:9673`) runs later that frame (or next frame depending on event/tick ordering) and calls `Advance(dt)` on the sequencer, which now plays from the Run cycle's first frame — no partial-frame blend from the old Walk pose.
|
|
||||||
- **Is there blending?** NO cross-fade/blend for the walk↔run case specifically — only the (skipped) link animation would have provided a transitional pose, and it's explicitly dropped for this case.
|
|
||||||
- **Is the new motion appended/queued, or does it replace?** It REPLACES: `ClearCyclicTail()` always removes the old cycle before the new one is enqueued (511), and for the walk↔run case the link itself is also stripped from the queue (629-632) so only the new cycle remains.
|
|
||||||
- **Speed**: `CurrentVelocity`/animation framerate both change immediately and atomically inside `SetCycle` — no interpolation of speed either (the fast-path's `MultiplyCyclicFramerate` smooth-rescale only applies when `CurrentMotion` is unchanged, i.e. NOT for an actual Walk↔Run motion-command swap, only for a same-command speed update).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q3 — PENDING/DONE (pending_motions + MotionDone lifecycle)
|
|
||||||
|
|
||||||
**NONE.** There is no `pending_motions` queue and no `MotionDone` callback/event anywhere in the codebase (confirmed via `grep -rn "pending_motion|MotionDone|PendingMotion" src/` — zero hits).
|
|
||||||
|
|
||||||
What DOES exist that's adjacent:
|
|
||||||
- `AnimationSequencer._queue` (a `LinkedList<AnimNode>`, `AnimationSequencer.cs:297`) holds link-then-cycle nodes, but it is not a "pending motion command" queue in the retail sense — it's purely an animation-frame playback queue (link frames + one looping tail node), rebuilt wholesale on every `SetCycle` call. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") — `SetCycle` always either fast-paths (same command) or fully rebuilds (different command) synchronously the instant the wire event arrives.
|
|
||||||
- `AnimationSequencer.ConsumePendingHooks()` (964-972) exists and drains `_pendingHooks`, a `List<AnimationHook>` accumulated during `Advance()` when integer frame boundaries are crossed (`Advance`, 862-957, calls `ExecuteHooks` at 913/939). This is a HOOK dispatch mechanism (audio/VFX/damage triggers baked into animation frames), NOT a motion-command completion signal.
|
|
||||||
- `AnimationDoneSentinel` (`AnimationSequencer.cs:1128-1129`, a static `AnimationHookDir.Both` hook) is added to `_pendingHooks` when a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, inside `Advance`). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME `_pendingHooks` list as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail's `pending_motions`/`MotionDone` state machine would (e.g. to know when to pop the next queued attack, or to notify the wire layer that a swing animation finished so the next command can be sent).
|
|
||||||
- **Consequence for callers**: nobody appends anything to a "next motion" queue and nobody pops one on completion. Every UM (or per-tick refinement call) calls `SetCycle`/`DoInterpretedMotion` directly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification in `OnLiveMotionUpdated` (4626 vs 4637) and (b) `SetCycle`'s own state (`CurrentMotion`/`CurrentStyle` comparison) — there's no notion of "motion command A is still pending completion, defer motion command B."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q4 — CYCLE SWAP mechanics: does frame index carry over?
|
|
||||||
|
|
||||||
Governed entirely by `AnimationSequencer.SetCycle` (see Q2 for the walk→run specific path) and `AdvanceToNextAnimation` (`AnimationSequencer.cs:1344-1364`):
|
|
||||||
|
|
||||||
- **General case (non-locomotion-direct, e.g. Ready→WalkForward)**: `_currNode` is set to the FIRST NEWLY-ENQUEUED node (`firstNew`, 583, 636-640) — i.e. the link animation, if one exists, plays from ITS `GetStartFramePosition()` (583+639: `_framePosition = _currNode.Value.GetStartFramePosition()`), which for positive framerate is `StartFrame` (frame 0 of the link) — **frame index does NOT carry over from the old cycle; it restarts at the link's (or cycle's, if no link) start frame.** When the link naturally exhausts during subsequent `Advance()` calls, `AdvanceToNextAnimation()` (1344) walks to `_currNode.Next`, or if null, wraps to `_firstCyclic` — again restarting `_framePosition` at `GetStartFramePosition()` of that new node (1362-1363).
|
|
||||||
- **Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case, `AnimationSequencer.cs:619-635`)**: `_currNode` is forced DIRECTLY onto `_firstCyclic` (the new cycle's looping node), with `_framePosition = _firstCyclic.Value.GetStartFramePosition()` (634) — i.e. **frame index is reset to 0 (or `EndFrame` for negative-speed cycles) of the NEW cycle, NOT carried over from the OLD cycle's current frame position.** There is no "keep the current phase, just swap animation data" — every `SetCycle` transition, link or no link, hard-resets `_framePosition` to a start boundary of whichever node becomes current.
|
|
||||||
- **No "link animation between the two loops" mechanism for locomotion-direct swaps** — the whole point of the "Fix B" branch is to SKIP the link (i.e. skip the transitional pose entirely) for cyclic→cyclic transitions, per the cdb-trace-derived retail-matching behavior documented in the comment (609-618).
|
|
||||||
- Net: cycle swap between two looping cycles is an **instant hard-cut with no phase continuity and (for locomotion-direct transitions) no transitional/link animation.** This differs from a "true" retail per-limb blend if such a thing exists in the more complete engine — acdream's sequencer is a single active `AnimNode` playback engine, not a blend-tree.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q5 — POSITION DRIVE between inbound position packets
|
|
||||||
|
|
||||||
Two structurally DIFFERENT paths exist, selected in `TickAnimations` (`GameWindow.cs:9717-9722`) by:
|
|
||||||
```csharp
|
|
||||||
if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid
|
|
||||||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
|
|
||||||
{
|
|
||||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne) // ← Path A: grounded PLAYER remotes
|
|
||||||
else // ← Path B: NPCs + airborne player remotes (legacy)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path A — grounded player remotes (`GameWindow.cs:9722-9920`)
|
|
||||||
Uses `PositionManager.ComputeOffset` (`PositionManager.cs:51-107`), which is explicitly a **REPLACE, not additive** combiner:
|
|
||||||
1. `InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed)` (called at `PositionManager.cs:84`) is tried FIRST.
|
|
||||||
- If the queue has waypoints AND the body is farther than `DesiredDistance` (0.05 m, `InterpolationManager.cs:79`) from the head waypoint, it returns a non-zero "catch-up" delta: `direction × min(catchUpSpeed × dt, dist)`, where `catchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0)` or a `MaxInterpolatedVelocity` (7.5 m/s) fallback (`InterpolationManager.cs:262-362`). **This REPLACES animation root motion entirely for that frame** (`PositionManager.cs:84-86`: `if (correction.LengthSquared() > 0f) return correction;`).
|
|
||||||
- If the queue is empty OR the head has been reached (dist ≤ 0.05m, which pops the head via `NodeCompleted`), `AdjustOffset` returns `Vector3.Zero`, and `PositionManager.ComputeOffset` falls through to animation root motion: `seqVel × dt`, rotated by body orientation, optionally slope-projected (`PositionManager.cs:88-106`).
|
|
||||||
2. `seqVel` is `ae.Sequencer.CurrentVelocity` (body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOT `MotionInterpreter.get_state_velocity()`.
|
|
||||||
3. `omegaToApply` is `rm.ObservedOmega` (seeded formulaically from wire TurnCommand+TurnSpeed in `OnLiveMotionUpdated`, `GameWindow.cs:4841-4847`) preferred over `seqOmega` (`GameWindow.cs:9842-9845`).
|
|
||||||
4. `rm.Body.calc_acceleration()` + `rm.Body.UpdatePhysicsInternal(dt)` still run (9878, 9881) for gravity/vertical integration, but `ResolveWithTransition` (collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position").
|
|
||||||
5. **Dominance**: for grounded player remotes, the InterpolationManager queue DOMINATES whenever it's non-empty and the body hasn't caught up; the sequencer's `CurrentVelocity` (animation root motion) only drives position when the queue is idle/caught-up.
|
|
||||||
|
|
||||||
### Path B — NPCs and airborne player remotes (legacy path, `GameWindow.cs:9933-10267+`)
|
|
||||||
This is the ORIGINAL / "unchanged until Task 8 cleanup" path (comment 9923):
|
|
||||||
1. `rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false)` (10074, the general branch) — calls `MotionInterpreter.get_state_velocity()` (`MotionInterpreter.cs:639-688`), which reads `InterpretedState.ForwardCommand`/`ForwardSpeed` (bulk-copied from the wire in `OnLiveMotionUpdated`, Q1/Q2) and returns `WalkAnimSpeed/RunAnimSpeed × ForwardSpeed` UNLESS the sequencer's `CurrentVelocity.Y` is non-zero (`GetCycleVelocity` accessor, wired — see below), in which case it prefers that (`MotionInterpreter.cs:653-668`, "Option B — MotionData-sourced forward velocity"). Result is written directly to `PhysicsBody.set_local_velocity` via `apply_current_movement` (`MotionInterpreter.cs:905-909`, gated on `PhysicsObj.OnWalkable`).
|
|
||||||
2. Alternative sub-branches at this same call site (`GameWindow.cs:9963-10076`) select DIFFERENT velocity sources depending on state:
|
|
||||||
- If `rm.HasServerVelocity` and NOT a player guid (9968): uses `rm.ServerVelocity` (synthesized from consecutive UP position deltas, or wire `Velocity` field when present) directly as `rm.Body.Velocity` — BYPASSING `apply_current_movement`/`get_state_velocity` entirely — UNLESS the velocity is stale (`velocityAge > ServerControlledVelocityStaleSeconds = 0.60s`, 926), in which case it's zeroed and `ApplyServerControlledVelocityCycle` is invoked to reset the cycle to Ready.
|
|
||||||
- If `rm.ServerMoveToActive && rm.HasMoveToDestination` (9987): uses `RemoteMoveToDriver.Drive` to steer orientation, then `apply_current_movement` for velocity magnitude (10042), clamped via `RemoteMoveToDriver.ClampApproachVelocity` (10052-10059) to avoid overshoot.
|
|
||||||
- Else (general fallback, 10074): `apply_current_movement` as described in step 1.
|
|
||||||
3. Manual per-tick rotation integration (`GameWindow.cs:10097-10106`) using `rm.ObservedOmega`.
|
|
||||||
4. `rm.Body.UpdatePhysicsInternal(dt)` (10128) — Euler integration.
|
|
||||||
5. **Collision sweep IS run for Path B** (`_physicsEngine.ResolveWithTransition`, 10156-10181) — unlike Path A, NPCs/airborne players DO get a client-side sweep between server updates (with a fixed sphere radius/height, step-up/down heights).
|
|
||||||
|
|
||||||
### Which dominates?
|
|
||||||
- Path A (player remotes, grounded): InterpolationManager queue dominates when active; sequencer's synthesized `CurrentVelocity` (animation root motion) is the fallback when the queue is idle.
|
|
||||||
- Path B (NPCs / airborne players): `MotionInterpreter.get_state_velocity()` (fed by `InterpretedState.ForwardCommand/ForwardSpeed` set from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer's `CurrentVelocity.Y` is non-zero, `get_state_velocity` PREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (`MotionInterpreter.cs:646-668` "Option B"). Wire-synthesized `ServerVelocity` from position deltas takes priority over both when present and fresh.
|
|
||||||
- **Neither `apply_raw_movement` nor the raw `adjust_motion`(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path** (confirmed via `grep -rn "apply_raw_movement|get_state_velocity()"` across `src/` outside `MotionInterpreter.cs` — only `src/AcDream.App/Input/PlayerMovementController.cs` calls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2 `apply_raw_movement`/`adjust_motion` port (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead get `InterpretedState.ForwardCommand`/`ForwardSpeed` bulk-copied directly (mirroring retail's `copy_movement_from`, per the comment at `GameWindow.cs:4536-4544,4552-4558`), bypassing `adjust_motion`'s left→right/backward-forward normalization and run-hold-key promotion — that normalization already happened SERVER-SIDE (ACE) before the wire command arrived, per the code comments.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q6 — CORRECTION (how inbound UpdatePosition corrects accumulated error)
|
|
||||||
|
|
||||||
Handled in `OnLivePositionUpdated` (`GameWindow.cs:5334-~5773`). The correction strategy differs by whether the remote is a PLAYER or not, and whether it's airborne:
|
|
||||||
|
|
||||||
### Player remotes (`IsPlayerGuid(update.Guid)` branch, `GameWindow.cs:5460-5635`)
|
|
||||||
Ports retail's `CPhysicsObj::MoveOrTeleport` (0x00516330):
|
|
||||||
1. **Airborne no-op** (5512-5521): if `!update.IsGrounded`, body position/velocity are NOT touched by the UP at all — gravity integration continues locally; only the render entity is synced to the current body position (undoing the unconditional top-of-function hard-snap).
|
|
||||||
2. **Landing transition** (5527-5553): first grounded UP after airborne clears `Airborne`, zeroes velocity, hard-snaps `rmState.Body.Position = worldPos` directly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whatever `InterpretedState.ForwardCommand` implies.
|
|
||||||
3. **Grounded routing** (5555-5581): distance check against the LOCAL player's position (`MaxPhysicsDistance = 96f`, 5556):
|
|
||||||
- `dist > 96`: hard `SetPositionSimple`-style snap — `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` (no smoothing at all).
|
|
||||||
- `dist <= 96`: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo:false, currentBodyPosition:rmState.Body.Position)` — feeds `InterpolationManager.Enqueue` (`InterpolationManager.cs:170-231`), which itself has three sub-branches:
|
|
||||||
- **Far branch** (dist from last-tail/body-position to new target > `AutonomyBlipDistance = 100m`): enqueues AND pre-arms an immediate blip (`_failCount = StallFailCountThreshold + 1 = 4`) so the very next `AdjustOffset` call snaps straight to the tail.
|
|
||||||
- **Already-close branch** (body-to-target ≤ `DesiredDistance = 0.05m`): wipes the queue entirely, no enqueue (`Clear()`).
|
|
||||||
- **Near/not-close branch**: tail-prune loop collapses consecutive stale entries within 0.05m of the new target, caps queue at 20 (drops head if full), then appends.
|
|
||||||
- Every-tick catch-up happens later in `TickAnimations` Path A via `PositionManager.ComputeOffset`→`InterpolationManager.AdjustOffset` (Q5). There is a secondary "stall" detector inside `AdjustOffset` (`InterpolationManager.cs:292-337`): every 5 frames it checks cumulative progress against `MinDistanceToReachPosition = 0.20m` and a secondary ratio check (`cumulative/progressQuantum/dt >= 0.30`); repeated stall failures (`_failCount > StallFailCountThreshold = 3`) trigger a hard SNAP to the tail waypoint (`InterpolationManager.cs:345-350`) and clear the queue.
|
|
||||||
4. **Velocity-fallback cycle refinement** (5582-5625): synthesizes velocity from consecutive server positions (`(worldPos - PrevServerPos) / dtPos`) and feeds `ApplyServerControlledVelocityCycle` → `ApplyPlayerLocomotionRefinement` (Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle.
|
|
||||||
5. `entity.SetPosition(rmState.Body.Position)` at the end (5633) — the render entity always mirrors the body, never `worldPos` directly, for player remotes within the near-branch (queue chase still in progress).
|
|
||||||
|
|
||||||
### Non-player remotes (`GameWindow.cs:5637-5767`)
|
|
||||||
NO InterpolationManager/PositionManager usage at all — this is the older "legacy" correction:
|
|
||||||
1. `serverVelocity` sourced from wire `update.Velocity` if present, else synthesized from position delta if `rmState.LastServerPosTime > 0` (5638-5646).
|
|
||||||
2. `rmState.Body.Position = worldPos` — **UNCONDITIONAL HARD SNAP, every single UpdatePosition, no soft-lerp, no distance check** (5657). Comment at 5679-5682 confirms: "Retail authoritatively hard-snaps cell membership here too."
|
|
||||||
3. `rmState.Body.Orientation = rot` — also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seeded `ObservedOmega`, not from UP deltas — an earlier attempt to derive omega from UP deltas caused a "halved observed rate" bug on the first UP after a turn).
|
|
||||||
4. `rmState.Body.Velocity = svel` if wire `Velocity` present (5711-5713); **`svel.LengthSquared() < 0.04f` is treated as an explicit stop signal** — triggers `rmState.Motion.StopCompletely()` (Q7) PLUS a direct `SetCycle` to Ready (5714-5731). This is the ONLY place `StopCompletely()` is called anywhere in the inbound path.
|
|
||||||
5. Between UPs (Path B in `TickAnimations`), NPCs get a client-side `ResolveWithTransition` collision sweep — see Q5.
|
|
||||||
|
|
||||||
### Named thresholds used for correction
|
|
||||||
| Constant | Value | File:line | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `SnapHardSnapThreshold` | 20.0 m | `GameWindow.cs:614` | (declared, comment references retail `GetAutonomyBlipDistance`; NOT directly read in the reviewed OnLivePositionUpdated flow for non-player remotes — hard snap there is unconditional every UP) |
|
|
||||||
| `MaxPhysicsDistance` | 96 m | `GameWindow.cs:5556` | Player-remote far-vs-near routing (SetPositionSimple vs Interp.Enqueue) |
|
|
||||||
| `InterpolationManager.AutonomyBlipDistance` | 100 m | `InterpolationManager.cs:105` | Far-branch pre-arm blip in `Enqueue` |
|
|
||||||
| `InterpolationManager.DesiredDistance` | 0.05 m | `InterpolationManager.cs:79` | "Reached"/"already close" radius |
|
|
||||||
| `InterpolationManager.MinDistanceToReachPosition` | 0.20 m | `InterpolationManager.cs:73` | 5-frame stall-check primary threshold |
|
|
||||||
| `InterpolationManager.StallFailCountThreshold` | 3 (fires on >3, i.e. 4th fail) | `InterpolationManager.cs:98` | Snap-to-tail trigger |
|
|
||||||
| `ServerControlledVelocityStaleSeconds` | 0.60 s | `GameWindow.cs:926` | NPC server-velocity staleness → zero + Ready |
|
|
||||||
| `RemoteMoveToDriver.StaleDestinationSeconds` | 1.5 s | `RemoteMoveToDriver.cs:136` | MoveTo destination staleness → stand down |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q7 — STOP (motion → Ready/stand)
|
|
||||||
|
|
||||||
There are **THREE distinct, independently-triggered stop paths** in the current code, none of which are unified:
|
|
||||||
|
|
||||||
### 1. UM-driven stop (`OnLiveMotionUpdated`, the "retail stop signal")
|
|
||||||
When the wire's `ForwardCommand` flag is absent or explicitly 0 AND it's not a MoveTo packet (`GameWindow.cs:4347,4364-4367`): `fullMotion = 0x41000003u` (Ready) is used directly as the SetCycle target. This flows through the normal `SetCycle` full-rebuild path (Q2/Q4) — Ready is NOT flagged as a "locomotion" low-byte (`IsLocomotionCycleLowByte` only covers 0x05/0x06/0x07/0x0F/0x10, `AnimationSequencer.cs:1509-1513`), so a Run→Ready transition DOES get the link animation this time (the "Fix B" cyclic-direct bypass only applies when BOTH old and new are locomotion). `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` are ALSO bulk-copied to `fullMotion`/`speedMod` = Ready/1.0 UNCONDITIONALLY at the same time (4590,4598) — so `MotionInterpreter.get_state_velocity()` will subsequently return `Vector3.Zero` (neither WalkForward nor RunForward matches Ready) for Path B (NPC) velocity. **No explicit `StopCompletely()`/`StopInterpretedMotion()` call happens in this path** — it's purely a state overwrite via the same "InterpretedState = wire's forward/speed" bulk-copy used for every other UM.
|
|
||||||
|
|
||||||
### 2. UP-driven stop for non-player remotes (near-zero wire velocity)
|
|
||||||
`GameWindow.cs:5711-5731`: only fires when the wire's `UpdatePosition.Velocity` field is explicitly present AND its length² < 0.04 (i.e. |v| < 0.2 m/s). Calls `rmState.Motion.StopCompletely()` (`MotionInterpreter.cs:562-587`) which resets BOTH `RawState` and `InterpretedState` to Ready/1.0/0/0/0/1.0 across all three axes (forward/sidestep/turn) and calls `PhysicsObj.set_velocity(Vector3.Zero)` directly — this IS an explicit velocity zero. Additionally forces `ae.Sequencer.SetCycle(curStyle, readyCmd)` (5729) so the visible cycle snaps to Ready too, independent of whatever UM might arrive later. Comment (5700-5710) notes this UP-absent-velocity path was previously a bug source ("fired StopCompletely every UP → intermittent run") and is now scoped ONLY to the explicit-near-zero case, NOT absent-velocity.
|
|
||||||
|
|
||||||
### 3. Player-remote UM stop (no distinct path — same as #1, but note the render/position split)
|
|
||||||
For player remotes, `OnLiveMotionUpdated`'s player branch (4417-4492) explicitly SKIPS the sequencer entirely for the LOCAL player's OWN echoed UM. For OTHER players (genuine remotes), the same #1 mechanism applies. **Position, however, is decoupled**: even after a Ready UM zeroes `InterpretedState`/switches the sequencer to Ready, the `InterpolationManager` queue (Path A, Q5) may still hold unconsumed waypoints from the last few UPs sent WHILE the player was still moving (the network is asynchronous — UM and UP for the same "I stopped" event may arrive in either order or with a gap). Since `PositionManager.ComputeOffset` prefers the queue's catch-up delta over `seqVel` whenever the queue is non-empty (`PositionManager.cs:84-86`), **the body can continue sliding toward the last few queued waypoints via `InterpolationManager.AdjustOffset`'s catch-up motion EVEN AFTER the sequencer has already switched to a zero-velocity Ready cycle** — because the queue-driven correction is independent of `CurrentVelocity`. This is a structural potential for residual sliding: the stop signal (UM→Ready) zeroes ANIMATION velocity but does NOT clear `rmState.Interp`'s queue; the queue only self-empties via normal `NodeCompleted` head-pop as the body reaches each remaining waypoint (`InterpolationManager.cs:278-282`), or by explicit `Clear()` calls which only happen at: (a) far-branch enqueue is NOT a clear, (b) already-close enqueue (0.05m gate), (c) `AdjustOffset`'s stall-blip clear, (d) the landing-transition block (5534, airborne case only). **There is no explicit `rm.Interp.Clear()` call anywhere in the UM-driven stop path (#1) or in the near-zero-UP stop path (#2)** — confirmed by reading `GameWindow.cs:4230-4967` (OnLiveMotionUpdated, no `Interp.Clear` call) and `GameWindow.cs:5711-5731` (the UP near-zero-velocity StopCompletely block, no `Interp.Clear` call there either — only the airborne-landing block at 5534 calls `rmState.Interp.Clear()`).
|
|
||||||
|
|
||||||
### Residual-sliding mechanisms explicitly present elsewhere (not stop-specific)
|
|
||||||
- `SnapResidualDecayRate = 8.0f` (1/sec, `GameWindow.cs:597`) is DECLARED with a comment describing an exponential decay of "soft-snap residual" (100ms half-life) — but this constant is not exercised in any of the code paths read in this pass (`OnLivePositionUpdated`/`OnLiveMotionUpdated`/`TickAnimations` Path A/B); it may be vestigial or used in a renderer-only smoothing pass not covered by this trace. Flagging as unconfirmed — did not find a live call site using this constant during this investigation.
|
|
||||||
|
|
||||||
### Summary answers
|
|
||||||
- (a) **Where remote DR velocity comes from**: for grounded PLAYER remotes, `AnimationSequencer.CurrentVelocity` (synthesized constants × speedMod, set inside `SetCycle`, NOT from `MotionInterpreter.get_state_velocity()`), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes from `InterpolationManager.AdjustOffset`'s catch-up formula (`maxSpeedFromMinterp × 2.0`, itself sourced from `MotionInterpreter.GetMaxSpeed()` = `RunAnimSpeed(4.0) × runRate`). For NPCs (and airborne players), DR velocity comes from `MotionInterpreter.get_state_velocity()` (which itself prefers the sequencer's `CurrentVelocity.Y` when non-zero, else falls back to `WalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed`), OR directly from `rm.ServerVelocity` (wire `Velocity` field or position-delta synthesis) when `HasServerVelocity` is true and fresh.
|
|
||||||
- (b) **When DR velocity changes after a new UM**: SAME FRAME / same synchronous call — `OnLiveMotionUpdated` bulk-copies `InterpretedState.ForwardCommand/ForwardSpeed` immediately (`GameWindow.cs:4590,4598`) and calls `SetCycle` immediately (4769), which synthesizes `CurrentVelocity` immediately inside the same call (`AnimationSequencer.cs:722-754`). There is no queueing/deferral — the very next `TickAnimations`/`Advance()` call (next frame) will observe the new velocity.
|
|
||||||
- (c) **Can animation phase and DR velocity disagree, for how long**: YES, structurally, in at least two ways: (1) For player remotes, `PositionManager` can be driving position via the InterpolationManager QUEUE (catch-up toward stale waypoints) while the SEQUENCER has already advanced to a new cycle/phase from a fresher UM — these two systems are not coupled; the queue drains independently at its own catch-up rate (up to `GetMaxSpeed()×2.0` or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) The `SetCycle` cyclic-direct hard-cut (Q2/Q4) resets `_framePosition` to a start boundary instantly on every walk↔run toggle, so the visible LEG PHASE has zero continuity with the body's actual accumulated forward distance at the moment of the toggle — there's no phase-matching/re-sync logic. Duration of disagreement is unbounded in principle (bounded in practice only by how quickly the InterpolationManager queue catches up, or by the next UM/UP hard-snap).
|
|
||||||
- (d) **How stop zeroes velocity, what can leave residual sliding**: `StopCompletely()` zeroes `PhysicsObj.set_velocity(Vector3.Zero)` directly on the body AND resets `InterpretedState`, but this ONLY fires from the UP near-zero-velocity path (#2 above) — the far more common UM-driven Ready transition (#1) only zeroes the ANIMATION's synthesized `CurrentVelocity` (via `SetCycle`'s Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all — `CurrentVelocity` simply stays whatever it was set to by `ClearPhysics()` at the top of `SetCycle`, i.e. `Vector3.Zero`, UNLESS a link with `HasVelocity` overrides it — Ready transitions DO get a link per Q2, and if that link's MotionData has `HasVelocity` set, `CurrentVelocity` would be non-zero for the DURATION of that link before wrapping to the (velocity-less, non-locomotion) Ready cycle). Meanwhile, for player remotes specifically, **NEITHER of the two stop paths clears `rmState.Interp`'s waypoint queue** — so if the queue still holds unreached waypoints from before the stop signal, `PositionManager.ComputeOffset`/`InterpolationManager.AdjustOffset` will continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames / `_failCount` exceeding 3), producing exactly the "residual sliding after stop" symptom the task is asking about. This is a genuine, code-confirmed gap: stop-signal handling and interpolation-queue clearing are two independent systems that are not wired together outside of the airborne-landing special case.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting notes for synthesis
|
|
||||||
|
|
||||||
- **Two entirely separate motion-drive architectures coexist**: the newer InterpolationManager/PositionManager "queue + REPLACE" model (grounded player remotes only) vs. the older `MotionInterpreter.apply_current_movement`/`get_state_velocity` "continuous velocity from InterpretedState" model (NPCs + airborne remotes). They share almost no code and have different stop/correction semantics.
|
|
||||||
- **`MotionInterpreter`'s D6.2 `adjust_motion`/`apply_raw_movement` port is entirely unused by the inbound remote path** — remotes get server-pre-normalized commands bulk-copied directly into `InterpretedState`, bypassing the retail-faithful raw→interpreted normalization pipeline that exists in the codebase but is wired to the LOCAL player only (`PlayerMovementController.cs`).
|
|
||||||
- **No unified "stop" concept**: three independent triggers (UM Ready, UP near-zero-velocity StopCompletely, airborne-landing zero) each partially zero different pieces of state (animation velocity only / full body+interpreted-state velocity / position+velocity+cycle), and none of them touch the InterpolationManager queue except the airborne-landing case.
|
|
||||||
- **`SetCycle`'s cyclic-locomotion-direct special case (Q2/Q4) is itself a documented, intentional divergence from the general link-transition mechanism**, justified in-code by a cdb trace of retail's `add_to_queue` behavior — i.e. per CLAUDE.md's divergence-register discipline, this specific behavior claims retail-fidelity (not an acknowledged approximation), though this investigation did not independently verify the cdb trace's conclusiveness.
|
|
||||||
|
|
@ -1,610 +0,0 @@
|
||||||
# Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager
|
|
||||||
|
|
||||||
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics`
|
|
||||||
|
|
||||||
Key files (all confirmed to exist):
|
|
||||||
- `Animation/MotionInterp.cs` — retail `CMotionInterp` (per-object motion-command interpreter; owns `PendingMotions`, `RawState`, `InterpretedState`)
|
|
||||||
- `Animation/Sequence.cs` — retail `CSequence` (the actual animation cycle/frame player; owns `AnimList`, `CurrAnim`, `FrameNumber`, `Velocity`/`Omega`)
|
|
||||||
- `Animation/MotionTable.cs` — retail motion-table graph walker (`GetObjectSequence` = the core cycle-swap / link-animation logic)
|
|
||||||
- `Managers/MotionTableManager.cs` — retail per-object `AnimationCounter`/`PendingAnimations` bookkeeping + redundant-link truncation
|
|
||||||
- `Managers/MovementManager.cs` — top dispatcher owning `MotionInterpreter` + `MoveToManager`
|
|
||||||
- `Managers/InterpolationManager.cs` — remote/dead-reckoning position correction (network snap-to-position blending)
|
|
||||||
- `Managers/PositionManager.cs` — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from `PhysicsObj.UpdatePositionInternal`
|
|
||||||
- `PartArray.cs` — glue between `PhysicsObj` and `Sequence`/`MotionTableManager` (`DoInterpretedMotion`, `Update`, `StopInterpretedMotion`)
|
|
||||||
- `PhysicsObj.cs` — top-level per-entity physics object; `update_object`/`UpdateObjectInternal`/`UpdatePositionInternal` drive the whole thing every tick
|
|
||||||
- `Animation/AnimSequenceNode.cs`, `Animation/MotionState.cs`, `Animation/RawMotionState.cs`, `Animation/InterpretedMotionState.cs`, `Animation/MotionNode.cs`, `Animation/AnimNode.cs`, `Animation/MovementParameters.cs`
|
|
||||||
|
|
||||||
IMPORTANT CAVEAT: ACE is the **server**. There is no "remote entity rendering" concept in ACE — every `PhysicsObj` on the server is locally-simulated and authoritative; ACE broadcasts `UpdatePosition`/`UpdateMotion` wire messages *outward* to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving":
|
|
||||||
1. **The locally-controlled-by-network-input path**: `Player_Tick.OnMoveToState_ClientMethod` / `OnMoveToState_ServerMethod` — this is literally the server *acting as* the retail client's `CMotionInterp` would for the connected player, driven by inbound `MoveToState` (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter."
|
|
||||||
2. **The dead-reckoning/position-correction path** for *other* entities as seen by a given observer would be `PhysicsObj.MoveOrTeleport` + `PositionManager.InterpolateTo` + `InterpolationManager` — this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters' `UpdatePosition` broadcasts, and structurally mirrors what a retail *client* does when it receives another player's `UpdatePosition`).
|
|
||||||
|
|
||||||
Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q1 — INBOUND ENTRY: wire message → motion interpreter
|
|
||||||
|
|
||||||
### Path A: motion-command message (`MoveToState`, retail wire opcode family 0xF61C) → MotionInterp
|
|
||||||
|
|
||||||
1. `ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13` `Handle(ClientMessage, Session)` — the game-action handler for `[GameAction(GameActionType.MoveToState)]`. Parses payload into a `MoveToState` struct (`Network/Structure/MoveToState.cs`, not read in detail here) and stores `session.Player.CurrentMoveToState = moveToState;` (line 20).
|
|
||||||
2. Line 29: `session.Player.OnMoveToState(moveToState);` — this is the entry into the physics/animation layer.
|
|
||||||
3. Line 36: `session.Player.BroadcastMovement(moveToState);` (`WorldObjects/Player_Networking.cs:309`) — re-broadcasts the motion as `GameMessageUpdateMotion` to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above).
|
|
||||||
|
|
||||||
4. `WorldObjects/Player_Tick.cs:176` `OnMoveToState(MoveToState moveToState)`:
|
|
||||||
- Guards on `FastTick` (line 178).
|
|
||||||
- Line 187-188: if not already moving/animating, resets `PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime` (this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum).
|
|
||||||
- Line 190-193: branches on `client_movement_formula` property + `StandingLongJump`:
|
|
||||||
- `OnMoveToState_ServerMethod` (retail-authoritative single-shot apply)
|
|
||||||
- `OnMoveToState_ClientMethod` (retail client-style edge-triggered DoMotion/StopMotion calls)
|
|
||||||
|
|
||||||
5. **`OnMoveToState_ServerMethod`** (`Player_Tick.cs:272-288`) — the retail-CMotionInterp-faithful path:
|
|
||||||
```csharp
|
|
||||||
var minterp = PhysicsObj.get_minterp();
|
|
||||||
minterp.RawState.SetState(moveToState.RawMotionState);
|
|
||||||
if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; }
|
|
||||||
var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None;
|
|
||||||
minterp.apply_raw_movement(true, allowJump);
|
|
||||||
```
|
|
||||||
This copies the ENTIRE wire-level `RawMotionState` (`Network/Structure/RawMotionState`) into `MotionInterp.RawState` in one shot via `RawMotionState.SetState` (`Animation/RawMotionState.cs:117-140`), then calls `MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`), which re-derives `InterpretedState` from `RawState` field-by-field and re-issues `DoInterpretedMotion` calls (see Q2 below). **This is the retail `CMotionInterp::apply_raw_movement` — full re-derivation every call, not an incremental diff.**
|
|
||||||
|
|
||||||
6. **`OnMoveToState_ClientMethod`** (`Player_Tick.cs:199-270`) — an edge-triggered alternative (a `client_movement_formula`-flagged, non-default variant) that diffs `rawState` against `prevState = LastMoveToState?.RawMotionState` and calls `PhysicsObj.DoMotion` / `PhysicsObj.StopMotion` only on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail *client itself* processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method.
|
|
||||||
|
|
||||||
### Path B: `PhysicsObj.DoMotion` (entry used by both client-edge method and any direct-call site)
|
|
||||||
|
|
||||||
`PhysicsObj.cs:340-346`:
|
|
||||||
```csharp
|
|
||||||
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
|
|
||||||
{
|
|
||||||
LastMoveWasAutonomous = true;
|
|
||||||
if (MovementManager == null) return WeenieError.NoAnimationTable;
|
|
||||||
var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams);
|
|
||||||
return MovementManager.PerformMovement(mvs);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
→ `MovementManager.PerformMovement` (`Managers/MovementManager.cs:124-157`) lazily constructs `MotionInterpreter` if null, then dispatches `MovementType.RawCommand` to `MotionInterpreter.PerformMovement(mvs)` (`Animation/MotionInterp.cs:236-262`), which for `RawCommand` calls `DoMotion(mvs.Motion, mvs.Params)` (`MotionInterp.cs:112-158`).
|
|
||||||
|
|
||||||
`MotionInterp.DoMotion` (line 112):
|
|
||||||
- Builds a local `currentParams` copy (`CopySome`, MotionInterp.cs:119).
|
|
||||||
- `adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)` (line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2).
|
|
||||||
- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while `CurrentStyle != NonCombat`.
|
|
||||||
- Action-count gating (line 147-151): `GetNumActions() >= 6` → `TooManyActions`.
|
|
||||||
- Calls `DoInterpretedMotion(currentMotion, currentParams)` (line 152) — the actual interpreter entry (shared with Path C below).
|
|
||||||
- If success and `movementParams.ModifyRawState`, calls `RawState.ApplyMotion(motion, movementParams)` (line 155) to record the **original, un-adjusted** motion into RawState (so Run-promotion doesn't get baked into RawState).
|
|
||||||
|
|
||||||
### Path C: `MotionInterp.DoInterpretedMotion` — the true funnel point (both paths above and the direct-interpreted-command callers converge here)
|
|
||||||
|
|
||||||
`Animation/MotionInterp.cs:51-110`:
|
|
||||||
1. `contact_allows_move(motion)` gate (line 57, see below) — blocks ground-locked motions while airborne.
|
|
||||||
2. `StandingLongJump` special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates `InterpretedState` without touching the physics animation (charge-jump doesn't reanimate).
|
|
||||||
3. Otherwise (line 64-90):
|
|
||||||
- `Dead` motion → `PhysicsObj.RemoveLinkAnimations()` first (line 66-67).
|
|
||||||
- `result = PhysicsObj.DoInterpretedMotion(motion, movementParams)` (line 69) → `PartArray.DoInterpretedMotion` (`PartArray.cs:130-136`) → `MotionTableManager.PerformMovement(mvs, Sequence)` (`Managers/MotionTableManager.cs:116-145`) → `Table.DoObjectMotion` → `GetObjectSequence` (`Animation/MotionTable.cs:55-257`, the actual cycle-graph logic, see Q2/Q4).
|
|
||||||
- On success: computes `jump_error_code` (lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), then `add_to_queue(movementParams.ContextID, motion, jump_error_code)` (line 85) — pushes a `MotionNode` onto `MotionInterp.PendingMotions` (see Q3).
|
|
||||||
- If `movementParams.ModifyInterpretedState`, calls `InterpretedState.ApplyMotion(motion, movementParams)` (line 88) to record the new forward/sidestep/turn/style/action state.
|
|
||||||
4. If not contact-allowed (airborne): Action commands fail with `YouCantJumpWhileInTheAir` (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with `ModifyInterpretedState` (line 99-100).
|
|
||||||
5. Line 106-107: if `PhysicsObj.CurCell == null`, `RemoveLinkAnimations()` — an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations.
|
|
||||||
|
|
||||||
`contact_allows_move` (`MotionInterp.cs:584-602`):
|
|
||||||
```
|
|
||||||
Dead / Falling / TurnRight..TurnLeft range → always true
|
|
||||||
non-creature WeenieObj → always true
|
|
||||||
!PhysicsState.Gravity → true
|
|
||||||
!TransientState.Contact → false // airborne, no ground contact
|
|
||||||
TransientState.OnWalkable → true // standing on walkable floor
|
|
||||||
else → false
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q2 — TRANSITION: walk↔run while already moving (no full stop)
|
|
||||||
|
|
||||||
### Frame 0: the wire triggers `apply_raw_movement` (server method) or a direct `DoMotion(RunForward, ...)` (client-edge method / direct caller)
|
|
||||||
|
|
||||||
`MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`):
|
|
||||||
```csharp
|
|
||||||
InterpretedState.CurrentStyle = RawState.CurrentStyle;
|
|
||||||
InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005)
|
|
||||||
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
|
|
||||||
InterpretedState.SideStepCommand = RawState.SideStepCommand;
|
|
||||||
...
|
|
||||||
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
|
|
||||||
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
|
|
||||||
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
|
|
||||||
apply_interpreted_movement(cancelMoveTo, allowJump);
|
|
||||||
```
|
|
||||||
|
|
||||||
`adjust_motion` (`MotionInterp.cs:394-428`) is where **Walk→Run promotion actually happens**:
|
|
||||||
```csharp
|
|
||||||
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
|
|
||||||
{
|
|
||||||
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
|
|
||||||
switch (motion)
|
|
||||||
{
|
|
||||||
case RunForward: return; // already run, no-op
|
|
||||||
case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65
|
|
||||||
case TurnLeft: motion = TurnRight; speed *= -1.0f; break;
|
|
||||||
case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break;
|
|
||||||
}
|
|
||||||
if (motion == SideStepRight)
|
|
||||||
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25)
|
|
||||||
|
|
||||||
if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey;
|
|
||||||
if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`apply_run_to_command` (`MotionInterp.cs:525-562`):
|
|
||||||
```csharp
|
|
||||||
case WalkForward:
|
|
||||||
if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id → RunForward substate id
|
|
||||||
speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate)
|
|
||||||
break;
|
|
||||||
case TurnRight: speed *= RunTurnFactor; break; // 1.5
|
|
||||||
case SideStepRight:
|
|
||||||
speed *= speedMod;
|
|
||||||
if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0
|
|
||||||
speed = Math.Sign(speed) * MaxSidestepAnimRate;
|
|
||||||
break;
|
|
||||||
```
|
|
||||||
**So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the *interpreter* (not the network layer) decides to swap the motion id from `WalkForward` (0x45000005) to `RunForward` (0x44000007) before it ever reaches the animation graph.** This matches the CLAUDE.md-documented wire quirk: acdream sends `WalkForward + HoldKey.Run`, and ACE relays it as `RunForward` to observers — that relay behavior mirrors exactly this `adjust_motion`/`apply_run_to_command` swap.
|
|
||||||
|
|
||||||
### Frame 1: `apply_interpreted_movement` re-issues `DoInterpretedMotion` for the (possibly swapped) ForwardCommand
|
|
||||||
|
|
||||||
`MotionInterp.apply_interpreted_movement` (`MotionInterp.cs:440-504`):
|
|
||||||
```csharp
|
|
||||||
if (InterpretedState.ForwardCommand == RunForward)
|
|
||||||
MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls
|
|
||||||
|
|
||||||
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance
|
|
||||||
|
|
||||||
if (contact_allows_move(InterpretedState.ForwardCommand))
|
|
||||||
{
|
|
||||||
if (!StandingLongJump)
|
|
||||||
{
|
|
||||||
movementParams.Speed = InterpretedState.ForwardSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed)
|
|
||||||
...sidestep, turn similarly...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frame 2: `DoInterpretedMotion(RunForward, ...)` reaches `MotionTable.GetObjectSequence` — THIS is where the cycle actually swaps
|
|
||||||
|
|
||||||
`GetObjectSequence` (`Animation/MotionTable.cs:60-257`). WalkForward (0x45000005) and RunForward (0x44000007) both have the `CommandMask.SubState` bit (0x40000000) set, so both hit the **SubState branch** (line 121 onward), NOT the "same substate, just re-speed" fast path — because `motion` (RunForward id) != `currState.Substate` (currently WalkForward id). The relevant excerpt:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
if ((motion & CommandMask.SubState) != 0)
|
|
||||||
{
|
|
||||||
var motionID = motion & 0xFFFFFF;
|
|
||||||
Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData
|
|
||||||
...
|
|
||||||
if (is_allowed(motion, motionData, currState))
|
|
||||||
{
|
|
||||||
if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod))
|
|
||||||
{
|
|
||||||
// FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run,
|
|
||||||
// since WalkForward != RunForward numerically)
|
|
||||||
change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod);
|
|
||||||
subtract_motion(sequence, motionData, currState.SubstateMod);
|
|
||||||
combine_motion(sequence, motionData, speedMod);
|
|
||||||
currState.SubstateMod = speedMod;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GENERAL PATH — actually taken for Walk->Run:
|
|
||||||
if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers();
|
|
||||||
|
|
||||||
var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod);
|
|
||||||
// link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict,
|
|
||||||
// keyed by (style<<16 | fromSubstate) -> Dictionary<toSubstate, MotionData>
|
|
||||||
|
|
||||||
if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod))
|
|
||||||
{
|
|
||||||
// fallback: go through the style's default substate as an intermediate hop
|
|
||||||
uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion);
|
|
||||||
link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f);
|
|
||||||
motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod);
|
|
||||||
}
|
|
||||||
|
|
||||||
sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero
|
|
||||||
sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4
|
|
||||||
|
|
||||||
if (motionData_ != null)
|
|
||||||
{
|
|
||||||
add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim
|
|
||||||
add_motion(sequence, motionData_, speedMod); // append default->run link anim
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case)
|
|
||||||
}
|
|
||||||
add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever)
|
|
||||||
|
|
||||||
currState.SubstateMod = speedMod;
|
|
||||||
currState.Substate = motion; // RunForward is now the tracked substate
|
|
||||||
re_modify(sequence, currState); // re-apply any active modifiers on top
|
|
||||||
|
|
||||||
numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Answer to Q2: the new motion is APPENDED to the sequence's `AnimList`, not a hard replace.** `sequence.remove_cyclic_anims()` first prunes any *already-cyclic* (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then `add_motion` calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of `AnimList` (`Sequence.append_animation`, `Sequence.cs:203-216`). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once `Sequence.update_internal` walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). **There IS a blend/link animation** — `get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed)` looks up a purpose-built transition clip from `MotionTable.Links[(style<<16)|fromSubstate][toSubstate]`; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. **Speed change is NOT instantaneous/immediate** — the running cycle only takes effect once the sequence actually plays through to it; only `Sequence.Velocity`/`Sequence.Omega` (the linear/angular displacement-per-quantum baked into the currently active `MotionData`) change per node, and those are set fresh by each `add_motion` call via `sequence.SetVelocity(motionData.Velocity * speed)` (`MotionTable.cs:362`) which OVERWRITES (not blends) `Sequence.Velocity` — but that overwrite only takes effect for the currently-active node once the sequence has walked into it.
|
|
||||||
|
|
||||||
One exception worth flagging: `SetVelocity`/`SetOmega` inside `add_motion` (line 362-363) is called once per `add_motion(sequence, motionData, speed)` invocation and each call **overwrites** `sequence.Velocity`/`sequence.Omega` — so by the time `GetObjectSequence` returns, `Sequence.Velocity` already reflects the LAST `add_motion` call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means **the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle** — a subtlety worth testing for in acdream's port (verify our `Sequence.Update` doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q3 — PENDING/DONE lifecycle: `pending_motions` + `MotionDone`
|
|
||||||
|
|
||||||
Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate:
|
|
||||||
|
|
||||||
### Layer 1: `MotionInterp.PendingMotions` (`List<MotionNode>`, `MotionInterp.cs:24`)
|
|
||||||
|
|
||||||
- **Type**: `LinkedList<MotionNode>`. `MotionNode` (`Animation/MotionNode.cs`): `ContextID` (int), `Motion` (uint), `JumpErrorCode` (WeenieError).
|
|
||||||
- **Appended by**: `MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)` (`MotionInterp.cs:388-392`):
|
|
||||||
```csharp
|
|
||||||
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
|
|
||||||
PhysicsObj.IsAnimating = true;
|
|
||||||
```
|
|
||||||
Called from `DoInterpretedMotion` (line 85), `StopCompletely` (line 321), `StopInterpretedMotion` (line 348), and `apply_interpreted_movement`'s turn-release branch (line 495).
|
|
||||||
- **Popped by**: `MotionInterp.MotionDone(bool success)` (`MotionInterp.cs:210-234`):
|
|
||||||
```csharp
|
|
||||||
var motionData = PendingMotions.First;
|
|
||||||
if (motionData != null)
|
|
||||||
{
|
|
||||||
var pendingMotion = motionData.Value;
|
|
||||||
if ((pendingMotion.Motion & CommandMask.Action) != 0)
|
|
||||||
{
|
|
||||||
PhysicsObj.unstick_from_object();
|
|
||||||
InterpretedState.RemoveAction();
|
|
||||||
RawState.RemoveAction();
|
|
||||||
}
|
|
||||||
motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy)
|
|
||||||
if (motionData != null)
|
|
||||||
{
|
|
||||||
PendingMotions.Remove(motionData);
|
|
||||||
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
This is called ONLY from `PhysicsObj.MotionDone(motion, success)` → `MovementManager.MotionDone(motion, success)` → `MotionInterpreter.MotionDone(success)` (note: the `motion` parameter is dropped/ignored at this layer — it always pops `PendingMotions.First`, positionally, not by matching motion id).
|
|
||||||
- **Who calls `PhysicsObj.MotionDone`**: `MotionTableManager.AnimationDone` (see Layer 2 below) and `MotionTableManager.CheckForCompletedMotions`.
|
|
||||||
- **`motions_pending()` / `IsAnimating`**: `MotionInterp.motions_pending()` (line 784-787) just checks `PendingMotions.Count > 0`; the faster `PhysicsObj.IsAnimating` bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer `IsAnimating` for perf.
|
|
||||||
- **`HandleExitWorld`** (`MotionInterp.cs:160-173`): drains `PendingMotions` entirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain).
|
|
||||||
|
|
||||||
### Layer 2: `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`, `Managers/MotionTableManager.cs:13`)
|
|
||||||
|
|
||||||
- **Type**: `AnimNode` (`Animation/AnimNode.cs`): `Motion` (uint), `NumAnims` (uint) — the count of individual `Animation` sub-clips (as opposed to the higher-level `MotionCommand`) that must finish playing before this queue entry is considered done.
|
|
||||||
- **Appended by**: `MotionTableManager.add_to_queue(motion, num_anims, sequence)` (`MotionTableManager.cs:163-167`), called from `PerformMovement` (line 127/134/139) with `counter` = the `numAnims` computed by `MotionTable.GetObjectSequence`/`StopSequenceMotion`/`StopObjectCompletely` (i.e. the total sub-clip count of link+cycle animations just appended to `Sequence.AnimList` for this motion). Also called by `initialize_state` (line 176, entry into default Ready state) with the numAnims from `SetDefaultState`.
|
|
||||||
- Immediately after append: `remove_redundant_links(sequence)` (line 166) — walks `PendingAnimations` from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls `trancuate_animation_list` to zero out the redundant entries' `NumAnims` and physically remove the corresponding still-unplayed link animations from `Sequence.AnimList` via `sequence.remove_link_animations(totalAnims)` (`Sequence.cs:324-340`). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling).
|
|
||||||
- **Popped/completed by TWO different drivers**:
|
|
||||||
1. **`AnimationDone(bool success)`** (`MotionTableManager.cs:28-61`) — driven by actual animation-hook completion signals (see `Sequence.update_internal`'s `AnimDoneHook`, Q4). Increments `AnimationCounter`, then while the FRONT node's `NumAnims <= AnimationCounter`: subtracts `entry.NumAnims` back out of the counter, removes an Action-head from `MotionState` if the motion has the Action bit, calls `PhysicsObj.MotionDone(motionID, success)` (→ pops Layer-1 `MotionInterp.PendingMotions`, see above), removes the front `PendingAnimations` node, and fires `PhysicsObj.WeenieObj.OnMotionDone(motionID, success)` (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).
|
|
||||||
2. **`CheckForCompletedMotions()`** (`MotionTableManager.cs:63-85`) — a POLLING variant, called from `PhysicsObj.CheckForCompletedMotions` (`PhysicsObj.cs:296-300`) → `PartArray.CheckForCompletedMotions` (`PartArray.cs:72-76`), itself invoked once per `MotionInterp.PerformMovement` call (`MotionInterp.cs:260`, right after `PhysicsObj.CheckForCompletedMotions()` unconditionally at the end of every `PerformMovement`). It loops while `PendingAnimations.First.Value.NumAnims == 0` (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior `remove_redundant_links`/`trancuate_animation_list` call), popping them exactly like `AnimationDone` does (same Action-head removal, `PhysicsObj.MotionDone`, `OnMotionDone`).
|
|
||||||
- **Actual per-sub-animation completion signal**: `Sequence.update_internal` (`Animation/Sequence.cs:351-443`), when `animDone` is set true (a frame index walked off the end of the current `AnimSequenceNode`'s HighFrame/LowFrame), fires:
|
|
||||||
```csharp
|
|
||||||
if (HookObj != null)
|
|
||||||
{
|
|
||||||
var node = AnimList.First;
|
|
||||||
if (!node.Equals(FirstCyclic))
|
|
||||||
HookObj.add_anim_hook(AnimationHook.AnimDoneHook);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
i.e. **only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node** — the looping cycle node itself never signals "done" this way (it loops forever via `advance_to_next_animation`'s wraparound to `FirstCyclic`, see Q4). `HookObj.add_anim_hook` (defined on `PhysicsObj`, not read in detail — queues an `FPHook`/animation hook for later dispatch) is what eventually drives `MotionTableManager.AnimationDone(true)` on the consuming side.
|
|
||||||
|
|
||||||
### Summary of the 3-tier queue relationship
|
|
||||||
```
|
|
||||||
Sequence.AnimList (LinkedList<AnimSequenceNode>) — the actual playable clips, link+cyclic
|
|
||||||
↑ pushed onto tail by add_motion() during GetObjectSequence
|
|
||||||
MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — motion-id + sub-clip COUNT bookkeeping
|
|
||||||
↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling
|
|
||||||
MotionInterp.PendingMotions (LinkedList<MotionNode>) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call)
|
|
||||||
↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations
|
|
||||||
entry at Layer 2 fully completes (positionally FIFO, NOT id-matched)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link?
|
|
||||||
|
|
||||||
Answer: **transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.**
|
|
||||||
|
|
||||||
### `Sequence.append_animation` (`Sequence.cs:203-216`)
|
|
||||||
```csharp
|
|
||||||
public void append_animation(AnimData animData)
|
|
||||||
{
|
|
||||||
var node = new AnimSequenceNode(animData);
|
|
||||||
if (!node.has_anim()) return;
|
|
||||||
AnimList.AddLast(node);
|
|
||||||
FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node
|
|
||||||
if (CurrAnim == null)
|
|
||||||
{
|
|
||||||
CurrAnim = AnimList.First;
|
|
||||||
FrameNumber = CurrAnim.Value.get_starting_frame();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Each `add_motion` call in `GetObjectSequence` does one `append_animation` PER sub-`Anim` in the `MotionData.Anims` list (`MotionTable.cs:358-370`, `add_motion`). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). `FirstCyclic` ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, `advance_to_next_animation` wraps back to `FirstCyclic`, not to `AnimList.First`.
|
|
||||||
|
|
||||||
### `Sequence.remove_cyclic_anims` (`Sequence.cs:303-322`) — called BEFORE the new link+cycle are appended
|
|
||||||
```csharp
|
|
||||||
public void remove_cyclic_anims()
|
|
||||||
{
|
|
||||||
var node = FirstCyclic;
|
|
||||||
while (node != null)
|
|
||||||
{
|
|
||||||
if (CurrAnim.Equals(node))
|
|
||||||
{
|
|
||||||
CurrAnim = node.Previous;
|
|
||||||
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
|
|
||||||
else FrameNumber = 0.0f;
|
|
||||||
}
|
|
||||||
var next = node.Next;
|
|
||||||
AnimList.Remove(node.Value);
|
|
||||||
node = next;
|
|
||||||
}
|
|
||||||
FirstCyclic = AnimList.Last;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
This walks from the OLD `FirstCyclic` (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (`CurrAnim`) happens to BE the old cyclic loop node being removed, `CurrAnim` is rewound to `node.Previous` with `FrameNumber` snapped to that previous node's `get_ending_frame()` — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. **In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, `HighFrame + 1 - EPSILON`, `AnimSequenceNode.cs:31-37`), THEN the link animation begins from ITS OWN starting frame (`LowFrame` for forward playback, `AnimSequenceNode.cs:72-78`).** There is no frame-phase carry-over from Walk into the link or from the link into Run — every `AnimSequenceNode` transition resets `frameNum` to that node's `get_starting_frame()` inside `advance_to_next_animation` (`Sequence.cs:145-201`, specifically line 165 `frameNum = currAnim.get_starting_frame();` for forward playback, or line 191 `frameNum = currAnim.get_ending_frame();` for the reverse-playback branch).
|
|
||||||
|
|
||||||
### `advance_to_next_animation` (`Sequence.cs:145-201`) — the actual node-to-node walk
|
|
||||||
Forward-playback branch (`timeElapsed >= 0.0f`):
|
|
||||||
```csharp
|
|
||||||
// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution)
|
|
||||||
if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
|
|
||||||
animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail
|
|
||||||
currAnim = animNode.Value;
|
|
||||||
frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame — never carries phase
|
|
||||||
if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
|
|
||||||
```
|
|
||||||
So the answer is explicit: **frame index restarts per-node** (each `AnimSequenceNode` — link clip or cycle clip — always begins at its own `LowFrame`/`HighFrame` boundary), and the only "carry-over" concept is which NODE plays next, driven by the `AnimList` linked-list order that `GetObjectSequence`/`add_motion` built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated **link animation** (`MotionTable.get_link`, `MotionTable.cs:395-426`) between them — never a numeric blend/crossfade of frame data. `Sequence.apply_physics` (Sequence.cs:221-230) applies `Velocity`/`Omega` translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in `Sequence`/`MotionTable` code.
|
|
||||||
|
|
||||||
### `apricot()` (`Sequence.cs:232-243`) — garbage-collects consumed-but-still-present nodes
|
|
||||||
```csharp
|
|
||||||
public void apricot()
|
|
||||||
{
|
|
||||||
var node = AnimList.First;
|
|
||||||
while (!node.Equals(CurrAnim))
|
|
||||||
{
|
|
||||||
if (node.Equals(FirstCyclic)) break;
|
|
||||||
AnimList.Remove(node);
|
|
||||||
node = AnimList.First;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Called every `Sequence.Update` tick (`Sequence.cs:132-143`, right after `update_internal`) — trims fully-played-past nodes off the FRONT of `AnimList` up to (but never past) `FirstCyclic`, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is `apricot` in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q5 — POSITION DRIVE between inbound packets: what advances position?
|
|
||||||
|
|
||||||
**Both, combined additively in one offset frame, gated by ground contact.** Sequence order (`PhysicsObj.UpdatePositionInternal`, `PhysicsObj.cs:1862-1879`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public void UpdatePositionInternal(double quantum, ref AFrame newFrame)
|
|
||||||
{
|
|
||||||
var offsetFrame = new AFrame();
|
|
||||||
if (!State.HasFlag(PhysicsState.Hidden))
|
|
||||||
{
|
|
||||||
if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta
|
|
||||||
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
|
|
||||||
offsetFrame.Origin *= Scale; // 2. scale only applied while grounded
|
|
||||||
else
|
|
||||||
offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT
|
|
||||||
}
|
|
||||||
if (PositionManager != null)
|
|
||||||
PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top
|
|
||||||
newFrame = AFrame.Combine(Position.Frame, offsetFrame);
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 1 — `PartArray.Update` → `Sequence.Update`** (`PartArray.cs:589-592`, `Sequence.cs:132-143`):
|
|
||||||
```csharp
|
|
||||||
public void Update(float quantum, ref AFrame offsetFrame)
|
|
||||||
{
|
|
||||||
if (AnimList.First != null)
|
|
||||||
{
|
|
||||||
update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame);
|
|
||||||
apricot();
|
|
||||||
}
|
|
||||||
else if (offsetFrame != null)
|
|
||||||
apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Inside `update_internal` (`Sequence.cs:351-443`), for every whole frame boundary crossed this tick:
|
|
||||||
```csharp
|
|
||||||
if (currAnim.Anim.PosFrames != null)
|
|
||||||
frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas")
|
|
||||||
if (Math.Abs(framerate) > PhysicsGlobals.EPSILON)
|
|
||||||
apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution)
|
|
||||||
```
|
|
||||||
So **within a single `Sequence.Update` call, BOTH sources are combined**: (a) any authored `PosFrames` deltas baked directly into the `.anim` asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via `AFrame.Combine`, AND (b) `Sequence.apply_physics` (`Sequence.cs:221-230`) which does `frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum);` where `Velocity`/`Omega` are the CURRENT node's `MotionData.Velocity`/`.Omega` (set by `add_motion`'s `sequence.SetVelocity(motionData.Velocity * speed)` — i.e. this IS "velocity from the interpreted motion state", since `speed` traces back to `InterpretedState.ForwardSpeed`/`get_state_velocity()`-derived values). **Which dominates depends entirely on the specific `.anim` asset** — most locomotion cycles (walk/run) in retail author their forward displacement via `Velocity` (constant per-cycle linear speed) rather than per-frame `PosFrames`, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the `.anim` DAT asset defines gets applied.
|
|
||||||
|
|
||||||
**Step 3 is critical**: `offsetFrame.Origin *= 0.0f` when NOT `OnWalkable` — **while airborne, animation-driven translation is completely discarded**; only `PositionManager.AdjustOffset` (network correction) and gravity/velocity integration elsewhere (`UpdatePhysicsInternal`, `PhysicsObj.cs:1832-1860`, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: **ground movement speed is whatever the `.anim` asset's baked `Velocity` says for that cycle at that `speed` multiplier, not a free-form physics velocity vector.**
|
|
||||||
|
|
||||||
**Step 4 — `PositionManager.AdjustOffset`** (`PositionManager.cs:20-28`) chains `InterpolationManager.adjust_offset` + `StickyManager.adjust_offset` + `ConstraintManager.adjust_offset` onto the SAME `offsetFrame` that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q6 — CORRECTION: how do inbound position updates fix accumulated error?
|
|
||||||
|
|
||||||
Entry point for "another entity's authoritative position arrived": `PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)` (`PhysicsObj.cs:905-934`):
|
|
||||||
```csharp
|
|
||||||
public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)
|
|
||||||
{
|
|
||||||
... staleness/sequence-number check against UpdateTimes[4] ...
|
|
||||||
if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp))
|
|
||||||
{
|
|
||||||
teleport_hook(true);
|
|
||||||
SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!contact) return false;
|
|
||||||
if (PlayerDistance < 96.0f)
|
|
||||||
InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PositionManager?.StopInterpolating();
|
|
||||||
SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`96.0f` (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate.
|
|
||||||
|
|
||||||
`PositionManager.InterpolateTo` (`PositionManager.cs:55-61`) lazily creates an `InterpolationManager` and calls `InterpolationManager.InterpolateTo(position, keepHeading)`.
|
|
||||||
|
|
||||||
### `InterpolationManager.InterpolateTo` (`InterpolationManager.cs:36-84`)
|
|
||||||
```csharp
|
|
||||||
var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position;
|
|
||||||
var dist = dest.Distance(position);
|
|
||||||
|
|
||||||
if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip"
|
|
||||||
{
|
|
||||||
if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position
|
|
||||||
{
|
|
||||||
// dedupe: drop trailing queued nodes that are already close (<0.05) to the new target
|
|
||||||
while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast();
|
|
||||||
while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20
|
|
||||||
enqueue new PositionType node (optionally overriding heading to current heading if keepHeading)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true);
|
|
||||||
StopInterpolating(); // close enough already — snap heading only, clear queue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4
|
|
||||||
enqueue new PositionType node; NodeFailCounter = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Per-tick blending: `InterpolationManager.adjust_offset(AFrame frame, double quantum)` (`InterpolationManager.cs:199-258`) — called from `PositionManager.AdjustOffset` every `UpdatePositionInternal` tick
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded
|
|
||||||
var first = queue.First.Value;
|
|
||||||
if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here
|
|
||||||
|
|
||||||
var dist = PhysicsObj.Position.Distance(first.Position);
|
|
||||||
if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units
|
|
||||||
|
|
||||||
var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed
|
|
||||||
if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f
|
|
||||||
|
|
||||||
var delta = OriginalDistance - dist;
|
|
||||||
ProgressQuantum += quantum; FrameCounter++;
|
|
||||||
|
|
||||||
if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f)))
|
|
||||||
{
|
|
||||||
// "making good enough progress" — keep smoothing
|
|
||||||
if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks
|
|
||||||
var offset = first.Position.Subtract(PhysicsObj.Position);
|
|
||||||
var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance
|
|
||||||
var distance = offset.Origin.Length();
|
|
||||||
if (distance <= 0.05f) NodeCompleted(true);
|
|
||||||
if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2
|
|
||||||
if (KeepHeading) offset.set_heading(0.0f);
|
|
||||||
frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal — added on top of animation displacement
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NodeFailCounter++;
|
|
||||||
NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick
|
|
||||||
```
|
|
||||||
|
|
||||||
Constants (verbatim, `InterpolationManager.cs:18-19`):
|
|
||||||
```
|
|
||||||
LargeDistance = 999999.0f
|
|
||||||
MaxInterpolatedVelocity = 7.5f
|
|
||||||
UseAdjustedSpeed = true (static, defaults on)
|
|
||||||
```
|
|
||||||
Snap/completion threshold: **`dist < 0.05f`** appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in `InterpolateTo`) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from `PhysicsGlobals.EPSILON` (0.0002f) which is the general floating-point/animation epsilon.
|
|
||||||
|
|
||||||
Progress-rate abandonment rule: if less than **30%** of the max-possible correction speed's worth of distance was closed over the last 5-tick window (`delta / ProgressQuantum / maxSpeed >= 0.3f` failing), `NodeFailCounter` increments; once `NodeFailCounter > 3` the `UseTime()` method (`InterpolationManager.cs:142-197`) takes over and does a **hard `SetPositionSimple` snap** (with fallback velocity-carry logic scanning back through the queue for the last `PositionType` node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up.
|
|
||||||
|
|
||||||
`NodeCompleted(bool success)` (`InterpolationManager.cs:91-126`) pops the front queue node, resets `FrameCounter`/`ProgressQuantum`, and recomputes `OriginalDistance` against the NEXT queued node (or `LargeDistance` if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node.
|
|
||||||
|
|
||||||
**No InterpolationNode "Velocity" playback happens inside `adjust_offset`** — `VelocityType`/`JumpType` queue nodes are explicitly skipped there (`InterpolationManager.cs:205`) and are instead consumed synchronously and immediately inside `UseTime()` (`InterpolationManager.cs:185-196`, `case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;`), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q7 — STOP: motion → Ready/stand
|
|
||||||
|
|
||||||
### Two distinct stop mechanisms in `MotionInterp`
|
|
||||||
|
|
||||||
**(a) `StopInterpretedMotion(uint motion, MovementParameters)`** (`MotionInterp.cs:329-365`) — stop ONE specific ongoing motion (e.g. release the forward key while still turning):
|
|
||||||
```csharp
|
|
||||||
if (contact_allows_move(motion))
|
|
||||||
{
|
|
||||||
if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight})
|
|
||||||
InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand)
|
|
||||||
if (result == WeenieError.None)
|
|
||||||
{
|
|
||||||
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id
|
|
||||||
if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only
|
|
||||||
|
|
||||||
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
|
|
||||||
```
|
|
||||||
`MotionTableManager.PerformMovement`'s `StopInterpretedCommand` case (`MotionTableManager.cs:130-135`):
|
|
||||||
```csharp
|
|
||||||
if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData;
|
|
||||||
add_to_queue((uint)MotionCommand.Ready, counter, seq);
|
|
||||||
```
|
|
||||||
`MotionTable.StopObjectMotion` → `StopSequenceMotion` (`MotionTable.cs:315-356`):
|
|
||||||
```csharp
|
|
||||||
if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion)
|
|
||||||
{
|
|
||||||
uint style; StyleDefaults.TryGetValue(currState.Style, out style);
|
|
||||||
GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ((motion & CommandMask.Modifier) == 0) return false;
|
|
||||||
// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers
|
|
||||||
```
|
|
||||||
**So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting `Ready`/idle instead of another locomotion cycle.** This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same `get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f)` call inside the re-entered `GetObjectSequence`), not an instant cut to a standing pose.
|
|
||||||
|
|
||||||
**(b) `StopCompletely()`** (`MotionInterp.cs:301-327`) — full stop, e.g. server-forced idle:
|
|
||||||
```csharp
|
|
||||||
PhysicsObj.cancel_moveto();
|
|
||||||
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
|
|
||||||
|
|
||||||
RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0;
|
|
||||||
InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0;
|
|
||||||
|
|
||||||
PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely)
|
|
||||||
add_to_queue(0, (uint)MotionCommand.Ready, jump);
|
|
||||||
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
|
|
||||||
```
|
|
||||||
`MotionTable.StopObjectCompletely` (`MotionTable.cs:293-313`):
|
|
||||||
```csharp
|
|
||||||
// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states)
|
|
||||||
while (currState.Modifiers.First != null)
|
|
||||||
StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims);
|
|
||||||
// then stop the current Substate (Walk/Run/etc.) itself
|
|
||||||
StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Velocity zeroing
|
|
||||||
`Velocity`/`Omega` on the `Sequence` are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time `add_motion` runs during the `GetObjectSequence(style, ..., true)` re-entry inside `StopSequenceMotion` (line 327): `sequence.SetVelocity(motionData.Velocity * speed)` where `motionData` is now the Ready/idle cycle's `MotionData` (`MotionTable.cs:362`, inside `add_motion`), and Ready/idle cycles are authored with zero linear `Velocity` in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against `docs/research/named-retail/` if exactness matters). Additionally, `sequence.clear_physics()` (`Sequence.cs:256-260`, sets `Velocity = Omega = Vector3.Zero`) is called unconditionally at the TOP of every `GetObjectSequence` SubState-branch invocation (`MotionTable.cs:152` inside the branch reached from `StopSequenceMotion`'s re-entry) — **so `Sequence.Velocity`/`Omega` ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent `add_motion` calls for the link+Ready-cycle**, meaning during the STOP LINK ANIMATION itself, whatever `Velocity`/`Omega` that specific link clip's `MotionData` carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below).
|
|
||||||
|
|
||||||
### Residual-slide prevention
|
|
||||||
There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through `get_link(...)` to a dedicated stop/deceleration animation clip whose `MotionData.Velocity` is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's `MotionData.Velocity` should be `Vector3.Zero` so continued looping produces zero displacement per Q5's `apply_physics(frame, quantum, quantum)` math. Physical/gravity velocity (`PhysicsObj.Velocity`, used only by the free-flight `UpdatePhysicsInternal` path, `PhysicsObj.cs:1832-1860`) is separately damped via `calc_friction(quantum, velocity_mag2)` (not read in detail; referenced at `PhysicsObj.cs:1849`) plus a hard clamp:
|
|
||||||
```csharp
|
|
||||||
if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero;
|
|
||||||
```
|
|
||||||
`SmallVelocity = 0.25f` (`PhysicsGlobals.cs:34`), `SmallVelocitySquared = 0.0625f` (line 36), `EPSILON = 0.0002f` (line 9) — i.e. once ground-friction has decayed `PhysicsObj.Velocity` to within `sqrt(0.0625 + 0.0002) ≈ 0.2504` units/sec of zero (this branch is really checking `velocity_mag2 <= SmallVelocitySquared + EPSILON`), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop.
|
|
||||||
|
|
||||||
### `RemoveLinkAnimations` on cell-exit
|
|
||||||
Both `StopInterpretedMotion` and `StopCompletely` end with `if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();` (`PhysicsObj.cs:992-996` → `PartArray.HandleEnterWorld` → `MotionTableManager.HandleEnterWorld` (`MotionTableManager.cs:103-108`) → `sequence.remove_all_link_animations()` (`Sequence.cs:289-301`) + drains `PendingAnimations` via repeated `AnimationDone(false)` calls). `remove_all_link_animations` differs from `remove_cyclic_anims` (Q4) — it strips every node BEFORE `FirstCyclic` (i.e. the non-looping link/transition segment), snapping `CurrAnim` straight to `FirstCyclic` (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when `CurCell == null`, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of `DoInterpretedMotion`/`StopInterpretedMotion` (Q1/Q3).
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# Claim map: inbound motion/animation research vs current code
|
|
||||||
|
|
||||||
Date: 2026-07-02. Repo (worktree): `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
|
|
||||||
(branch `claude/vigorous-joliot-f0c3ad`, HEAD `b60b9b4a`).
|
|
||||||
|
|
||||||
**Path note:** `docs/research/2026-06-04-animation-sequencer-deep-dive.md` does NOT
|
|
||||||
exist in this worktree or in git history at all (it is an *untracked* file only
|
|
||||||
in the main checkout `C:/Users/erikn/source/repos/acdream/docs/research/...`).
|
|
||||||
It was never committed. Read directly from that absolute path for this audit.
|
|
||||||
The other two docs (`2026-06-26-movement-animation-retail-parity-audit.md`,
|
|
||||||
`2026-07-01-d6-motion-interp-pseudocode.md`) exist and are committed in this
|
|
||||||
worktree.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Doc 1: 2026-06-04-animation-sequencer-deep-dive.md (8 ranked divergences)
|
|
||||||
|
|
||||||
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Missing `pending_motions`/`MotionDone` chain (MotionInterp, HIGH). `add_to_queue` + `CheckForCompletedMotions` unimplemented. Affects multi-step action sequences, combo attacks, `UpdateMotion Commands[]` action chains. (doc anchor: "Divergences ranked" #1, L1242-1247) | Grepped `MotionInterpreter.cs`/`AnimationSequencer.cs` for `pending_motions`/`MotionDone`/`CheckForCompletedMotions`/`add_to_queue` — only comment references (`FUN_00510900 — CheckForCompletedMotions (animation flush, not simulated here)`, `FUN_00528790(…) — add_to_queue`). No queue data structure, no `MotionDone` event. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:399,412,558` (comments only, no impl) |
|
|
||||||
| 2 | `contact_allows_move` level mismatch (MotionInterp, HIGH). acdream blocks Fallen/Dead/Crouch-range inside `contact_allows_move` itself; retail does this at `DoMotion` level with distinct error codes. (doc anchor: "Divergences ranked" #2, L1249-1253) | Read `contact_allows_move` body: still explicitly checks `fwd == MotionCommand.Fallen \|\| fwd == MotionCommand.Dead` and the crouch/sit/sleep range, returning `false` (blocked) directly from this function — exactly the divergence described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (see lines 1092-1099 for the Fallen/Dead/Crouch block) |
|
|
||||||
| 3 | `re_modify` not implemented after cycle rebuild (CSequence, MEDIUM). Stacked Modifier motions (combat stance overlays) lose velocity/omega contribution on cycle switch. (doc anchor: "Divergences ranked" #3, L1255-1260) | Grepped `AnimationSequencer.cs` for `re_modify`/`ReModify`/`ModifierList`/`_activeModifiers` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/AnimationSequencer.cs` (no match found for the term at all) |
|
|
||||||
| 4 | `TransparentHook` fires as instant snap vs smooth lerp (anim-hooks, MEDIUM). No `FPHook` time-interpolation equivalent. (doc anchor: "Divergences ranked" #4, L1262-1266) | Not directly re-verified this session (out of scope for walk/run + stop-slide focus — this is a fade/translucency concern, not motion/position). | **COULD-NOT-VERIFY** (not checked; low relevance to task's (a)/(b)/(c) scope beyond "(c) inbound animation machinery generally") | n/a |
|
|
||||||
| 5 | `direction_` default `0xFFFFFFFE` vs `AnimationHookDir.Both` (anim-hooks, LOW). Dat-baked hooks with missing/zero direction may fire when retail would never fire them. (doc anchor: "Divergences ranked" #5, L1268-1272) | Not re-verified this session — orthogonal to motion/position, low priority. | **COULD-NOT-VERIFY** | n/a |
|
|
||||||
| 6 | Legacy entity path has no hook dispatch (anim-hooks, LOW-MEDIUM), at old `GameWindow.cs:8563-8573`. (doc anchor: "Divergences ranked" #6, L1274-1277) | Not re-verified — line numbers are 2026-06-04-vintage and GameWindow.cs has grown/shifted substantially (now 13,759 lines vs whatever it was then); the cited range is very likely stale. | **STALE-ANCHOR** (line numbers unverified/likely shifted; concept not re-checked) | n/a |
|
|
||||||
| 7 | `enter_default_state` not called on spawn (MotionInterp, LOW). No `InitializeMotionTables`/pending_motions Ready-node seeding at spawn. (doc anchor: "Divergences ranked" #7, L1279-1282) | Grepped `MotionInterpreter.cs` for `enter_default_state`/`EnterDefaultState`/`InitializeMotionTables` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs` (no match) |
|
|
||||||
| 8 | `StandingLongJump` path not called per-tick (MotionInterp, LOW). The standing-long-jump pre-jump pose sub-state exists as a flag but `apply_interpreted_movement`'s per-tick branch (Ready + StopSideStep) is not wired. (doc anchor: "Divergences ranked" #8, L1284-1287) | `StandingLongJump` field exists and is set/cleared in a few places (`MotionInterpreter.cs:328,941,1119,1146`) but no per-tick `apply_interpreted_movement`-style dispatch was found calling `DoInterpretedMotion(Ready)` + `StopInterpretedMotion(SideStepRight)` from the flag. Also confirmed D3 in doc 2 (MoveToState longjump bit) is now WIRED on the *outbound* wire side (`standingLongjump` param exists in `MoveToState.Build`), but that's the wire byte, not the per-tick animation branch this claim is about — those are different things. | **STILL-TRUE** (the per-tick animation branch is still not called; note the wire-side `standingLongjump` param now exists as an input but nothing sets it to true from a live per-tick standing-longjump detection) | `src/AcDream.Core/Physics/MotionInterpreter.cs:328,941,1119,1146` (flag only, no per-tick apply path); cf. `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70` (wire param exists but is a pass-through, always `false` by default) |
|
|
||||||
| 9 (supporting) | Remote-entity path dispatches via direct `SetCycle` call from `GameWindow.OnLiveMotionUpdated`, NOT via `move_to_interpreted_state` (executive summary + divergence #5/#9 in the MotionInterp section, L303-317). | Confirmed: `move_to_interpreted_state`/`MoveToInterpretedState` still does not exist anywhere except one comment reference. `OnLiveMotionUpdated` still directly manipulates `ae.Sequencer` (`SetCycle`) and does a raw bulk-copy into `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` — bypassing `adjust_motion` for remotes entirely. This is THE central mechanism relevant to the walk<->run-lag symptom: the D6-ported `adjust_motion`/`apply_raw_movement`/`get_state_velocity` triad is **local-player-only** (see doc-3 cross-check below). | **STILL-TRUE** (confirmed, and directly load-bearing for the task's (a)/(c) focus) | `src/AcDream.App/Rendering/GameWindow.cs:4517` (comment only ref to move_to_interpreted_state); `GameWindow.cs:4590,4598` (direct bulk-copy into InterpretedState, bypassing adjust_motion); `GameWindow.cs:4327-4492` (SetCycle-direct dispatch) |
|
|
||||||
| 10 (supporting) | acdream's remote velocity path bypasses `apply_current_movement` gating differences: acdream calls `PhysicsObj.OnWalkable` gate directly rather than the full retail contact/gravity flow (MotionInterp section divergence #3, L305). | `apply_current_movement` (used by BOTH local and remote via `rm.Motion.apply_current_movement` calls) still gates on `PhysicsObj.OnWalkable` directly and calls `get_state_velocity()` — same shape as described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:890-910`; called for remotes at `GameWindow.cs:10042,10074` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Doc 2: 2026-06-26-movement-animation-retail-parity-audit.md (D1-D12)
|
|
||||||
|
|
||||||
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| D1 | MoveToState raw flags not retail — presence-based instead of default-difference packing; overs-sends default values. (doc L118-130) | `MoveToState.cs` doc header now explicitly says "default-difference flags dword + conditional fields + actions" and `RawMotionState.Default` exists with retail defaults (`CurrentHoldKey=None`, `ForwardCommand=0x41000003`, `ForwardSpeed=1.0`, etc. matching doc's retail defaults table L60-72). Shipped as part of L.2b (`78e163a4`, 2026-06-30). | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/MoveToState.cs:22`; `src/AcDream.Core/Physics/RawMotionState.cs:44-58` |
|
|
||||||
| D2 | RawMotionState action-list and style packing incomplete — no full action-list packing, current-style scenarios. (doc L132-136) | `RawMotionState` now has `Actions` (IReadOnlyList<RawMotionAction>) with a doc comment describing retail's `num_actions` bits 11-15 packing. Appears substantially addressed as part of L.2b, though full conformance vs the bitfield layout (11 one-bit flags + num_actions:5) was not independently re-verified byte-for-byte this session. | **CLOSED-SINCE** (structure now present; exact bit-packing not re-verified) | `src/AcDream.Core/Physics/RawMotionState.cs:57-63` |
|
|
||||||
| D3 | MoveToState longjump bit not modeled — only contact 0/1 passed, `standing_longjump` not wired as named state. (doc L138-147) | `MoveToState.Build` now has a `standingLongjump` parameter and packs `trailing = (byte)((standingLongjump ? 0x02 : 0) \| (contact ? 0x01 : 0))` — matches doc's described retail byte layout. NOTE: the parameter is wired as an INPUT but this session did not verify any call site sets it to `true` from a live per-tick standing-longjump detection (see doc-1 item #8 above — the per-tick animation branch driving this flag is still missing). So the WIRE FORMAT is fixed; whether it's ever populated correctly is a separate, still-open question. | **CLOSED-SINCE** (wire format only; upstream detection still absent) | `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70,100-101` |
|
|
||||||
| D4 | JumpAction packet layout retail-incompatible — missing full Position, extra objectGuid/spellId fields, wrong order. (doc L149-161) | `JumpAction.cs` doc header now cites `Position::Pack (0x005a9640)` and explicitly notes the fix: "the pre-slice code had it backwards (two spurious trailing zero u32s, no Position at all)" — confirms this was found and fixed as part of L.2b. | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/JumpAction.cs:21-31` |
|
|
||||||
| D5 | Position heartbeat close but not fully proven — `NotePositionSent` stamps all three (time/position/contact-plane) on BOTH MTS and AP paths, but retail's `SendMovementEvent` (MTS) only stamps `last_sent_position_time`; only `SendPositionEvent` (AP) stamps all three. (doc L163-169) | Confirmed STILL live and explicitly re-audited: a 2026-06-30 dated comment at the MTS send site says verbatim "this is a real, audit-confirmed divergence from retail... left UNCHANGED; reported to the lead engineer instead of fixed here." This is the most concrete, freshest confirmation in the whole audit — it was re-checked via Ghidra decompile-by-address and deliberately NOT fixed. | **STILL-TRUE** (explicitly re-confirmed and deliberately deferred, not a stale claim) | `src/AcDream.App/Rendering/GameWindow.cs:8320-8339` |
|
|
||||||
| D6 | MotionInterpreter lacks canonical retail raw→interpreted normalization (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement` not ported); SideStepLeft/WalkBackward produce zero velocity; jump lateral velocity hand-patched. (doc L171-182) | **This is exactly what Doc 3 (D6 pseudocode doc) documents as SHIPPED** for the local player. Confirmed present: `adjust_motion` (`MotionInterpreter.cs:741`), `apply_run_to_command` (`:805`), `apply_raw_movement` (`:848`), `get_state_velocity` (`:639`) all exist and are called from `PlayerMovementController.cs:909,974,1014`. | **CLOSED-SINCE** (for LOCAL player only — see D7 below, this does NOT extend to remotes) | `src/AcDream.Core/Physics/MotionInterpreter.cs:639,741,805,848`; `src/AcDream.App/Input/PlayerMovementController.cs:880-1014` |
|
|
||||||
| D7 | Animation application split from retail motion flow — `AnimationSequencer` has useful pieces but `MotionInterpreter.apply_current_movement` is velocity-oriented and doesn't drive animation state through retail's order (style/forward/sidestep/turn/actions sequencing). (doc L184-188) | **THE central claim for the task's (a)/(c) focus.** Confirmed still true and now MORE precisely characterized: for remotes, `OnLiveMotionUpdated` bulk-copies `ForwardCommand`/`ForwardSpeed` directly into `InterpretedState` (bypassing `adjust_motion`), AND separately calls `ae.Sequencer.SetCycle(...)` directly for the visual cycle — two parallel, only loosely-coupled paths (one drives position/velocity via `apply_current_movement`→`get_state_velocity`, the other drives the visual cycle via `SetCycle`). Neither goes through `apply_interpreted_movement`'s full style/forward/sidestep/turn sequencing. Additionally there is a SEPARATE `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` mechanism (UP-velocity-derived, with a 200ms UM grace window + 4.5/5.5 m/s hysteresis bucketing) that ALSO writes `SetCycle` for the local-player-observed-remotely case — this is a THIRD parallel path, not documented in either research doc, that is very likely a major contributor to "walk<->run reacts too slowly" (its grace window + hysteresis band means a walk<->run toggle needs velocity to cross a threshold via UpdatePosition samples before the visual cycle re-buckets, since a UM may not arrive for HoldKey-only toggles per the code's own comments). | **STILL-TRUE** (confirmed and refined — root mechanism now identified precisely, more detailed than the original doc knew) | `src/AcDream.App/Rendering/GameWindow.cs:4590,4598` (bulk-copy bypass); `GameWindow.cs:5112-5176` (`ApplyServerControlledVelocityCycle`); `GameWindow.cs:5178-5300+` (`ApplyPlayerLocomotionRefinement`, grace/hysteresis constants at `:5095,5104,5110`) |
|
|
||||||
| D8 | Force-walk and MoveTo are approximations — `ServerControlledLocomotion`, `RemoteMoveToDriver`, `PlayerMovementController.BeginServerAutoWalk` approximate steering/cycle selection vs retail's full `MoveToManager` queue (pre-turn, move, aux turn, final heading, sticky targeting, progress failure, `MovementParameters::get_command`). (doc L190-196) | `ServerControlledLocomotion.cs` is still only 87 lines (a `PlanMoveToStart`/`PlanFromVelocity` helper, not a queued state machine). No pre-turn/aux-turn/sticky-target/progress-failure machinery found. | **STILL-TRUE** | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines total); `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines, still a simplified steering helper) |
|
|
||||||
| D9 | Inbound movement types 8 (TurnToObject) and 9 (TurnToHeading) dropped — `UpdateMotion` handles interpreted state and MoveTo 6/7 but not 8/9. (doc L198-207) | Confirmed: `UpdateMotion.TryParse` only branches on `movementType == 0` and `movementType is 6 or 7`; no `8`/`9` case anywhere. Grep for `TurnToObject`/`TurnToHeading` across `UpdateMotion.cs` and `GameWindow.cs` returns zero hits. The 2026-07-02 handoff doc explicitly reconfirms this live: "CONFIRMED live: mt=0x09 arrives and just SetCycle(Ready) instead of turning." | **STILL-TRUE** (independently re-confirmed via grep + corroborated by a live capture cited in the handoff doc) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:136-238` (no case 8/9) |
|
|
||||||
| D10 | Spawn-time movement state weaker than live — `CreateObject` detects movement types/flags but doesn't preserve full MoveTo target/origin/threshold data the way `UpdateMotion` does. (doc L209-213) | Confirmed with exact detail: `CreateObject.TryParseMoveToPayload` (private, 3-out-param overload: `movementParameters`, `speed`, `runRate`) SKIPS Origin/distanceToObject/minDistance/failDistance/walkRunThreshold/desiredHeading via `pos +=` advances with no output — so `CreateObject`'s returned `ServerMotionState.MoveToPath` is always `null`. By contrast `UpdateMotion.TryParseMoveToPayload` (a DIFFERENT, more complete overload) DOES populate `out CreateObject.MoveToPathData? path` with Origin/thresholds. Two parsers of the same wire shape, one materially weaker. | **STILL-TRUE** (confirmed with precise mechanism: two divergent parser overloads for the same MoveTo payload) | `src/AcDream.Core.Net/Messages/CreateObject.cs:1194-1226` (weak 3-field overload, spawn path) vs `src/AcDream.Core.Net/Messages/UpdateMotion.cs:255-331` (full path-populating overload, live-update path) |
|
|
||||||
| D11 | Sequence/autonomy data parsed then discarded — retail carries movement sequence, server-control sequence, autonomous state, motion flags, position sequence into movement application; acdream exposes only a subset. (doc L215-219) | Confirmed: `UpdateMotion.TryParse` still does a blind `pos += 6` skip over `movementSequence`(u16) + `serverControlSequence`(u16) + `isAutonomous`(u8) + align — none of these three fields are captured into the `Parsed` record or `ServerMotionState`. (Note: `WorldSession.cs` DOES track `_serverControlSequence`/`_instanceSequence`/etc., but those are the OUTBOUND sequence counters sourced from `UpdatePosition`, not the INBOUND per-entity `UpdateMotion` fields this claim is about — different mechanism entirely, do not conflate.) `_motionFlags` (byte) is read into a local var and never used beyond that line either. | **STILL-TRUE** (confirmed, and clarified that the WorldSession sequence counters some might assume closes this are unrelated) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:89-106` (skip), `:111` (`_motionFlags` read, unused past declaration) |
|
|
||||||
| D12 | Jump/falling/contact gates simplified — retail allows specific movement while falling/dead, has separate jump checks for posture/stamina/constraints/pending-motion/contact/leave-hit-ground reapplication; acdream blocks or simplifies several. (doc L221-225) | Not exhaustively re-verified line-by-line this session (large surface). Spot-checked `contact_allows_move`/`motion_allows_jump`-equivalent: the Fallen/Dead/Crouch block still lives inside `contact_allows_move` (see doc-1 item #2), which is itself evidence the gates remain simplified/misplaced relative to retail's `DoMotion`-level error codes. No `pending_motions`-driven jump eligibility check found (consistent with item #1). | **STILL-TRUE** (partially re-verified via the contact_allows_move overlap with doc-1 #2; full jump/posture/stamina gate audit not redone) | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (same evidence as doc-1 #2) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Doc 3: 2026-07-01-d6-motion-interp-pseudocode.md — cross-check of its own claims
|
|
||||||
|
|
||||||
| Claim | Current-code check | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| "D6 ships `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/`get_state_velocity` in `MotionInterpreter.cs`, unifying LOCAL player velocity+turn+jump+wire onto one `RawMotionState` at `forward_speed=1.0`" | Confirmed via git log (`0f099bb6` D6.2a, `d34721fa` D6.2b) and via direct code read — all four functions exist and are wired into `PlayerMovementController.cs` only. | **STILL-TRUE (as scoped)** |
|
|
||||||
| "Out of scope for D6: RawMotionState unification — retail uses ONE raw state for both the wire and the velocity pipeline; D6 keeps the L.2b wire construction separate... unifying is a later slice" (doc L153-156) | This scoping statement is itself accurate and still holds: the wire-side `RawMotionState`/`MoveToState.Build` path and the local velocity-side `apply_raw_movement` input are DIFFERENT `RawMotionState` construction sites (confirmed by design decision #1 in the doc itself, "ONE `RawMotionState`... is built from `MovementInput`" — but this unification was the D6.2 GOAL for local-only; there is still no unification with the REMOTE/inbound path at all). | **STILL-TRUE (scoping honored, and the doc's own "later slice" is exactly the gap this task's handoff doc targets)** |
|
|
||||||
| "Out of scope for D6: turn ... acdream keeps direct-Yaw turn is replaced" / actually turn WAS ported per design decision #3 ("Turn ported to interpreted, feel unchanged... omega.Z = ±(π/2) × turn_speed... the fixed TurnRateFor direct-Yaw is replaced by the pipeline") — LOCAL only. | For remotes, `RemoteMoveToDriver.TurnRateFor` is still the mechanism (per doc's own "AP-9 stays" note) — confirmed no remote-side `adjust_motion`-sourced turn omega found; `RemoteMoveToDriver.cs` (340 lines) still exists with its own turn-rate logic, separate from `MotionInterpreter`. | **STILL-TRUE (remote turn is still the old direct-rate approximation; local turn was ported per D6 but that's a different code path)** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Synthesis: what this means for the walk<->run lag + stop-slide symptom (task focus a/b)
|
|
||||||
|
|
||||||
1. **D6 (the shipped port) does not touch the remote/inbound path at all.** Every function it
|
|
||||||
ported (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, `get_state_velocity`)
|
|
||||||
is called exclusively from `PlayerMovementController.cs` (local player). Zero call sites
|
|
||||||
from `GameWindow.cs`'s remote-entity code (`OnLiveMotionUpdated`, `_remoteDeadReckon`,
|
|
||||||
`ApplyServerControlledVelocityCycle`) invoke these D6-ported functions.
|
|
||||||
2. **The remote path bulk-copies wire commands directly into `InterpretedState`**
|
|
||||||
(`GameWindow.cs:4590,4598`), bypassing `adjust_motion` — so backward/sidestep-left
|
|
||||||
normalization and run-rate scaling never happen for remotes the retail-faithful way.
|
|
||||||
This matches the retail `copy_movement_from` bulk-copy semantics for THAT ONE STEP, but the
|
|
||||||
surrounding `pending_motions`/queue/`move_to_interpreted_state` state machine around it
|
|
||||||
(doc-1 items #1, #9) is still entirely absent.
|
|
||||||
3. **A newly-identified (not named in either doc) mechanism likely explains "reacts too
|
|
||||||
slowly":** `ApplyPlayerLocomotionRefinement` (`GameWindow.cs:5178+`) only kicks in after a
|
|
||||||
`UmGraceSeconds = 0.2` (200ms) window since the last UM, and then applies **hysteresis**
|
|
||||||
bands (`PlayerRunPromoteSpeed = 5.5f`, `PlayerRunDemoteSpeed = 4.5f`) before re-bucketing
|
|
||||||
walk↔run from UpdatePosition-derived velocity — this is architecturally exactly the kind of
|
|
||||||
mechanism that would produce a visible lag on a walk↔run toggle, especially combined with
|
|
||||||
the code's own comment that retail may not broadcast a fresh MoveToState for HoldKey-only
|
|
||||||
toggles (Shift while W held), forcing reliance on this slower velocity-inference path.
|
|
||||||
4. **D9 (types 8/9 dropped)** means any server-driven turn command mid-locomotion is silently
|
|
||||||
turned into a `SetCycle(Ready)`, which would visibly interrupt/desync locomotion — a
|
|
||||||
plausible contributor to "compounding error... running around + turning."
|
|
||||||
5. **D11 (sequence numbers discarded)** means acdream has no way to detect/reject
|
|
||||||
out-of-order or duplicate `UpdateMotion` packets, which is a plausible contributor to the
|
|
||||||
"position errors once the entity stops" (a late/duplicate packet could re-apply a stale
|
|
||||||
state after a newer one, with no sequence check to catch it).
|
|
||||||
|
|
||||||
None of the above is a fix recommendation — this is a raw claim/verification map only, per
|
|
||||||
the report-only investigation mode.
|
|
||||||
|
|
@ -1,969 +0,0 @@
|
||||||
# Retail decomp map: INBOUND remote-entity motion pipeline
|
|
||||||
|
|
||||||
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (66 MB, Sept 2013
|
|
||||||
EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that
|
|
||||||
file (not addresses), from a Sept-2026 checkout at
|
|
||||||
`C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`.
|
|
||||||
Addresses (0x0051xxxx etc.) are also given per function for cross-reference with
|
|
||||||
`symbols.json` / Ghidra.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q1 — INBOUND ENTRY: wire message -> motion interpreter
|
|
||||||
|
|
||||||
Full call chain, outermost (network) to innermost (motion-table state machine):
|
|
||||||
|
|
||||||
```
|
|
||||||
ACSmartBox::DispatchSmartBoxEvent(NetBlob*) line 357117 (0x005595d0)
|
|
||||||
switch (opcode) {
|
|
||||||
case 0xf619: // "Movement" — the live/current movement update
|
|
||||||
SmartBox::UnpackPositionEvent(...) line 357142
|
|
||||||
if result == NETBLOB_PROCESSED_OK:
|
|
||||||
CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid)
|
|
||||||
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357154 (call), def @271370 (0x00509690)
|
|
||||||
if nonzero: cmdinterp->LoseControlToServer()
|
|
||||||
|
|
||||||
case 0xf74c: // position+movement combo, has an extra u16 seq check first
|
|
||||||
is_newer(obj->update_times[8], seq) gate line 357224
|
|
||||||
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357232
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`CPhysics::SetObjectMovement` (2-arg overload used for 0xf619/0xf74c dispatch,
|
|
||||||
`__stdcall`, line 271370 / addr 0x00509690):
|
|
||||||
```
|
|
||||||
int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen,
|
|
||||||
u16 seqA, u16 seqB, bool isAutonomous)
|
|
||||||
{
|
|
||||||
isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer();
|
|
||||||
|
|
||||||
// 16-bit wraparound-aware "is this sequence number newer" compare,
|
|
||||||
// done TWICE against two independent counters obj->update_times[1]
|
|
||||||
// and obj->update_times[5]:
|
|
||||||
diff = |seqA - obj->update_times[1]| (mod 0x10000)
|
|
||||||
newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA) // wraparound rule
|
|
||||||
if not newer: return 0 // STALE PACKET, DROPPED
|
|
||||||
|
|
||||||
obj->update_times[1] = seqA
|
|
||||||
diff2 = |obj->update_times[5] - seqB|
|
|
||||||
newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old)
|
|
||||||
if not newer2: return 0 // STALE, DROPPED
|
|
||||||
obj->update_times[5] = seqB
|
|
||||||
|
|
||||||
if (!isAutonomous || !isPlayer) { // remote entity ALWAYS
|
|
||||||
// takes this branch;
|
|
||||||
// local player only
|
|
||||||
// takes it when NOT
|
|
||||||
// self-driving (server
|
|
||||||
// override / rubber-band)
|
|
||||||
obj->last_move_was_autonomous = isAutonomous
|
|
||||||
CPhysicsObj::unpack_movement(obj, &buf, bufLen) line 271423 (0x00509742)
|
|
||||||
if isPlayer: return 1 // signals caller to call LoseControlToServer
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`CPhysicsObj::unpack_movement` (line 280179, addr 0x00512040):
|
|
||||||
```
|
|
||||||
void unpack_movement(CPhysicsObj* this, buf**, bufLen)
|
|
||||||
{
|
|
||||||
if (this->movement_manager == null)
|
|
||||||
this->movement_manager = MovementManager::Create(this, this->weenie_obj)
|
|
||||||
MovementManager::unpack_movement(this->movement_manager, buf, bufLen) line 280202
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`MovementManager::unpack_movement` (line 300563, addr 0x00524440) — deserializes
|
|
||||||
the wire struct and dispatches to ONE of 10 sub-cases (`command_ids[ecx_4]` type
|
|
||||||
tag read from the first u16 in the buffer, case 0..9 via jump table @300707):
|
|
||||||
|
|
||||||
```
|
|
||||||
switch (type_tag) {
|
|
||||||
case 0: // InterpretedMotionState (RawMotionState command wrapper) -- THIS
|
|
||||||
// is the walk/run/turn/sidestep command path used for remote
|
|
||||||
// players AND monsters
|
|
||||||
InterpretedMotionState::UnPack(&ims, buf, bufLen) line 300606
|
|
||||||
// optional trailing u32 = "sticky" target object id
|
|
||||||
MovementManager::move_to_interpreted_state(this, &ims) line 300618 (0x0052457c)
|
|
||||||
if sticky_id != 0: CPhysicsObj::stick_to_object(...)
|
|
||||||
motion_interpreter->standing_longjump = (type_tag & 0x200)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
case 6: // MoveToObject
|
|
||||||
Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject)
|
|
||||||
motion_interpreter->my_run_rate = <wire float>
|
|
||||||
CPhysicsObj::MoveToObject(physics_obj, target_guid, ¶ms) line 300644
|
|
||||||
|
|
||||||
case 7: // MoveToPosition
|
|
||||||
similar; CPhysicsObj->my_run_rate set from wire; then
|
|
||||||
MoveToManager::MoveToPosition(...) line 300659
|
|
||||||
|
|
||||||
case 8: // TurnToObject
|
|
||||||
case 9: // TurnToHeading
|
|
||||||
-> MoveToManager::TurnToHeading / handled via MoveToManager
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0
|
|
||||||
(`InterpretedMotionState::UnPack` + `move_to_interpreted_state`) is the relevant
|
|
||||||
path.** This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape
|
|
||||||
as the client's own `RawMotionState`/interp state, containing
|
|
||||||
`current_style`, `forward_command`, `forward_speed`, `sidestep_command/speed`,
|
|
||||||
`turn_command/speed`, plus a list of pending server "actions"
|
|
||||||
(`context_id`/`action_stamp` pairs used for jump-charge/attack acknowledgement,
|
|
||||||
NOT used for normal walk/run).
|
|
||||||
|
|
||||||
`MovementManager::move_to_interpreted_state` (line 300259, addr 0x00524170):
|
|
||||||
```
|
|
||||||
void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims)
|
|
||||||
{
|
|
||||||
if (motion_interpreter == null) {
|
|
||||||
motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj)
|
|
||||||
CMotionInterp::enter_default_state(motion_interpreter)
|
|
||||||
}
|
|
||||||
CMotionInterp::move_to_interpreted_state(motion_interpreter, ims) line 300272
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`CMotionInterp::move_to_interpreted_state` (line 305936, addr 0x005289c0) — THE
|
|
||||||
entry point that turns a wire InterpretedMotionState into an actual motion-table
|
|
||||||
transition:
|
|
||||||
```
|
|
||||||
int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims)
|
|
||||||
{
|
|
||||||
if (physics_obj == null) return 0
|
|
||||||
this->raw_state.current_style = ims->current_style
|
|
||||||
CPhysicsObj::interrupt_current_movement(physics_obj)
|
|
||||||
bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command)
|
|
||||||
InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims) // <-- OVERWRITES
|
|
||||||
// forward/side/turn
|
|
||||||
// command+speed wholesale,
|
|
||||||
// line 293301
|
|
||||||
CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...)) line 305949
|
|
||||||
|
|
||||||
// then replay any queued server "actions" (jump charge etc.) whose
|
|
||||||
// action_stamp is newer than server_action_stamp — sequence-wraparound
|
|
||||||
// compare identical in shape to the SetObjectMovement 0x7fff test above
|
|
||||||
for (action in ims->actions) {
|
|
||||||
if (newer(action.stamp, this->server_action_stamp)) {
|
|
||||||
this->server_action_stamp = action.stamp
|
|
||||||
CMotionInterp::DoInterpretedMotion(this, action.motion, ¶ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**KEY: `copy_movement_from` is a flat field-by-field OVERWRITE of the
|
|
||||||
InterpretedMotionState (forward_command, forward_speed, sidestep_*, turn_*,
|
|
||||||
current_style) — there is no "diff the old vs new command" step here.** The
|
|
||||||
actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN,
|
|
||||||
inside `CMotionTable::GetObjectSequence`, when `apply_current_movement` ->
|
|
||||||
`apply_interpreted_movement` -> `DoInterpretedMotion` is called with the new
|
|
||||||
`forward_command`/`forward_speed`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q2 — TRANSITION: walk<->run while already moving
|
|
||||||
|
|
||||||
`CMotionInterp::apply_current_movement` (line 305838, addr 0x00528870):
|
|
||||||
```
|
|
||||||
void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag)
|
|
||||||
{
|
|
||||||
if (physics_obj == null || !initted) return
|
|
||||||
isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer()
|
|
||||||
if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj))
|
|
||||||
return apply_raw_movement(this, forceFlag, jumpFlag) // LOCAL player path
|
|
||||||
return apply_interpreted_movement(this, forceFlag, jumpFlag) // REMOTE entity path
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`movement_is_autonomous` just returns `physics_obj->last_move_was_autonomous`
|
|
||||||
(set by `SetObjectMovement` above — for a genuinely-remote object this flag is
|
|
||||||
always false relative to the LOCAL viewer, so remote players/monsters always
|
|
||||||
take the `apply_interpreted_movement` branch.)
|
|
||||||
|
|
||||||
`CMotionInterp::apply_interpreted_movement` (line 305713, addr 0x00528600) — the
|
|
||||||
per-command dispatcher that turns the bookkeeping InterpretedMotionState fields
|
|
||||||
back into individual `DoInterpretedMotion` calls:
|
|
||||||
```
|
|
||||||
void apply_interpreted_movement(CMotionInterp* this, int a, int b)
|
|
||||||
{
|
|
||||||
if physics_obj == null: return
|
|
||||||
if interpreted_state.forward_command == RUN_FORWARD (0x44000007):
|
|
||||||
this->my_run_rate = interpreted_state.forward_speed // caches server-echoed run rate
|
|
||||||
|
|
||||||
DoInterpretedMotion(this, interpreted_state.current_style, {}) // style (stance) first
|
|
||||||
|
|
||||||
if (!contact_allows_move(this, interpreted_state.forward_command)) {
|
|
||||||
DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {})
|
|
||||||
} else if (standing_longjump) {
|
|
||||||
DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {})
|
|
||||||
StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {})
|
|
||||||
} else {
|
|
||||||
DoInterpretedMotion(this, interpreted_state.forward_command, {}) // <-- WALK/RUN COMMAND
|
|
||||||
if interpreted_state.sidestep_command == 0:
|
|
||||||
StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {})
|
|
||||||
else:
|
|
||||||
DoInterpretedMotion(this, interpreted_state.sidestep_command, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
if interpreted_state.turn_command != 0:
|
|
||||||
DoInterpretedMotion(this, interpreted_state.turn_command, {})
|
|
||||||
return // early return — no idle-stop check runs this frame
|
|
||||||
if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0)
|
|
||||||
add_to_queue(this, ctx=0, READY_STANCE, tickCount)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
So a wire "run instead of walk" update decays into exactly one
|
|
||||||
`CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed})`
|
|
||||||
call (or `WALK_FORWARD`) — a single call with the SAME semantics as the local
|
|
||||||
input path, not a special "speed-changed" fast path at this layer.
|
|
||||||
|
|
||||||
`CMotionInterp::DoInterpretedMotion` (line 305575, addr 0x00528360):
|
|
||||||
```
|
|
||||||
uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p)
|
|
||||||
{
|
|
||||||
if physics_obj == null: return 8
|
|
||||||
if (contact_allows_move(this, motion)) {
|
|
||||||
if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to
|
|
||||||
ApplyMotion-only path)
|
|
||||||
if motion == 0x40000011 /* some "cancel" motion */:
|
|
||||||
CPhysicsObj::RemoveLinkAnimations(physics_obj) // <-- flush queued link anims
|
|
||||||
result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p) // -> CPartArray -> MotionTableManager
|
|
||||||
if result == 0:
|
|
||||||
jumpAllowed = ...
|
|
||||||
add_to_queue(this, p->context_id, motion, jumpAllowed)
|
|
||||||
if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p)
|
|
||||||
} else if (motion & 0x10000000) == 0:
|
|
||||||
label_528440:
|
|
||||||
if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...)
|
|
||||||
result = 0
|
|
||||||
else:
|
|
||||||
result = 0x24 // motion rejected (e.g. mid-air command not allowed)
|
|
||||||
|
|
||||||
if (physics_obj != null && physics_obj->cell == 0)
|
|
||||||
CPhysicsObj::RemoveLinkAnimations(physics_obj) // detached-from-world guard
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`CPhysicsObj::DoInterpretedMotion` -> `CPartArray::DoInterpretedMotion` -> packs
|
|
||||||
a `MovementStruct{type=InterpretedCommand}` and calls
|
|
||||||
`MotionTableManager::PerformMovement`, which for `InterpretedCommand` calls:
|
|
||||||
|
|
||||||
```
|
|
||||||
if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks))
|
|
||||||
MotionTableManager::add_to_queue(this, motion, outTicks, sequence) // queues in
|
|
||||||
// pending_animations
|
|
||||||
// (DIFFERENT list
|
|
||||||
// from CMotionInterp's
|
|
||||||
// pending_motions!)
|
|
||||||
```
|
|
||||||
`CMotionTable::DoObjectMotion` is a thin wrapper for
|
|
||||||
`CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0)`.
|
|
||||||
|
|
||||||
### `CMotionTable::GetObjectSequence` — THE cycle-swap decision (line 298636, addr 0x00522860)
|
|
||||||
|
|
||||||
This is the true state machine that decides append-vs-replace-vs-fast-path. Given
|
|
||||||
`new_substate = motion & 0xffffff` bucketed by which high bit is set on `motion`:
|
|
||||||
|
|
||||||
**Bit `0x40000000` set — a normal "cycle" motion (this is what WALK/RUN commands
|
|
||||||
carry, e.g. `0x44000007` RunForward, `0x45000005` WalkForward):**
|
|
||||||
```
|
|
||||||
cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff))
|
|
||||||
if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state):
|
|
||||||
|
|
||||||
// *** THE SAME-CYCLE FAST PATH ***
|
|
||||||
if (new_substate == state->substate // SAME logical
|
|
||||||
&& same_sign(new_speed, state->substate_mod) // command (walk OR run
|
|
||||||
&& CSequence::has_anims(sequence)) { // stays walk, or run stays
|
|
||||||
// run — direction unchanged)
|
|
||||||
// AND a cycle is already
|
|
||||||
// playing
|
|
||||||
change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed) // rescale playback rate
|
|
||||||
subtract_motion(sequence, cycleData, state->substate_mod) // remove OLD velocity contribution
|
|
||||||
combine_motion(sequence, cycleData, new_speed) // add NEW velocity contribution
|
|
||||||
state->substate_mod = new_speed
|
|
||||||
return 1 // <-- NO new CSequence nodes appended. Same AnimSequenceNode
|
|
||||||
// keeps playing; only its playback-rate + the CSequence's
|
|
||||||
// cached velocity/omega vectors change.
|
|
||||||
}
|
|
||||||
|
|
||||||
// *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT
|
|
||||||
// substate id, not same-sign-same-substate) — LINK TRANSITION PATH ***
|
|
||||||
linkAnim = CMotionTable::get_link(table, state->style, state->substate,
|
|
||||||
state->substate_mod, new_substate, new_speed)
|
|
||||||
if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) {
|
|
||||||
// no direct link authored, OR direction reversed: route through the
|
|
||||||
// style's registered "default"/rest substate as an intermediate hop
|
|
||||||
defaultSubstate = style_defaults[state->style]
|
|
||||||
linkAnim = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0)
|
|
||||||
linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed)
|
|
||||||
}
|
|
||||||
|
|
||||||
CSequence::clear_physics(sequence) // zero cached velocity/omega — see Q6/Q7
|
|
||||||
CSequence::remove_cyclic_anims(sequence) // drop any still-looping cycle node(s)
|
|
||||||
add_motion(sequence, linkAnim, 1.0-or-substate_mod) // append the transition ("link") anim node(s)
|
|
||||||
add_motion(sequence, linkAnim2, new_speed) // (if double-hop via default state)
|
|
||||||
add_motion(sequence, cycleData, new_speed) // append the NEW cyclic anim, marked cyclic
|
|
||||||
state->substate = new_substate
|
|
||||||
state->substate_mod = new_speed
|
|
||||||
CMotionTable::re_modify(table, sequence, state) // re-apply any active modifiers (e.g. sidestep)
|
|
||||||
// on top of the new chain
|
|
||||||
*outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1
|
|
||||||
return 1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Answering the prompt's explicit sub-question (a):** DoInterpretedMotion on a
|
|
||||||
speed change does **NOT always reuse the same cycle**. It depends on whether the
|
|
||||||
new command maps to the SAME `substate` id as the currently-playing one:
|
|
||||||
- Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 ->
|
|
||||||
WalkForward speed 1.0, or RunForward at any two different `forward_speed`
|
|
||||||
values) hits the **fast path**: `change_cycle_speed` + `subtract_motion`/
|
|
||||||
`combine_motion` — same `AnimSequenceNode` object, just re-timed and
|
|
||||||
re-weighted. No new node, no restart.
|
|
||||||
- **Walk<->Run is a substate CHANGE (`0x45000005` WalkForward vs
|
|
||||||
`0x44000007` RunForward are different substate ids)**, so it does NOT hit
|
|
||||||
the fast path. It goes through the **link-transition path**: `get_link`
|
|
||||||
looks up an authored transition animation (a short blend clip, e.g.
|
|
||||||
walk-to-run or run-to-walk) between the two substates; that link node(s)
|
|
||||||
are appended to the sequence via `add_motion`, followed by the new cyclic
|
|
||||||
node. The OLD cyclic node is dropped (`remove_cyclic_anims`). Playback then
|
|
||||||
proceeds: link anim plays first (non-cyclic, finite frames), and once it
|
|
||||||
completes the `CSequence::update_internal` advance mechanism moves
|
|
||||||
`curr_anim` forward in the `anim_list` to the next node — the new cyclic
|
|
||||||
walk/run anim — automatically (see Q4).
|
|
||||||
|
|
||||||
**Sub-question (b): the CSequence node list.** `CSequence::anim_list` is a
|
|
||||||
doubly-linked list (`DLListBase` of `AnimSequenceNode`), NOT a single
|
|
||||||
"QueuedAnimations" array. `add_motion` -> `CSequence::append_animation` (line
|
|
||||||
301777) creates one new `AnimSequenceNode` per `MotionData::anims[i]` entry and
|
|
||||||
`DLListBase::InsertAfter`s it at the tail. `this->first_cyclic` marks where the
|
|
||||||
cyclic (looping) portion of the list begins; `remove_cyclic_anims` trims
|
|
||||||
everything from `first_cyclic` onward when a new transition starts (so
|
|
||||||
`clear_physics` + `remove_cyclic_anims` together mean: "keep any link anim
|
|
||||||
that's mid-playback [it's before first_cyclic], but throw away the old loop").
|
|
||||||
`curr_anim` points at the node currently being played; `CSequence::update`
|
|
||||||
advances `frame_number` within `curr_anim` and, in `apricot()`, walks
|
|
||||||
`curr_anim` forward through the list once frames are exhausted for a node,
|
|
||||||
discarding fully-consumed non-cyclic nodes from the front of the list up to
|
|
||||||
`first_cyclic`.
|
|
||||||
|
|
||||||
**Sub-question: is there blending?** No cross-fade/blend in the graphics sense.
|
|
||||||
It's sequential: `link_anim -> cyclic_anim`, back-to-back play, and the
|
|
||||||
crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this
|
|
||||||
codebase means the `CSequence.velocity`/`omega` accumulators (float vectors)
|
|
||||||
are algebraically combined (`combine_motion`/`subtract_motion`/`add_motion`
|
|
||||||
add or subtract scaled contributions) — that's a physics-level blend of
|
|
||||||
velocity, not a skeletal pose blend.
|
|
||||||
|
|
||||||
**Sub-question: is there an immediate speed change?** Only in the same-substate
|
|
||||||
fast path (`change_cycle_speed`+`subtract_motion`+`combine_motion` all happen
|
|
||||||
synchronously inside `GetObjectSequence`, i.e., on the SAME frame the wire
|
|
||||||
message is processed — no interpolation of speed itself). For walk<->run
|
|
||||||
(different substate), the VISIBLE speed change is gated behind the link anim's
|
|
||||||
playback duration — velocity is whatever `CSequence.velocity` currently holds
|
|
||||||
(the link anim's own authored velocity/omega, added via `add_motion`), and only
|
|
||||||
once the cyclic node becomes current does the full run/walk cyclic velocity
|
|
||||||
apply.
|
|
||||||
|
|
||||||
### `same_sign` (line 298253, addr 0x00522260) — verbatim
|
|
||||||
```
|
|
||||||
int same_sign(float a, float b) {
|
|
||||||
// true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
|
|
||||||
// this is the "is direction unchanged" test used to gate the same-cycle
|
|
||||||
// fast path and to decide whether get_link needs a sign-aware lookup.
|
|
||||||
return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `change_cycle_speed` (line 298276, addr 0x00522290) — verbatim constant
|
|
||||||
```
|
|
||||||
void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
|
|
||||||
if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002
|
|
||||||
CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate
|
|
||||||
else if (fabs(newSpeed) >= 0.000199999995f)
|
|
||||||
CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0)
|
|
||||||
// else: both ~0, no-op
|
|
||||||
}
|
|
||||||
```
|
|
||||||
This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied
|
|
||||||
to every node from `sequence->first_cyclic` onward
|
|
||||||
(`AnimSequenceNode::multiply_framerate`, line 302425) — so a walk<->walk speed
|
|
||||||
change (same substate) scales animation playback speed proportionally to the
|
|
||||||
commanded speed ratio, and ALSO swaps `low_frame`/`high_frame` if the new
|
|
||||||
multiplier is negative (playing the cycle backward).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle
|
|
||||||
|
|
||||||
**There are TWO distinct pending-queues, easy to conflate:**
|
|
||||||
|
|
||||||
1. **`CMotionInterp::pending_motions`** (singly-linked `LListData`, fields:
|
|
||||||
`[next, context_id, motion, jumpAllowedFlag]`). Owner: `CMotionInterp`.
|
|
||||||
Appended by `CMotionInterp::add_to_queue` (line 305032, addr 0x00527b80) —
|
|
||||||
called from `DoInterpretedMotion` (line 305607), `StopInterpretedMotion`
|
|
||||||
(line 305657), `apply_interpreted_movement`'s idle-stop path (line 305775),
|
|
||||||
and `StopCompletely` (line 305227). Popped ONLY by
|
|
||||||
`CMotionInterp::MotionDone` (line 305238, addr 0x00527ec0):
|
|
||||||
```
|
|
||||||
void MotionDone(CMotionInterp* this, int arg2) {
|
|
||||||
if (physics_obj == null) return
|
|
||||||
head = pending_motions.head_
|
|
||||||
if (head != null) {
|
|
||||||
if (head->motion & 0x10000000) { // this queued motion carried
|
|
||||||
// a server "action" (jump
|
|
||||||
// charge etc.)
|
|
||||||
CPhysicsObj::unstick_from_object(physics_obj)
|
|
||||||
InterpretedMotionState::RemoveAction(&interpreted_state)
|
|
||||||
RawMotionState::RemoveAction(&raw_state)
|
|
||||||
}
|
|
||||||
pop head off pending_motions (delete node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`CMotionInterp::motions_pending()` (line 305322) == `pending_motions.head_ != null`.
|
|
||||||
**`context_id`/`action_stamp` on this queue is used for jump-charge and
|
|
||||||
other server-acknowledged "actions", NOT for ordinary walk/run — ordinary
|
|
||||||
`DoInterpretedMotion` calls still push a node here (so `motions_pending()`
|
|
||||||
reflects "any interpreted motion is mid-flight"), but nothing about
|
|
||||||
walk<->run reads the `context_id`/action semantics.**
|
|
||||||
|
|
||||||
2. **`MotionTableManager::pending_animations`** (doubly-linked `DLListBase`,
|
|
||||||
fields: `[motion_id, tickCount]`), plus `MotionTableManager::animation_counter`
|
|
||||||
(running decrement counter). Owner: `MotionTableManager` (one per
|
|
||||||
`CPartArray`, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame
|
|
||||||
-level completion tracker, distinct from #1's motion-command-level tracker).
|
|
||||||
Appended by `MotionTableManager::add_to_queue` (line 290854, addr
|
|
||||||
0x0051bfe0) every time `GetObjectSequence` succeeds, storing the `outTicks`
|
|
||||||
value it returned (how many more discrete animation "steps"/frames worth of
|
|
||||||
non-cyclic content remain before this motion is fully consumed). Also
|
|
||||||
immediately calls `remove_redundant_links` (line 290771) to prune
|
|
||||||
already-queued-but-superseded link-transition entries (see Q2 note on
|
|
||||||
walk<->run spam).
|
|
||||||
|
|
||||||
**Consumption / popping — TWO drivers:**
|
|
||||||
- **Per-tick poll:** `MotionTableManager::CheckForCompletedMotions` (line
|
|
||||||
290645, addr 0x0051be00), called every physics tick via
|
|
||||||
`CPartArray::HandleMovement` -> `MotionTableManager::UseTime` (alias for
|
|
||||||
`CheckForCompletedMotions`, line 290845) from
|
|
||||||
`CPhysicsObj::UpdateObjectInternal` (line 283748). Walks
|
|
||||||
`pending_animations` from the head while `tickCount == 0`, firing
|
|
||||||
`CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)` for each and
|
|
||||||
removing action-heads (`MotionState::remove_action_head`) if the
|
|
||||||
`0x10000000` bit is set.
|
|
||||||
- **Anim-hook driven:** `CPhysicsObj::Hook_AnimDone` (line 277845, addr
|
|
||||||
0x0050fda0) — registered as a `CAnimHook` fired by
|
|
||||||
`CSequence::execute_hooks` (line 300780) when a specific animation FRAME
|
|
||||||
carries a hook whose `direction_` matches playback direction. Calls
|
|
||||||
`CPartArray::AnimationDone(1)` -> `MotionTableManager::AnimationDone(1)`
|
|
||||||
(line 290558, addr 0x0051bce0), which increments `animation_counter` and
|
|
||||||
pops every `pending_animations` entry whose `tickCount <= animation_counter`
|
|
||||||
(decrementing the counter by each popped entry's `tickCount`, i.e. a
|
|
||||||
running-total consumption model, not a strict per-frame countdown).
|
|
||||||
- **Synchronous, post-dispatch:** `CMotionInterp::PerformMovement` (line
|
|
||||||
306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw
|
|
||||||
input path (`MovementManager::PerformMovement` cases 0-4) — calls
|
|
||||||
`CPhysicsObj::CheckForCompletedMotions` immediately after every
|
|
||||||
`DoMotion`/`DoInterpretedMotion`/`StopMotion`/`StopInterpretedMotion`/
|
|
||||||
`StopCompletely` dispatch (line 306234/241/248/255/262), so a
|
|
||||||
zero-duration motion completes in the SAME frame it was issued rather
|
|
||||||
than waiting for the next tick. This path is NOT used by the wire/remote
|
|
||||||
entry (`CMotionInterp::apply_interpreted_movement` calls
|
|
||||||
`DoInterpretedMotion`/`StopInterpretedMotion` directly without a following
|
|
||||||
`CheckForCompletedMotions` — the remote entity therefore only gets its
|
|
||||||
completions serviced by the per-tick poll and the anim-hook path, not the
|
|
||||||
synchronous one).
|
|
||||||
|
|
||||||
`CPhysicsObj::MotionDone(physics_obj, motion_id, arg3)` (line 277856, addr
|
|
||||||
0x0050fdb0) -> `MovementManager::MotionDone` (line 300396) ->
|
|
||||||
`CMotionInterp::MotionDone` (line 305238, described in #1 above). **This is
|
|
||||||
the bridge between the two queues**: a `MotionTableManager`-level
|
|
||||||
animation-frame completion cascades UP into popping the
|
|
||||||
`CMotionInterp`-level command queue.
|
|
||||||
|
|
||||||
**Callback / state change on completion:** popping `pending_motions` only (a)
|
|
||||||
optionally clears the "stuck to object" state + removes a pending server action
|
|
||||||
if the popped node had the `0x10000000` "carries an action" bit, and (b) frees
|
|
||||||
the node. It does NOT itself touch velocity, `substate`, or the `CSequence`
|
|
||||||
node list — those were already mutated synchronously back when
|
|
||||||
`GetObjectSequence` ran (at command-ISSUE time, not command-COMPLETE time).
|
|
||||||
**The animation-frame-level completion (`AnimationDone`/`CheckForCompletedMotions`)
|
|
||||||
is what actually matters for gameplay feel: it's what lets a queued
|
|
||||||
non-cyclic link anim naturally hand off to the next queued node (see Q4) and
|
|
||||||
what lets a "jump" or other single-shot server action be acknowledged as
|
|
||||||
finished.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q4 — CYCLE SWAP: frame index carryover vs restart vs link
|
|
||||||
|
|
||||||
Per `CSequence::append_animation` (line 301777, addr 0x00525510):
|
|
||||||
```
|
|
||||||
void append_animation(CSequence* this, AnimData* animData) {
|
|
||||||
node = new AnimSequenceNode(animData)
|
|
||||||
if (!node->has_anim()) { delete node immediately; return } // degenerate/empty motion, skip
|
|
||||||
DLListBase::InsertAfter(&anim_list, node, anim_list.tail_) // always appended at TAIL
|
|
||||||
this->first_cyclic = node // *** every appended node
|
|
||||||
// becomes the new
|
|
||||||
// first_cyclic marker
|
|
||||||
// until superseded ***
|
|
||||||
if (curr_anim == null) { // sequence was idle/empty
|
|
||||||
curr_anim = anim_list.head_
|
|
||||||
frame_number = curr_anim->get_starting_frame()
|
|
||||||
}
|
|
||||||
// if curr_anim was already non-null (something mid-playback), it is
|
|
||||||
// left untouched — the newly appended node just waits at the tail.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
So **appending never resets `frame_number` for whatever's currently playing.**
|
|
||||||
The frame index of the CURRENTLY playing node (the link anim, or the old cycle
|
|
||||||
if it's still `curr_anim`) is untouched.
|
|
||||||
|
|
||||||
`CSequence::update` (line 302402, addr 0x00525b80):
|
|
||||||
```
|
|
||||||
void update(CSequence* this, double dt, Frame* outDelta) {
|
|
||||||
if (anim_list.head_ != null) {
|
|
||||||
CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta)
|
|
||||||
CSequence::apricot(this) // list-trim housekeeping (below)
|
|
||||||
} else if (outDelta != null) {
|
|
||||||
CSequence::apply_physics(this, outDelta, dt, dt) // PURE velocity integration,
|
|
||||||
// no animation nodes at all
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`update_internal` (line 301839, addr 0x005255d0) is heavily x87-obfuscated in
|
|
||||||
this decompile (unresolvable float compares/branches show as raw
|
|
||||||
`/* unimplemented */` FPU op comments) — the BN decompiler could not fully
|
|
||||||
recover its control flow. What IS recoverable: it advances `frame_number`
|
|
||||||
within `curr_anim` by `dt * anim->framerate`-derived amount, and once a node's
|
|
||||||
frames are exhausted it walks `curr_anim` to `AnimSequenceNode::GetNext(...)`
|
|
||||||
(confirmed indirectly via `apricot`'s cleanup logic below and via
|
|
||||||
`CSequence::execute_hooks`/`multiply_cyclic_animation_fr` operating on
|
|
||||||
"`first_cyclic` onward" — i.e., cyclic nodes loop in place by wrapping
|
|
||||||
`frame_number`, non-cyclic/link nodes advance `curr_anim` to the next list
|
|
||||||
node when frames are exhausted).
|
|
||||||
|
|
||||||
`CSequence::apricot` (line 300978, addr 0x00524b40) — the list-trim called every
|
|
||||||
`update()`:
|
|
||||||
```
|
|
||||||
void apricot(CSequence* this) {
|
|
||||||
i = (anim_list.head_ != null) ? adjustedHead : null
|
|
||||||
if (i != curr_anim) {
|
|
||||||
while (i != first_cyclic) {
|
|
||||||
// unlink node i from anim_list (both directions), delete it,
|
|
||||||
// then advance i to the new head
|
|
||||||
... unlink + delete ...
|
|
||||||
i = new head
|
|
||||||
if (i == curr_anim) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
i.e., **once `curr_anim` has moved past the head of the list (a node finished
|
|
||||||
playing), `apricot` deletes every now-stale node from `anim_list.head_` up to
|
|
||||||
(but not including) `first_cyclic`.** This is standard "consume finished
|
|
||||||
one-shot link anims off the front of the queue" behavior.
|
|
||||||
|
|
||||||
**Direct answer:** the frame index does **NOT carry over between the OLD cycle
|
|
||||||
and the NEW cycle** — they are different `AnimSequenceNode` objects wrapping
|
|
||||||
different `CAnimation` data with independently-tracked start frames
|
|
||||||
(`AnimSequenceNode::get_starting_frame()`). What DOES carry over/continue
|
|
||||||
smoothly is:
|
|
||||||
- the **link animation plays out fully first** (its own authored frame range,
|
|
||||||
from `get_starting_frame()` to its end), because it was appended to the
|
|
||||||
tail and `curr_anim` only advances once the current node's frames are
|
|
||||||
exhausted (via `update_internal`'s internal advance, not `apricot`, which
|
|
||||||
is just cleanup).
|
|
||||||
- once the link anim's frames are exhausted, playback naturally proceeds to
|
|
||||||
the next node in `anim_list` (the newly appended cyclic walk/run node),
|
|
||||||
which starts fresh at ITS `get_starting_frame()`.
|
|
||||||
- So the transition literally IS the link animation: **walk -> run uses an
|
|
||||||
authored transition clip in between**; there is no cross-fade of the walk
|
|
||||||
cycle's frame position into the run cycle's frame position. The retail art
|
|
||||||
pipeline authors these link/transition clips specifically so this hard
|
|
||||||
swap looks continuous.
|
|
||||||
- If `get_link` found NO authored link for this style/substate pair (the
|
|
||||||
`linkAnim == null` branch in `GetObjectSequence`), the code instead hops
|
|
||||||
through the style's "default" (idle/ready) substate as an intermediate —
|
|
||||||
two link anims chained — rather than doing a raw cut.
|
|
||||||
- `change_cycle_speed`'s `multiply_cyclic_animation_fr` (called ONLY on the
|
|
||||||
same-substate fast path) operates on `this->first_cyclic` onward, i.e., it
|
|
||||||
re-times whatever is the CURRENT cyclic node in place — it does not touch
|
|
||||||
frame_number's absolute position within that node, only its rate of
|
|
||||||
advance, so a walk-speed-change (not walk<->run) preserves the current
|
|
||||||
frame's phase, just plays faster/slower/backward from there.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q5 — POSITION DRIVE between inbound packets
|
|
||||||
|
|
||||||
Confirmed by tracing `CPhysicsObj::update_object` -> `UpdateObjectInternal` ->
|
|
||||||
`UpdatePositionInternal` -> `CPartArray::Update` -> `CSequence::update`:
|
|
||||||
|
|
||||||
```
|
|
||||||
CPhysicsObj::update_object(CPhysicsObj* this) line 283950, addr 0x00515d10
|
|
||||||
{
|
|
||||||
... skip if parented/no-cell/frozen ...
|
|
||||||
dt = Timer::cur_time - this->update_time
|
|
||||||
if dt < 0.000199999995f: return // EPSILON, same constant as change_cycle_speed
|
|
||||||
if dt < 2.0:
|
|
||||||
UpdatePositionInternal-chain for the whole dt in one call
|
|
||||||
else:
|
|
||||||
// clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0,
|
|
||||||
// then one final UpdateObjectInternal(remainder) call — prevents a
|
|
||||||
// huge single-frame teleport after e.g. a stall/loading hitch
|
|
||||||
while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 }
|
|
||||||
UpdateObjectInternal(this, remaining)
|
|
||||||
}
|
|
||||||
|
|
||||||
CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt) line 283611, addr 0x005156b0
|
|
||||||
{
|
|
||||||
... early-outs for ethereal/off-world states, still runs particle/script update ...
|
|
||||||
if (this->cell != 0) {
|
|
||||||
var deltaFrame = {identity}
|
|
||||||
UpdatePositionInternal(this, dt, &deltaFrame) // <-- computes the candidate move
|
|
||||||
if (has spheres / real collision geometry) {
|
|
||||||
if (deltaFrame == zero-delta) {
|
|
||||||
set_frame(this, &deltaFrame); cached_velocity = 0
|
|
||||||
} else {
|
|
||||||
heading update (velocity-derived or state-flag-derived)
|
|
||||||
CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0) // <-- FULL
|
|
||||||
// COLLISION
|
|
||||||
// SWEEP,
|
|
||||||
// same
|
|
||||||
// machinery
|
|
||||||
// as local
|
|
||||||
// player
|
|
||||||
// movement
|
|
||||||
if (result == null) {
|
|
||||||
set_frame(this, &deltaFrame) // blocked entirely -> stays put, but frame still applied??
|
|
||||||
// (this branch means find_valid_position failed to
|
|
||||||
// produce a transition object; effectively a no-collision
|
|
||||||
// passthrough for objects without real spheres)
|
|
||||||
cached_velocity = 0
|
|
||||||
} else {
|
|
||||||
cached_velocity = (result->sphere_path.curr_pos - m_position) / dt // ACTUAL POST-COLLISION
|
|
||||||
// velocity, NOT the
|
|
||||||
// raw commanded one
|
|
||||||
SetPositionInternal(this, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// no collision spheres on this part array: apply frame directly, no sweep
|
|
||||||
set_frame(this, &deltaFrame); cached_velocity = 0
|
|
||||||
}
|
|
||||||
DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement
|
|
||||||
(== MotionTableManager::UseTime
|
|
||||||
== CheckForCompletedMotions) /
|
|
||||||
PositionManager::UseTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta) line 280817, addr 0x00512c30
|
|
||||||
{
|
|
||||||
if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta) // <-- FILLS outDelta
|
|
||||||
// via CSequence::update
|
|
||||||
if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt)
|
|
||||||
// (server position-correction blend, see Q6)
|
|
||||||
Frame::combine(outDelta, &this->m_position.frame, outDelta) // outDelta = currentFrame (+) outDelta
|
|
||||||
if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta) // gravity/step physics on top
|
|
||||||
CPhysicsObj::process_hooks(this) // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position
|
|
||||||
}
|
|
||||||
|
|
||||||
CPartArray::Update(CPartArray* this, float dt, Frame* outDelta) line 285883, addr 0x00517db0
|
|
||||||
{
|
|
||||||
CSequence::update(&this->sequence, dt, outDelta) // exactly the branch described in Q4:
|
|
||||||
// animation-node-consumption path OR
|
|
||||||
// pure apply_physics(velocity*dt) fallback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Direct answer: BOTH, and they are the SAME code path, not two competing
|
|
||||||
sources.** `CSequence::update` chooses between:
|
|
||||||
(a) **animation-node consumption** (`update_internal`) when `anim_list` is
|
|
||||||
non-empty — this advances frames AND, per-node, the per-frame position
|
|
||||||
delta baked into the `AnimFrame` data (`get_pos_frame`/`get_part_frame`)
|
|
||||||
contributes to the produced `outDelta` Frame (the x87-obscured part of
|
|
||||||
`update_internal`, but its role is confirmed by `AnimSequenceNode::get_pos_frame`
|
|
||||||
/ `get_part_frame` existing specifically to fetch per-frame authored
|
|
||||||
pose+position data), and
|
|
||||||
(b) **`apply_physics`** (pure `outDelta.origin += dt * this->velocity;
|
|
||||||
outDelta.rotate(dt * this->omega)`) when `anim_list` is EMPTY (i.e. a
|
|
||||||
pure-interpreted-velocity idle/moving state with no queued transition
|
|
||||||
animations left) — this is the steady-state "walking/running in a
|
|
||||||
straight line between server packets" case for a LOOPING cyclic anim once
|
|
||||||
its own list bookkeeping considers it "done producing new nodes" — but
|
|
||||||
note `has_anims()` / `anim_list.head_ != null` is true whenever there's
|
|
||||||
ANY node (including the still-looping cyclic one), so in practice, for a
|
|
||||||
normal walk/run cycle, path (a) is what's active essentially always;
|
|
||||||
path (b) is the true-idle / "no motion data at all, just raw velocity"
|
|
||||||
fallback (e.g. after `StopCompletely` clears everything, or for
|
|
||||||
objects that were never given a motion table).
|
|
||||||
Either way, the output Frame delta is what feeds `Frame::combine` against the
|
|
||||||
CURRENT position, and the combined candidate then goes through the FULL
|
|
||||||
`CPhysicsObj::transition` collision sweep — remote entities are
|
|
||||||
collision-checked every tick exactly like the local player, they are not
|
|
||||||
simply "teleported" along a straight line. `cached_velocity` (used for e.g.
|
|
||||||
UI/physics queries, NOT for driving the next tick's move — the next tick
|
|
||||||
re-derives everything from `CSequence` state) is the ACTUAL post-collision
|
|
||||||
displacement/dt, which can differ from the commanded interpreted velocity if
|
|
||||||
a wall was hit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q6 — CORRECTION: reconciling inbound position updates
|
|
||||||
|
|
||||||
Two independent correction paths were located; both are called from
|
|
||||||
`UpdatePositionInternal`/its callers, gated by whether the wire message
|
|
||||||
carried a full `Position` update or just a motion-command update:
|
|
||||||
|
|
||||||
1. **`PositionManager::adjust_offset`** (called every tick from
|
|
||||||
`UpdatePositionInternal`, line 280857) — blends a stored "we're behind
|
|
||||||
where we should be" offset into the per-tick delta over time, i.e. a
|
|
||||||
position-manager-owned soft-correction/interpolation smoothing layer
|
|
||||||
(`PositionManager::UnStick`/`StopInterpolating`/`IsInterpolating`/
|
|
||||||
`IsFullyConstrained`/`GetStickyObjectID` are its other exposed operations —
|
|
||||||
all wrapped 1:1 through `CPhysicsObj::unstick_from_object`,
|
|
||||||
`StopInterpolating`, `IsInterpolating`, `IsFullyConstrained`,
|
|
||||||
`get_sticky_object_id`). The named-retail decompile does not expose
|
|
||||||
`PositionManager::adjust_offset`'s internal body in this file (its class
|
|
||||||
implementation lives outside the traced call chain reached in this pass);
|
|
||||||
what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates
|
|
||||||
the same `Frame* outDelta` that `CPartArray::Update`/`CSequence::update`
|
|
||||||
just wrote, i.e. it's a correction applied ON TOP OF the
|
|
||||||
animation/velocity-driven delta, before that delta is combined with
|
|
||||||
current position and swept for collision. This is the retail equivalent of
|
|
||||||
"dead-reckoning error absorbed gradually into the next frame's move" rather
|
|
||||||
than a hard position snap.
|
|
||||||
2. **Full snap path**: when `0xf74c`/`0xf619` carries not just a motion
|
|
||||||
command but also a fresh authoritative `Position` (the position+movement
|
|
||||||
combo case, or `MoveToObject`/`MoveToPosition` in `unpack_movement`'s cases
|
|
||||||
6/7), the code calls `CPhysicsObj::SetPositionInternal` (line 283892, addr
|
|
||||||
0x00515bd0) via the `MoveToManager`/`CPhysicsObj::MoveToObject`/
|
|
||||||
`SetScatterPositionInternal` machinery — this is a direct authoritative
|
|
||||||
`Position` set (through `AdjustPosition` + `CheckPositionInternal` +
|
|
||||||
`handle_all_collisions`), i.e. a hard reposition/snap when the server
|
|
||||||
sends a full position rather than only a motion-state delta. `unpack_movement`
|
|
||||||
case 0 (the plain `InterpretedMotionState`, used for ordinary walk<->run)
|
|
||||||
does NOT carry a `Position` at all — it only ever updates the motion
|
|
||||||
command/speed and lets local dead-reckoning (`CSequence`-driven `update` +
|
|
||||||
collision sweep, per Q5) carry the position forward until the next
|
|
||||||
authoritative position or motion packet arrives. There is no visible
|
|
||||||
"snap-if-error-exceeds-threshold" constant found in the traced functions
|
|
||||||
in this pass — the correction is structurally continuous
|
|
||||||
(`adjust_offset` blended every tick) rather than threshold-triggered,
|
|
||||||
based on what's directly observable in this file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q7 — STOP: motion -> ready/stand
|
|
||||||
|
|
||||||
Stopping is **not special-cased outside the normal `GetObjectSequence`
|
|
||||||
machinery** — it is routed through the exact same link-transition logic as any
|
|
||||||
other substate change, targeting the style's registered idle/rest substate.
|
|
||||||
|
|
||||||
Entry points, both eventually reaching `CMotionTable::StopSequenceMotion`
|
|
||||||
(line 298954, addr 0x00522fc0):
|
|
||||||
|
|
||||||
```
|
|
||||||
CMotionInterp::StopInterpretedMotion(this, motion, params) line 305635, addr 0x00528470
|
|
||||||
-> if contact_allows_move fails OR standing_longjump-with-jump-motion:
|
|
||||||
just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no
|
|
||||||
physical stop is even attempted (e.g. can't "stop turning" mid-air the same way)
|
|
||||||
-> else:
|
|
||||||
CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params)
|
|
||||||
-> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand)
|
|
||||||
-> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks)
|
|
||||||
-> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks)
|
|
||||||
if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result)
|
|
||||||
InterpretedMotionState::RemoveMotion(&interpreted_state, motion) // clears forward_command
|
|
||||||
// back to 0x41000003 READY
|
|
||||||
```
|
|
||||||
|
|
||||||
`CMotionTable::StopSequenceMotion` (line 298954, addr 0x00522fc0):
|
|
||||||
```
|
|
||||||
int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) {
|
|
||||||
*outTicks = 0
|
|
||||||
if ((motion & 0x40000000) != 0 && motion == state->substate) {
|
|
||||||
// stopping the MAIN cycle (forward walk/run, not a modifier like
|
|
||||||
// sidestep): look up the style's default (idle/ready) substate and
|
|
||||||
// re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition
|
|
||||||
// to idle", full link-anim machinery applies (Q2/Q4)
|
|
||||||
defaultSubstate = style_defaults[state->style]
|
|
||||||
return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1)
|
|
||||||
}
|
|
||||||
if ((motion & 0x20000000) != 0) {
|
|
||||||
// stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of
|
|
||||||
// the base cycle rather than replacing it): find the modifier's
|
|
||||||
// MotionData and directly SUBTRACT its velocity/omega contribution
|
|
||||||
for (m in state->modifier_head-list) {
|
|
||||||
if (m.motion == motion) {
|
|
||||||
modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion)
|
|
||||||
if (modData != null) {
|
|
||||||
subtract_motion(seq, modData, m.speed_mod) // <-- direct velocity/omega
|
|
||||||
// subtraction, NO link anim,
|
|
||||||
// NO node changes — this IS
|
|
||||||
// how e.g. releasing sidestep
|
|
||||||
// while still running removes
|
|
||||||
// just the sideways component
|
|
||||||
MotionState::remove_modifier(state, m, prev)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Velocity zeroing:** happens in TWO places depending on stop type:
|
|
||||||
- Main-cycle stop (walk/run -> ready): via `GetObjectSequence`'s
|
|
||||||
link-transition branch, which unconditionally calls
|
|
||||||
`CSequence::clear_physics(sequence)` BEFORE appending the new link+cycle
|
|
||||||
chain — `clear_physics` (line 301194, addr 0x00524d50) zeroes
|
|
||||||
`sequence->velocity` and `sequence->omega` outright, then the new link
|
|
||||||
anim's OWN authored velocity/omega (if any) is added back via
|
|
||||||
`add_motion`. So there IS a hard zero, immediately followed by
|
|
||||||
re-population from the transition-to-idle clip's own baked
|
|
||||||
velocity/omega (typically ~0 for a stand/ready clip, hence "stop").
|
|
||||||
- Modifier stop (sidestep/turn release): `subtract_motion` directly removes
|
|
||||||
exactly that modifier's contribution (scaled by its `speed_mod`) from the
|
|
||||||
still-nonzero base-cycle velocity — no full zero, no `clear_physics` call,
|
|
||||||
because the base cycle (e.g. still running) keeps its own velocity intact.
|
|
||||||
|
|
||||||
**Stop/link animation:** YES — the idle-entry is itself an authored `get_link`
|
|
||||||
transition clip from the current substate to the style's default substate,
|
|
||||||
exactly like any other substate-to-substate transition (Q2/Q4). There is no
|
|
||||||
"instant freeze frame"; retail plays a deceleration/stop clip.
|
|
||||||
|
|
||||||
**Residual-sliding prevention:** because `clear_physics` zeroes
|
|
||||||
`sequence->velocity`/`omega` at the moment the stop-transition is initiated
|
|
||||||
(not merely when the stop ANIMATION finishes), the `apply_physics`/animation
|
|
||||||
per-frame delta stops contributing translation from THAT frame onward except
|
|
||||||
whatever the stop-link-clip's own authored motion data supplies via
|
|
||||||
`add_motion(seq, linkAnim, ...)` immediately after the clear. So there's no
|
|
||||||
"physics keeps sliding while the stop anim plays" bug window — the only motion
|
|
||||||
during the stop-link clip is whatever the clip's OWN keyframed velocity says
|
|
||||||
(typically small/decelerating by design), and once the link clip's frames are
|
|
||||||
exhausted and playback reaches the (typically near-static) idle cyclic node,
|
|
||||||
velocity is whatever that idle cycle's own `add_motion(..., cycleData, speed)`
|
|
||||||
contributed (near zero for a proper "Ready"/idle motion).
|
|
||||||
|
|
||||||
Additionally: `CPhysicsObj::RemoveLinkAnimations` (-> `CPartArray::HandleEnterWorld`
|
|
||||||
which is really "flush the motion table manager's queued link anims", called
|
|
||||||
from multiple guard points: whenever `physics_obj->cell == 0` inside both
|
|
||||||
`DoInterpretedMotion` and `StopInterpretedMotion`'s tail, from `HitGround`,
|
|
||||||
`LeaveGround`, and from `move_to_interpreted_state`'s caller context indirectly)
|
|
||||||
provides a hard-reset safety valve: if the object leaves the world/cell
|
|
||||||
mid-transition, any queued link-transition chain is discarded outright rather
|
|
||||||
than left dangling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verbatim float constants collected in this pass
|
|
||||||
|
|
||||||
| Constant | Where | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `0.000199999995f` (~0.0002) | `change_cycle_speed` (298276), `CPhysicsObj::update_object` dt-epsilon (283950 area), `CPhysicsObj::SetTranslucency` (279489) | Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. |
|
|
||||||
| `2.0` (dt seconds) | `CPhysicsObj::update_object` (~284009) | Large-dt chunking threshold: any single `update_object` gap >= 2.0s is stepped in 1.0s `UpdateObjectInternal` slices to avoid one huge teleport-y integration step. |
|
|
||||||
| `1.0` (dt seconds) | same function | Per-slice step size used while chunking large dt. |
|
|
||||||
| `1.25f` | `CMotionInterp::get_state_velocity` (305160) | Sidestep speed multiplier when computing "logical state velocity" (`sidestep_speed * 1.25f`) — used for e.g. UI/AI queries, not the actual CSequence velocity. |
|
|
||||||
| `1.5f` | `CMotionInterp::apply_run_to_command` (305062), motion `0x6500000d` (TURN) case | Speed multiplier applied to turn commands. |
|
|
||||||
| `3f` / `-1f*3f` | `CMotionInterp::apply_run_to_command`, motion `0x6500000f` (SIDESTEP) case | Sidestep speed is clamped/scaled to exactly `+-3.0` depending on sign of the run-rate-scaled input (with sign preserved via the `x87_r7 < 0` branch). |
|
|
||||||
| `3.11999989f` (~3.12) | `CMotionInterp::get_state_velocity` (305176) | Walk-forward (`0x45000005`) logical-velocity multiplier. |
|
|
||||||
| `4f` | `CMotionInterp::get_state_velocity` (305180) | Run-forward (`0x44000007`) logical-velocity multiplier. |
|
|
||||||
| `96f` | `CPhysicsObj::update_object` (283974, player_distance gate) | Distance (world units, ~yards? — needs unit confirmation) beyond which a different `set_active` path is taken for a non-player-object relative to the player. |
|
|
||||||
| `0.100000001f` (0.1) | `CPhysicsObj::set_elasticity` (277817) | Elasticity clamp floor — unrelated to motion but shares the file region. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Function/line index (quick lookup for a synthesis pass)
|
|
||||||
|
|
||||||
| Symbol | Line | Addr | Role |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ACSmartBox::DispatchSmartBoxEvent` | 357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) |
|
|
||||||
| `CPhysics::SetObjectMovement` (stdcall) | 271370 | 0x00509690 | Sequence-number staleness gate, dispatch |
|
|
||||||
| `CPhysicsObj::unpack_movement` | 280179 | 0x00512040 | Lazily creates MovementManager, forwards |
|
|
||||||
| `MovementManager::unpack_movement` | 300563 | 0x00524440 | Deserializes wire struct, 10-way type switch |
|
|
||||||
| `MovementManager::move_to_interpreted_state` | 300259 | 0x00524170 | Lazy-create CMotionInterp, forward |
|
|
||||||
| `CMotionInterp::move_to_interpreted_state` | 305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions |
|
|
||||||
| `InterpretedMotionState::copy_movement_from` | 293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) |
|
|
||||||
| `CMotionInterp::apply_current_movement` | 305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path |
|
|
||||||
| `CMotionInterp::apply_interpreted_movement` | 305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot |
|
|
||||||
| `CMotionInterp::DoInterpretedMotion` | 305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue |
|
|
||||||
| `CPhysicsObj::DoInterpretedMotion` | 276348 | 0x0050ea70 | Thin forward to CPartArray |
|
|
||||||
| `CPartArray::DoInterpretedMotion` | 286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager |
|
|
||||||
| `MotionTableManager::PerformMovement` | 290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely |
|
|
||||||
| `CMotionTable::DoObjectMotion` | 300045 | 0x00523e90 | -> GetObjectSequence(force=0) |
|
|
||||||
| `CMotionTable::GetObjectSequence` | 298636 | 0x00522860 | **THE cycle-swap/append/fast-path decision** |
|
|
||||||
| `same_sign` | 298253 | 0x00522260 | Direction-unchanged test |
|
|
||||||
| `change_cycle_speed` | 298276 | 0x00522290 | Same-cycle playback-rate rescale |
|
|
||||||
| `CMotionTable::get_link` | 298552 | 0x00522710 | Authored transition-anim lookup |
|
|
||||||
| `add_motion` / `combine_motion` / `subtract_motion` | 298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out |
|
|
||||||
| `CSequence::append_animation` | 301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump |
|
|
||||||
| `CSequence::clear_physics` | 301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega |
|
|
||||||
| `CSequence::remove_cyclic_anims` | referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition |
|
|
||||||
| `CSequence::update` | 302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) |
|
|
||||||
| `CSequence::update_internal` | 301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) |
|
|
||||||
| `CSequence::apply_physics` | 300955 | 0x00524ab0 | outDelta.origin += dt*velocity; rotate(dt*omega) |
|
|
||||||
| `CSequence::apricot` | 300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic |
|
|
||||||
| `CPartArray::Update` | 285883 | 0x00517db0 | == CSequence::update |
|
|
||||||
| `CPhysicsObj::UpdatePositionInternal` | 280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks |
|
|
||||||
| `CPhysicsObj::UpdateObjectInternal` | 283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls |
|
|
||||||
| `CPhysicsObj::update_object` | 283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking |
|
|
||||||
| `CPhysicsObj::transition` | 280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position |
|
|
||||||
| `MotionTableManager::add_to_queue` | 290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links |
|
|
||||||
| `MotionTableManager::remove_redundant_links` | 290771 | 0x0051bf20 | Collapse superseded queued link transitions |
|
|
||||||
| `MotionTableManager::CheckForCompletedMotions` | 290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone |
|
|
||||||
| `MotionTableManager::AnimationDone` | 290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter |
|
|
||||||
| `CPhysicsObj::Hook_AnimDone` | 277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) |
|
|
||||||
| `CPhysicsObj::MotionDone` | 277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue |
|
|
||||||
| `MovementManager::MotionDone` | 300396 | 0x005242d0 | Forward |
|
|
||||||
| `CMotionInterp::MotionDone` | 305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged |
|
|
||||||
| `CMotionInterp::add_to_queue` | 305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions |
|
|
||||||
| `CMotionInterp::motions_pending` | 305322 | 0x00527fe0 | pending_motions.head_ != null |
|
|
||||||
| `CMotionInterp::StopInterpretedMotion` | 305635 | 0x00528470 | Entry for stopping a command |
|
|
||||||
| `CMotionTable::StopObjectMotion` | 300053 | 0x00523ec0 | -> StopSequenceMotion |
|
|
||||||
| `CMotionTable::StopSequenceMotion` | 298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) |
|
|
||||||
| `CMotionTable::StopObjectCompletely` | 300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each |
|
|
||||||
| `InterpretedMotionState::ApplyMotion` | 293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields |
|
|
||||||
| `InterpretedMotionState::RemoveMotion` | 293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults |
|
|
||||||
| `CPhysicsObj::RemoveLinkAnimations` | 277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims |
|
|
||||||
| `CMotionInterp::contact_allows_move` | 305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions |
|
|
||||||
| `CMotionInterp::PerformMovement` | 306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes on scope / what was NOT fully resolved
|
|
||||||
|
|
||||||
- `CSequence::update_internal`'s exact per-frame arithmetic (how `frame_number`
|
|
||||||
advances, exact interpolation between `low_frame`/`high_frame`, and the
|
|
||||||
precise mechanism by which a per-frame authored position delta from
|
|
||||||
`AnimFrame`/`get_pos_frame` gets folded into the output `Frame*`) is
|
|
||||||
x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU
|
|
||||||
compare/branch sequences show as `/* unimplemented {fcomp ...} */` rather
|
|
||||||
than resolved C. This matches the documented project-wide limitation (see
|
|
||||||
`memory/feedback_bn_decomp_field_names.md` and the CLAUDE.md cdb-toolchain
|
|
||||||
section) that some floating-point-heavy retail functions don't fully
|
|
||||||
decompile via Binary Ninja and may need a cdb live-trace or manual
|
|
||||||
disassembly pass to pin exact behavior. What IS certain from the
|
|
||||||
surrounding code (append_animation/get_pos_frame/get_part_frame/apricot)
|
|
||||||
is the STRUCTURE: node-list advance + per-node authored frame data feeding
|
|
||||||
the output Frame.
|
|
||||||
- `PositionManager::adjust_offset`'s body (the Q6 soft-correction blend) was
|
|
||||||
not located inside this pseudo-C excerpt in this pass — only its call site
|
|
||||||
and sibling API surface (`UnStick`, `StopInterpolating`, `IsInterpolating`,
|
|
||||||
`IsFullyConstrained`, `GetStickyObjectID`) were confirmed. A follow-up grep
|
|
||||||
for `PositionManager::` method bodies (likely a different source file /
|
|
||||||
address range not covered by the anchors given) would be needed to get its
|
|
||||||
exact blend formula and any snap-threshold constant.
|
|
||||||
- No explicit "snap if error > threshold" constant was found for position
|
|
||||||
correction in the portions traced; the retail design as observed here is a
|
|
||||||
continuous per-tick blend (`adjust_offset`) plus occasional authoritative
|
|
||||||
hard `SetPositionInternal` when the wire message actually carries a
|
|
||||||
`Position` (MoveToObject/MoveToPosition/PositionAndMovement paths), not a
|
|
||||||
distance-threshold-triggered snap layered on top of ordinary motion-command
|
|
||||||
packets.
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
# Handoff: inbound animation + position — verbatim retail port
|
|
||||||
|
|
||||||
Date: 2026-07-02
|
|
||||||
Branch: `claude/vigorous-joliot-f0c3ad` (clean; prior arc all committed through `d34721fa`)
|
|
||||||
Mode for the new session: **/investigate first (report-only), then port on approval.**
|
|
||||||
|
|
||||||
## What just shipped (context — the OUTBOUND / LOCAL half is done)
|
|
||||||
|
|
||||||
This session made acdream's *outbound* wire and *local-player* motion
|
|
||||||
retail-faithful, verified against live ACE + user/retail-observer sign-off:
|
|
||||||
|
|
||||||
- **L.1b** `2c8620ea` — dual `IMotionCommandCatalog` (AceModern runtime /
|
|
||||||
Retail2013 conformance, full `command_ids[0x198]` extraction).
|
|
||||||
- **L.2b** `78e163a4` + sign-off `f271a49e` — outbound wire parity (D1
|
|
||||||
default-difference `RawMotionState::Pack`, D3 contact/longjump byte, D4
|
|
||||||
`JumpPack`).
|
|
||||||
- **D6** `4ed27836` (pseudocode) + `0f099bb6` (D6.2a) + `47407506` (sign-off) +
|
|
||||||
`d34721fa` (D6.2b) — ported `CMotionInterp::adjust_motion` /
|
|
||||||
`apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` and
|
|
||||||
unified the local player's velocity + turn + jump + wire onto one
|
|
||||||
input-built `RawMotionState` at `forward_speed=1.0`. Backward/strafe-left no
|
|
||||||
longer zero; strafe retail-exact; turn omega from interpreted `turn_speed`.
|
|
||||||
- **Key wire finding:** ACE **recomputes** the broadcast run speed from run
|
|
||||||
skill (echo-test confirmed) and auto-upgrades `WalkForward+HoldKey.Run →
|
|
||||||
RunForward`. The wire sends raw `forward_speed=1.0`. (Crib:
|
|
||||||
`claude-memory/project_retail_motion_outbound.md`.)
|
|
||||||
|
|
||||||
**That was OUTBOUND + LOCAL. This handoff is the INBOUND / REMOTE half.**
|
|
||||||
|
|
||||||
## The goal
|
|
||||||
|
|
||||||
Port retail's **incoming (inbound) handling of animation + position VERBATIM**
|
|
||||||
into acdream — but **surgically**, targeted at where acdream deviates (not a
|
|
||||||
blind wholesale sequencer rewrite; a rushed sequencer swap has "broken
|
|
||||||
everything" in a past session — integrate carefully). Strict standard: no
|
|
||||||
approximations; retail decomp / ACE / DAT as oracles; parity tests before
|
|
||||||
behavior changes.
|
|
||||||
|
|
||||||
## The symptom (the acceptance oracle — user's live observation)
|
|
||||||
|
|
||||||
Watching a **remote player** (inbound motion) in acdream:
|
|
||||||
|
|
||||||
- Overall "works OK" but **not polished**. Some **sliding** and some
|
|
||||||
**position errors once the entity stops**.
|
|
||||||
- **The worst offender:** transitioning **walk↔run WITHOUT stopping** — the
|
|
||||||
motion **interpreter reacts too slowly**, and this **throws all animation +
|
|
||||||
position off afterward**, compounding as the entity keeps **running around +
|
|
||||||
turning**. The error accumulates through subsequent motion.
|
|
||||||
|
|
||||||
Fixing the walk↔run-transition responsiveness (and the resulting animation +
|
|
||||||
position desync) IS the acceptance test.
|
|
||||||
|
|
||||||
## Root-cause hypothesis (UNCONFIRMED — verify in the investigation first)
|
|
||||||
|
|
||||||
acdream's motion sequencer does not implement retail's motion-**transition**
|
|
||||||
state machine. Retail's `CMotionInterp` doesn't just swap the animation cycle
|
|
||||||
on a motion change — it **appends** the new motion to a `CSequence` and
|
|
||||||
transitions through it with `pending_motions` / `MotionDone` bookkeeping. If
|
|
||||||
acdream applies a walk↔run change abruptly/late (snap `SetCycle`) instead of
|
|
||||||
sequencing it, the animation cycle and the dead-reckoned position drift apart
|
|
||||||
and the error accumulates. The 2026-06-04 deep-dive already ranked missing
|
|
||||||
`pending_motions` / `MotionDone` as **HIGH**.
|
|
||||||
|
|
||||||
## Pre-paid research to leverage (READ THESE)
|
|
||||||
|
|
||||||
1. **`docs/research/2026-06-04-animation-sequencer-deep-dive.md`** — START
|
|
||||||
HERE. Retail `CMotionInterp` + `CSequence` + hook-dispatch state graph + 8
|
|
||||||
ranked divergences (missing `pending_motions`/`MotionDone` HIGH; frame-swap
|
|
||||||
class; link→cycle boundary). **Verify its findings against the CURRENT code
|
|
||||||
— the L.1 animation work may have closed some gaps since 2026-06-04; do not
|
|
||||||
trust its line numbers.**
|
|
||||||
2. `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the
|
|
||||||
inbound divergences: **D7** (animation split from retail motion flow), **D8**
|
|
||||||
(force-walk / `MoveToManager` approximate), **D9** (inbound `TurnToObject`/
|
|
||||||
`TurnToHeading` types 8/9 dropped — CONFIRMED live: `mt=0x09` arrives and
|
|
||||||
just `SetCycle(Ready)` instead of turning), **D10** (spawn-time MoveTo weak),
|
|
||||||
**D11** (sequence/autonomy parsed then discarded), **D12** (jump/contact
|
|
||||||
gates simplified).
|
|
||||||
3. `docs/research/2026-07-01-d6-motion-interp-pseudocode.md` — the OUTBOUND/local
|
|
||||||
`CMotionInterp` port already done; inbound uses the SAME machinery
|
|
||||||
(`get_state_velocity`, `adjust_motion`, `apply_raw_movement` now exist in
|
|
||||||
`src/AcDream.Core/Physics/MotionInterpreter.cs`).
|
|
||||||
|
|
||||||
## Decomp anchors (the oracle; grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`)
|
|
||||||
|
|
||||||
- `CMotionInterp::apply_current_movement` (~line 305713 — sequences style /
|
|
||||||
forward-falling / sidestep / turn / actions), `DoInterpretedMotion` (~305575),
|
|
||||||
`CMotionTable::GetObjectSequence` (~298636).
|
|
||||||
- `CSequence` append/advance/update (~lines 301622, 301777, 301839, 302425).
|
|
||||||
- The 2026-06-04 deep-dive has the full CMotionInterp/CSequence address list.
|
|
||||||
- `acclient.h` for the structs; Ghidra HTTP bridge at `http://127.0.0.1:8081`
|
|
||||||
(decompile-by-address) if it's up.
|
|
||||||
|
|
||||||
## Where the inbound code lives (starting points to map)
|
|
||||||
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs` — inbound remote-entity motion:
|
|
||||||
`OnLiveMotionUpdated`, the remote `SetCycle` / `ObservedOmega` seeding
|
|
||||||
(~4815-4856), dead-reckoned position + omega application (~9750-9865), the
|
|
||||||
`[SETCYCLE]`/`[SCFAST]`/`[SCFULL]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/
|
|
||||||
`[UPCYCLE]` diagnostics.
|
|
||||||
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — the cycle/action sequencer.
|
|
||||||
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — interpreted state (now has
|
|
||||||
the D6 `adjust_motion`/`apply_raw_movement`/`get_state_velocity`).
|
|
||||||
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` — remote MoveTo + `TurnRateFor`.
|
|
||||||
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`, `UpdatePosition.cs`,
|
|
||||||
`MoveToState.cs` — inbound parsing.
|
|
||||||
|
|
||||||
## The plan for the new session
|
|
||||||
|
|
||||||
1. **/investigate (report-only):** produce a ranked **deviation map** —
|
|
||||||
where acdream's inbound animation + position handling deviates from retail's
|
|
||||||
`CMotionInterp`/`CSequence`, **anchored on the walk↔run transition-lag
|
|
||||||
symptom** (not a whole-system rewrite). Parallel research agents are a good
|
|
||||||
fit. Optionally corroborate with a live capture (below). Deliver the report
|
|
||||||
+ recommended verbatim-port slices; get explicit approval before edits.
|
|
||||||
2. **Port (after approval):** verbatim-port the deviating retail mechanisms —
|
|
||||||
most likely the `CSequence` motion-queue / transition + `pending_motions` /
|
|
||||||
`MotionDone`, and the inbound position/dead-reckoning where it deviates.
|
|
||||||
Tests-first (parity tests before behavior). **Integrate the sequencer
|
|
||||||
SURGICALLY** — change the minimum; do not replace the whole transform/cycle
|
|
||||||
pipeline at once.
|
|
||||||
3. **Verify:** the walk↔run transition is smooth and no cascading
|
|
||||||
animation/position desync — user visual + retail-observer side-by-side.
|
|
||||||
|
|
||||||
## Live-capture setup (structured testing)
|
|
||||||
|
|
||||||
- acdream = **OBSERVER** (login as `+Acdream`); the **other player driven from a
|
|
||||||
retail client** (retail packets = ground truth, so any render artifact is a
|
|
||||||
pure acdream inbound bug).
|
|
||||||
- Launch env (PowerShell): the standard live vars +
|
|
||||||
`ACDREAM_DUMP_MOTION=1` + `ACDREAM_REMOTE_VEL_DIAG=1` (heavy; dumps
|
|
||||||
`[UM_RAW]`/`[SETCYCLE]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/`[UPCYCLE]` so
|
|
||||||
you can correlate each inbound packet with what acdream renders).
|
|
||||||
- Structured protocol, one motion at a time: walk-straight (start→stop),
|
|
||||||
run-straight, turn-in-place, curved path, idle, **walk↔run toggle
|
|
||||||
(the key one)**, abrupt-stop-from-run.
|
|
||||||
- NOTE: `+Acdream` had `run=15225` (GM-buffed) this session → absolute speeds
|
|
||||||
unrepresentative; judge **relative** behavior + transitions.
|
|
||||||
|
|
||||||
## Housekeeping
|
|
||||||
|
|
||||||
- **Close the running observer acdream client before the new session rebuilds**
|
|
||||||
(it locks `AcDream.App.exe`; `dotnet build` fails with the DLL-copy lock
|
|
||||||
otherwise — the user closes it, we do NOT kill it).
|
|
||||||
- A background research agent (mapping acdream's inbound animation handling) was
|
|
||||||
in-flight when this session stopped — orphaned; the new session re-does the
|
|
||||||
mapping cleanly.
|
|
||||||
|
|
||||||
## Discipline reminders
|
|
||||||
|
|
||||||
- The user's side-by-side retail observations are **axioms** (retail-oracle);
|
|
||||||
once a mechanism plan exists, artifacts become acceptance tests, not new
|
|
||||||
investigations (no whack-a-mole).
|
|
||||||
- Report-only until fixes approved. Decomp-verbatim. Tests-first.
|
|
||||||
- Every deviation the port introduces/retires updates
|
|
||||||
`docs/architecture/retail-divergence-register.md` in the same commit.
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# R1-P0 — pseudocode pins and ambiguity resolutions
|
|
||||||
|
|
||||||
The verbatim extraction lives in `r1-csequence-decomp.md` (1,756 lines, per-
|
|
||||||
function raw pseudo-C + cleaned translation, line anchors into
|
|
||||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This note records
|
|
||||||
the corrections and pins the R1 port codes against, per the gap map
|
|
||||||
(`r1-gap-map.md`).
|
|
||||||
|
|
||||||
## Corrections to the extraction (verified against raw decomp + ACE)
|
|
||||||
|
|
||||||
1. **§21 `update_internal` cleaned flow is garbled in two places.** The
|
|
||||||
authoritative skeleton is ACE `Sequence.cs:351-443` (verified verbatim,
|
|
||||||
matches the raw decomp branch layout at 0x005255d0-0x005259ca):
|
|
||||||
overshoot check → clamp `frame_number` to `get_high_frame()` (fwd) /
|
|
||||||
`get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
|
|
||||||
`animDone = true` → per-frame crossing loop → `if (!animDone) return` →
|
|
||||||
AnimDone gate (`anim_list.head != first_cyclic`) →
|
|
||||||
`advance_to_next_animation` → carry leftover → loop.
|
|
||||||
2. **Hook direction constants**: forward crossings pass +1
|
|
||||||
(0x0052578c, counter `++` after), reverse pass −1 (0x0052590c, counter
|
|
||||||
`--` after). Retail encoding == ACE/DatReaderWriter `AnimationHookDir`
|
|
||||||
(Backward=−1 / Both=0 / Forward=1) — no remap.
|
|
||||||
|
|
||||||
## PINNED ambiguity — leftover-time carry after node advance
|
|
||||||
|
|
||||||
Raw decomp at 0x0052598a-0x0052598d APPEARS to zero the elapsed argument
|
|
||||||
after `advance_to_next_animation` before looping; ACE carries the leftover
|
|
||||||
(`timeElapsed = frameTimeElapsed`, Sequence.cs:436-442).
|
|
||||||
|
|
||||||
**PIN: the ACE reading (carry the leftover).** Rationale: (a) Binary Ninja
|
|
||||||
routinely loses x87 stack-slot reassignments (the same artifact class as the
|
|
||||||
garbled setcc returns in `is_newer`); (b) zeroing would make a lag spike
|
|
||||||
unable to fast-forward through multiple queued nodes in one tick, visibly
|
|
||||||
freezing links under hitches — behavior nobody has ever reported on retail;
|
|
||||||
(c) the loop structure (`while(true)` with the leftover recomputed per
|
|
||||||
node) only makes sense with a carry.
|
|
||||||
|
|
||||||
**Confirmation pending (not blocking):** cdb breakpoints on
|
|
||||||
`acclient!CSequence::update` + `acclient!CSequence::advance_to_next_animation`
|
|
||||||
with hit counters (pattern `tools/cdb/l2g-observer.cdb`) under an induced
|
|
||||||
stall — an advance/update ratio > 1 in a single update proves the carry.
|
|
||||||
Fold into the next live retail session; if it DISPROVES the carry, the fix
|
|
||||||
is one line in `update_internal` + rerun of the P4 conformance suite.
|
|
||||||
|
|
||||||
## P0 addenda for later stages
|
|
||||||
|
|
||||||
- The same cdb session should also capture `append_animation` /
|
|
||||||
`remove_cyclic_anims` argument logs (P2/P5 goldens) — one session serves
|
|
||||||
all of R1.
|
|
||||||
- `frame_number` is x87 `long double` (acclient.h:30747); C# `double` is the
|
|
||||||
closest available → divergence-register row lands with the P2 commit
|
|
||||||
(G15).
|
|
||||||
- ACE's `PhysicsGlobals.EPSILON` subtraction in
|
|
||||||
`get_starting/get_ending_frame` is an ACE fabrication (compensating for
|
|
||||||
ACE's `float FrameNumber`) — retail returns bare ints. Do NOT copy (G1).
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
# acdream AnimationSequencer — current-state map (R1)
|
|
||||||
|
|
||||||
File: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines, read whole).
|
|
||||||
Companions: `AnimationCommandRouter.cs` (98 lines), `AnimationHookRouter.cs` (95
|
|
||||||
lines), `IAnimationHookSink.cs` (89 lines) — all read whole.
|
|
||||||
|
|
||||||
## 0. Type inventory in AnimationSequencer.cs
|
|
||||||
|
|
||||||
- `IAnimationLoader` — abstraction (`LoadAnimation(uint id) : Animation?`) so
|
|
||||||
the sequencer can be unit-tested without a real `DatCollection`.
|
|
||||||
- `DatCollectionLoader : IAnimationLoader` — production impl, wraps
|
|
||||||
`DatCollection.Get<Animation>(id)`.
|
|
||||||
- `PartTransform` (readonly struct) — `Vector3 Origin`, `Quaternion
|
|
||||||
Orientation`. Output unit of `Advance`.
|
|
||||||
- `AnimNode` (internal sealed class) — one queue entry:
|
|
||||||
`Animation Anim; double Framerate; int StartFrame; int EndFrame; bool
|
|
||||||
IsLooping; bool HasPosFrames; Vector3 Velocity; Vector3 Omega`.
|
|
||||||
Methods: `MultiplyFramerate(factor)`, `GetStartFramePosition()`,
|
|
||||||
`GetEndFramePosition()`. `FrameEpsilon = 1e-5` (mirrors retail
|
|
||||||
`_DAT_007c92b4`).
|
|
||||||
- `AnimationSequencer` (public sealed class) — the engine itself, one
|
|
||||||
instance per entity.
|
|
||||||
|
|
||||||
## (a) Feature matrix — AnimationSequencer vs retail CSequence concepts
|
|
||||||
|
|
||||||
| Retail concept | Retail anchor cited in acdream | acdream implementation | Notes / fidelity |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Node list (AnimSequenceNode queue) | FUN_00525EB0 `advance_to_next_animation`; ACE `Sequence.cs` | `LinkedList<AnimNode> _queue`, `_currNode`, `_firstCyclic` pointers | Doubly-linked list mirrored with .NET `LinkedList<T>`. Non-cyclic head (link frames) + looping tail (`_firstCyclic..end`) invariant maintained explicitly rather than via a node flag walked at runtime. |
|
|
||||||
| Link resolution (`get_link`) | ACE `MotionTable.cs:395-426`; retail `CMotionTable::GetObjectSequence` 0x00522860 | `GetLink(style, substate, substateSpeed, motion, speed)` private method | Forward-direction path: `Links[(style<<16)|substate][motion]`, then style-level catch-all `Links[style<<16][motion]`. **Reversed-direction branch** added (comment cites "K-fix6"): when either speed is negative, looks up `Links[(style<<16)|motion][substate]` instead (link FROM motion TO substate, played reversed) — handles WalkBackward/SideStepLeft/TurnLeft transitions. Also a `StyleDefaults` fallback under the reversed branch. This 2-branch structure is **acdream's own generalization**, not a literal 1:1 decompile citation (no direct FUN_xxx cited for the branch split itself, only for its constituent parts via ACE line numbers). |
|
|
||||||
| `adjust_motion` (TurnLeft/SideStepLeft/WalkBackward → mirror) | ACE `MotionInterp.cs:394-428` | Inlined at top of `SetCycle` + duplicated in `HasCycle`: 0x000E→0x000D (negate speed), 0x0010→0x000F (negate speed), 0x0006→0x0005 (negate speed × 0.65 `BackwardsFactor`) | Faithful port; the `0x65` low-byte switch is applied to `motion & 0xFFFFu`, i.e. matched purely on the low 16 bits regardless of class byte. |
|
|
||||||
| Fast-path re-speed (no restart on same motion) | ACE `MotionTable.cs:132-139` | `SetCycle` early-return branch: if `CurrentStyle==style && CurrentMotion==motion && sign(speedMod)==sign(CurrentSpeedMod)` → `MultiplyCyclicFramerate` instead of rebuild | Faithful. Explicit **sign-flip exception** documented: when adjust_motion flips speedMod's sign while `motion` value itself stays the same (WalkForward with negative speed = backward), the fast path is bypassed by the sign check so a full restart occurs — comment marks this as an acdream-observed necessity (2026-05-02), not literally cited to a retail address. |
|
|
||||||
| `multiply_cyclic_animation_framerate` | FUN_00525CE0; ACE `Sequence.cs L277-L287` | `MultiplyCyclicFramerate(float factor)` — walks `_firstCyclic..end`, calls `AnimNode.MultiplyFramerate` on each, and also scales `CurrentVelocity *= factor; CurrentOmega *= factor` | Faithful for node framerates. The velocity/omega scaling is justified by algebraic equivalence to ACE's `subtract_motion(old)+combine_motion(new)` (`MotionTable.change_cycle_speed`, `MotionTable.cs L372-L379`) rather than being separately decompiled — an acdream derivation, not a citation of a specific retail scaling line. |
|
|
||||||
| `GetStartFramePosition` / `GetEndFramePosition` | FUN_00526880 / FUN_005268B0 | `AnimNode.GetStartFramePosition()/GetEndFramePosition()` | Faithful 1:1 including the `EPSILON = _DAT_007c92b4` (hardcoded here as `1e-5` — the retail exact float constant was **not** independently verified against the binary in this file's comments; it's asserted equal). |
|
|
||||||
| `multiply_framerate` (StartFrame↔EndFrame swap for negative speed) | FUN_005267E0 | `AnimNode.MultiplyFramerate(factor)` | **Explicitly documented divergence**: retail swaps StartFrame↔EndFrame for negative factor; acdream keeps `StartFrame ≤ EndFrame` as an invariant and encodes direction purely via `Framerate`'s sign, compensating in the `Advance` loop's boundary checks instead. Comment states this is valid "because the callers we care about... only ever pass positive factors" — i.e. an acknowledged simplification with a stated (unverified beyond code review) precondition. |
|
|
||||||
| `update_internal` (per-frame advance loop) | FUN_005261D0; ACE `Sequence.cs:351-443` | `Advance(float dt)` | Faithful structurally: `while (timeRemaining>0 && _currNode!=null)` loop (capped `safety=64` — **acdream-invented safety valve**, no retail citation, guards against a "degenerate motion table" infinite loop). Computes `delta = rate*timeRemaining`; forward vs reverse branches each: (1) detect boundary overflow, (2) clamp `_framePosition` to boundary-epsilon, (3) walk every integer frame crossed applying `ApplyPosFrame` + `ExecuteHooks`, (4) on wrap, call `advance_to_next_animation` (`AdvanceToNextAnimation()`) and continue with leftover `overflow` time. |
|
|
||||||
| `advance_to_next_animation` (node wrap) | FUN_00525EB0 | `AdvanceToNextAnimation()` — moves `_currNode` to `.Next`, or wraps to `_firstCyclic` if null, else holds on last node if no cyclic tail exists | Faithful. Resets `_framePosition` via `GetStartFramePosition()` on transition. |
|
|
||||||
| `execute_hooks` | ACE `Sequence.cs:262-270` | `ExecuteHooks(node, frameIndex, playbackDir)` — fires hook if `hook.Direction == Both \|\| hook.Direction == playbackDir` | Faithful 1:1 port of the direction-match condition. |
|
|
||||||
| Root motion (`AFrame.Combine` / `frame.Subtract`) | ACE (`AFrame.Combine`/`Subtract`, cited generically, no line #) | `ApplyPosFrame(node, frameIndex, reverse)` — forward: `_rootMotionPos += Rotate(pf.Origin, _rootMotionRot); _rootMotionRot = Normalize(_rootMotionRot * pf.Orientation)`; reverse: conjugate-then-subtract | Faithful port of the two composition directions. Accumulated into `_rootMotionPos`/`_rootMotionRot`, drained via `ConsumeRootMotionDelta()`. |
|
|
||||||
| AnimationDone hook on link drain | ACE `PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook)` (cited generically) | `AnimationDoneSentinel` (static `AnimationDoneHook{Direction=Both}`) pushed to `_pendingHooks` in `Advance` just before `AdvanceToNextAnimation()`, gated on `!_currNode.Value.IsLooping` | Faithful concept; the sentinel object is a single shared static instance (not per-fire), meaning downstream code cannot distinguish *which* motion completed purely from hook identity — must correlate via entity + timing. Not flagged as a bug in the file; simply a design note for consumers. |
|
|
||||||
| Quaternion slerp | FUN_005360d0 (chunk_00530000.c:4799-4846) | `SlerpRetailClient(q1, q2, t)` static method | Faithful port including retail's odd step-5 validation-then-linear-fallback quirk (`SlerpEpsilon = 1e-4f` near-parallel check, then acos/sin slerp, then re-validate blend weights ∈[0,1] before trusting the slerp result — falls back to linear otherwise). Explicitly documented as differing from "the standard formula" only in this validation step. |
|
|
||||||
| Frame-boundary blend (`BuildBlendedFrame`) | Not directly cited to a single retail FUN — described as producing "the current blended keyframe" | `BuildBlendedFrame()` — clamps `frameIdx` to `[rangeLo,rangeHi]`; computes `nextIdx` stepping in playback direction; **wraps only if `curr.IsLooping`**, else holds boundary frame | **Explicitly acdream-motivated fix, not retail-cited**: comment for issue **#61** ("door swing-open flap; run-stop twitch") states holding the boundary frame instead of wrapping-blending into frame 0 for one-shot (non-looping) nodes avoids "a brief flash through the anim's starting pose at the link→cycle boundary." This is the **frame-swap / link→cycle boundary flash class of bug** the deep-dive skill targets — currently patched empirically here, not derived from a decompiled retail boundary-hold mechanism. |
|
|
||||||
| Placement / root frames | `AnimData.AnimId`, `Animation.PartFrames`, `Animation.PosFrames`, `Frame.Origin/Orientation` (DatReaderWriter types) | `LoadAnimNode` reads `anim.PartFrames.Count` for frame bounds; `hasPosFrames = anim.Flags.HasFlag(PosFrames) && anim.PosFrames.Count >= numFrames` | Sentinel resolution for `HighFrame == -1` ("all frames") ported from `MotionResolver.GetIdleCycle` (cited by name, not address). `if (low > high) high = low` guards a degenerate AnimData. |
|
|
||||||
| "Fix B" (#39, 2026-05-06) — cyclic→cyclic direct transition skips link | cdb live trace 2026-05-03 of a Walk→Run transition: `add_to_queue(45000005, looping=1)` then `add_to_queue(44000007, looping=1)` with `truncate_animation_list` never firing | In `SetCycle`: `IsLocomotionCycleLowByte` check on both `CurrentMotion` and new `motion`'s low byte (0x05/0x06/0x07/0x0F/0x10); if both are locomotion AND `_firstCyclic` exists, the just-enqueued link node is **removed from the queue** and `_currNode` is forced directly onto `_firstCyclic` | This is the most heavily-commented divergence-turned-fix in the file. Explicit warning left in comments: "Commit c06b6c5 (reverted in a2ae2ae) demonstrated that unconditionally skipping the link breaks all of these [Idle→cycle, Falling→Ready, pose-change links, combat substates]" — i.e. the fix is deliberately **scoped only to the locomotion-cycle subset**, confirmed retail-faithful via a live cdb capture (grep-named-first / attach-cdb workflow), not guessed. |
|
|
||||||
| K-fix18 — `skipTransitionLink` | Not a retail citation — an acdream product decision, explicitly labeled "K-fix18" in the doc comment | `SetCycle(..., bool skipTransitionLink = false)` param. When true: `linkData` forced null, AND (separately) the entire `_queue` is cleared / `_currNode`/`_firstCyclic` reset to null before rebuild | **Confirmed acdream-invented deviation** (not retail behavior) — used only for Falling-on-jump-start (`GameWindow.cs:4830`, `10201`) to avoid a ~100ms "stop running" pose delay before the fall animation engages. This should have (or needs to be checked against) a `retail-divergence-register.md` row per CLAUDE.md's mandatory bookkeeping rule — the file itself doesn't reference the register. |
|
|
||||||
| Stop-anim fallback (direction-agnostic settle) | Not retail-cited; acdream reasoning: "settle anim is direction-agnostic" | In `SetCycle`, if `linkData is null` after the primary lookup, retries `GetLink` with `CurrentMotion`'s low byte remapped 0x06→0x05, 0x10→0x0F, 0x0E→0x0D (peer forward/right substate) | Acknowledged as an acdream workaround for a `null linkData` gap when stopping from WalkBackward/SideStepLeft/TurnLeft — motivated by an observed visible glitch ("left leg twitches forward two times") rather than by a decompiled retail fallback path. |
|
|
||||||
| Stale-head handling | Comment: "For remote entities receiving many bundled UMs over time, this stale-head build-up was the root cause of..." | `preEnqueueTail` snapshot before `EnqueueMotionData` calls; after enqueue, `firstNew = preEnqueueTail?.Next ?? _queue.First`; `_currNode` is **force-set** onto `firstNew` (or `_firstCyclic` under the Fix-B branch) rather than left wherever it was | Acdream-diagnosed and acdream-authored fix (no retail citation) — addresses a structural bug where `_currNode` could remain parked on old non-cyclic head frames left over from a previous `SetCycle` call, which visually manifested as "transitions between cycles don't visibly switch the leg pose" for remote entities. Local player was unaffected because `PlayerMovementController` calls `SetCycle` frequently enough to keep the queue clean — this is called out explicitly as a per-entity-class asymmetry. |
|
|
||||||
| Velocity/Omega synthesis for locomotion & turn cycles | `CMotionInterp::get_state_velocity` (FUN_00528960 cited by address); `MotionInterpreter.RunAnimSpeed` etc (decompiled from `_DAT_007c96e0/e4/e8`); ACE's `omega.z = ±(π/2)×turnSpeed` (cross-checked against holtburger `motion_resolution.rs`) | Post-`EnqueueMotionData` block in `SetCycle`: switches on `motion & 0xFFu` for 0x05/0x06/0x07/0x0F/0x10, sets `CurrentVelocity` from `WalkAnimSpeed=3.12f` / `RunAnimSpeed=4.0f` / `SidestepAnimSpeed=1.25f` constants × `adjustedSpeed`; separately, if `CurrentOmega` is ~zero, synthesizes turn omega `±(π/2)*adjustedSpeed` for 0x0D/0x0E | **Explicitly justified divergence from literal dat content**: "the Humanoid motion table ships every locomotion MotionData with Flags=0x00 (no HasVelocity)" — i.e. the dat itself is silent on velocity for these cycles, and retail's *actual* body-physics source (`get_state_velocity`) is a separate C++ function not expressed as sequencer/dat state at all. acdream folds that separate retail mechanism into `AnimationSequencer.CurrentVelocity` as a pragmatic single surface for consumers (dead-reckoning, `get_state_velocity` Option-B). Comment explicitly documents an earlier bug: a gate of `if (CurrentVelocity.LengthSquared() < 1e-9f)` previously let dat-baked link velocity "win" over synthesis, breaking walk→run transitions — now **unconditionally overwritten** for known locomotion low-bytes. |
|
|
||||||
| `clear_physics` before rebuild | ACE `Sequence.cs L256-L260` | `ClearPhysics()` — zeroes `CurrentVelocity`/`CurrentOmega`, called from `SetCycle` before the enqueue chain | Faithful; matches retail's `sequence.clear_physics()` call before each `add_motion` chain (`MotionTable.cs L100-L101, L152-L153`). |
|
|
||||||
| `add_motion` velocity/omega replace-not-accumulate semantics | ACE `MotionTable.add_motion` (`MotionTable.cs L358-L370`) | `EnqueueMotionData`: `if (motionData.Flags.HasFlag(HasVelocity)) CurrentVelocity = vel;` (replace, not add) similarly for Omega | Faithful — comment explicitly notes this REPLACES not accumulates, and that the final value after link+cycle enqueue is the cycle's (last write wins), which dead-reckoning consumers rely on. |
|
|
||||||
| PlayAction / Modifier & Action-class overlay | ACE `MotionTable.GetObjectSequence` line 189-207 (Action, mask 0x10) and line 234-242 (Modifier, mask 0x20) | `PlayAction(uint motionCommand, float speedMod=1f)` — tries `GetLink` (Links dict) first for Action-mask commands, then falls back to `_mtable.Modifiers` dict (styled key then plain key) for Modifier-mask commands | Faithful dual-path lookup. Notes Jump (`0x2500003B`) has both Action+Modifier-looking bits but ACE treats it via the Modifier path specifically — cited as a cross-check against ACE, not an independent retail decompile of Jump's own class byte. Inserts new non-looping nodes via `_queue.AddBefore(_firstCyclic, n)` (or `AddLast` if no cyclic tail), and force-jumps `_currNode` to the first inserted node if the cursor was currently on the cyclic tail (so overlay actions play immediately instead of waiting for a cycle wrap). |
|
|
||||||
| `Sequence.Velocity`/`Sequence.Omega` sequence-wide mirror | ACE `Sequence.cs L127-130`; `MotionTable.add_motion` L358-370 | `CurrentVelocity`/`CurrentOmega` public properties, described in the doc comment as "not per-node" — reflects the **most recently added** MotionData's velocity×speedMod, even while an earlier link node is still playing visually | Faithful semantic port — explicitly documented gotcha: "while a link animation plays, the surfaced velocity is still the cycle's velocity." |
|
|
||||||
| `HasCycle` (retail cycle-existence probe) | Not a literal retail function name — acdream utility mirroring the head of `SetCycle`'s adjust_motion + cycle-key lookup | `HasCycle(uint style, uint motion) : bool` — duplicates the adjust_motion switch (0x000E/0x0010/0x0006) then checks `_mtable.Cycles.ContainsKey(cycleKey)` | Acdream-invented consumer helper (doc comment explains it exists so callers can fall back to a known-good motion instead of hitting `SetCycle`'s unconditional `ClearCyclicTail` on a missing cycle, which "leaves the body without any animation tail" — visible as "torso on the ground"). Not retail-cited as its own function. |
|
|
||||||
| Reset | Not retail-cited | `Reset()` — clears queue, hooks, root motion, resets Current* fields to defaults | Utility method for entity despawn/respawn recycling; no retail anchor given. |
|
|
||||||
| Diagnostics: `CurrentNodeDiag`, `FirstCyclicAnimRefHash`, `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]` logging | N/A — acdream-only | `CurrentNodeDiag` tuple property (AnimRefHash via `RuntimeHelpers.GetHashCode`, IsLooping, Framerate, StartFrame, EndFrame, FramePosition, QueueCount); `FirstCyclicAnimRefHash`; three `Console.WriteLine` blocks gated on `ACDREAM_REMOTE_VEL_DIAG=1`, throttled to 0.5s via `_lastSetCycleDiagTime` | Entirely acdream-invented instrumentation ("Commit A 2026-05-03" / D2/D3/D4 diagnostic labels) for the remote-motion debugging campaign. Per CLAUDE.md rule 5 (diagnostic owner classes), this is a per-call-site `Environment.GetEnvironmentVariable` read pattern flagged in CLAUDE.md itself as "tech debt; do not add more" — these predate that rule and haven't been migrated to a diagnostic-owner class. |
|
|
||||||
|
|
||||||
## (b) Public API surface — what consumers depend on
|
|
||||||
|
|
||||||
### Constructor
|
|
||||||
```
|
|
||||||
AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
|
|
||||||
```
|
|
||||||
All three args are `ArgumentNullException.ThrowIfNull`'d. Consumers must
|
|
||||||
supply a non-null `Setup`/`MotionTable`/loader — `RenderBootstrap.cs`'s
|
|
||||||
`SequencerFactory` local function has a 3-tier fallback chain (real
|
|
||||||
setup+mtable → real setup + empty `MotionTable()` → fully-empty
|
|
||||||
`Setup()`+`MotionTable()`+`NullAnimLoader()`) precisely because the ctor
|
|
||||||
won't accept nulls.
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
- `bool HasCycle(uint style, uint motion)` — probe before calling `SetCycle`
|
|
||||||
to avoid the "torso on ground" missing-cycle collapse. Called from
|
|
||||||
`GameWindow.cs:3723/3728/3732/3824` (initial spawn cycle selection with
|
|
||||||
Run→Walk→Ready fallback chain) and `RemoteMotionSink.Commit()` (same
|
|
||||||
fallback pattern for remote UM-driven cycles, plus an `ACDREAM_REMOTE_VEL_DIAG`
|
|
||||||
`[HASCYCLE]` diagnostic).
|
|
||||||
- `void SetCycle(uint style, uint motion, float speedMod = 1f, bool
|
|
||||||
skipTransitionLink = false)` — the primary state-transition entry point.
|
|
||||||
Callers across the codebase:
|
|
||||||
- `GameWindow.cs:3751` — initial spawn cycle.
|
|
||||||
- `GameWindow.cs:3825` — initial cycle after a fallback check.
|
|
||||||
- `GameWindow.cs:4830` — Falling-on-jump-start with `skipTransitionLink:
|
|
||||||
true` (K-fix18).
|
|
||||||
- `GameWindow.cs:4936` (`ApplyServerControlledVelocityCycle`) — NPC/monster
|
|
||||||
remotes still on the legacy `ServerControlledLocomotion.PlanFromVelocity`
|
|
||||||
path (pre-S6 unification; comment says this path stays until "S6 unifies
|
|
||||||
all entity classes onto the CMotionInterp funnel").
|
|
||||||
- `GameWindow.cs:5155/5309/9817` — landing cycle / stop-to-ready transitions.
|
|
||||||
- `GameWindow.cs:10223` — local player's own `SetCycle` call (with sidestep
|
|
||||||
speed-scaling correction factor `WalkAnimSpeed/SidestepAnimSpeed*0.5`
|
|
||||||
documented against ACE's `MovementData.cs:124-131` wire formula, #45).
|
|
||||||
- `RemoteMotionSink.Commit()` (`src/AcDream.App/Rendering/RemoteMotionSink.cs:215`)
|
|
||||||
— remote **player** entities via the L.2g S2b `CMotionInterp` funnel path
|
|
||||||
(see below); this is the modern replacement for the legacy path above for
|
|
||||||
player remotes specifically.
|
|
||||||
- `AnimationCommandRouter.RouteFullCommand` — routes `SubState`-class
|
|
||||||
commands here (see router section below).
|
|
||||||
- `void MultiplyCyclicFramerate(float factor)` — not called directly outside
|
|
||||||
`AnimationSequencer.cs` in the searched consumer set (invoked internally
|
|
||||||
by `SetCycle`'s fast-path re-speed branch); no external call sites found
|
|
||||||
in `src/`.
|
|
||||||
- `IReadOnlyList<PartTransform> Advance(float dt)` — per-frame tick. Sole
|
|
||||||
call site: `GameWindow.cs:9876` inside the animated-entity tick loop
|
|
||||||
(`seqFrames = ae.Sequencer.Advance(dt)`), guarded by an
|
|
||||||
`ae.Sequencer is not null` branch; entities without a sequencer fall to a
|
|
||||||
"legacy path" (`ae.CurrFrame += dt * ae.Framerate` manual slerp, line
|
|
||||||
~9896).
|
|
||||||
- `IReadOnlyList<AnimationHook> ConsumePendingHooks()` — drained immediately
|
|
||||||
after `Advance` at `GameWindow.cs:9882`, fanned out per-hook to
|
|
||||||
`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)` (`AnimationHookRouter`,
|
|
||||||
which further fans out to registered `IAnimationHookSink`s).
|
|
||||||
- `(Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()` — no
|
|
||||||
call sites found in the searched `src/` consumer grep; root motion
|
|
||||||
(PosFrames) accumulation exists in the sequencer but nothing currently
|
|
||||||
drains/consumes it in production code (search of `ConsumeRootMotionDelta`
|
|
||||||
and `\.PlayAction\(` etc. above did not surface a caller — **this looks
|
|
||||||
like dead/unwired API surface**, worth flagging for the deep-dive).
|
|
||||||
- `void PlayAction(uint motionCommand, float speedMod = 1f)` — called from
|
|
||||||
`AnimationCommandRouter.RouteFullCommand` for `Action`/`Modifier`/
|
|
||||||
`ChatEmote` route kinds, and directly from `RemoteMotionSink.ApplyMotion`
|
|
||||||
for the same route kinds (overlay dispatch for remote entities).
|
|
||||||
- `void Reset()` — no external call site found in the searched consumer set.
|
|
||||||
|
|
||||||
### Properties (read by consumers)
|
|
||||||
- `CurrentStyle`, `CurrentMotion`, `CurrentSpeedMod` (uint/uint/float,
|
|
||||||
private-set) — read by `GameWindow.cs` (style fallback defaults `0x8000003Du`
|
|
||||||
NonCombat at lines 3723/4827/4919 etc.), `RemoteMotionSink` ctor (seeds
|
|
||||||
`_style` from `sequencer.CurrentStyle`), `RemoteMotionSink.Commit()`
|
|
||||||
(`_sequencer.CurrentMotion`/`CurrentSpeedMod` for diagnostic comparison),
|
|
||||||
`GameWindow.cs:4915` (`ApplyServerControlledVelocityCycle`'s
|
|
||||||
`IsRemoteLocomotion(currentMotion)` gate).
|
|
||||||
- `CurrentVelocity` / `CurrentOmega` (`Vector3`, private-set) — the most
|
|
||||||
cross-cutting output surface:
|
|
||||||
- `GameWindow.cs:9331-9334` — remote player per-tick body translation
|
|
||||||
(`seqVel`/`seqOmega` feed `PositionManager`-style catch-up/anim
|
|
||||||
composition; extensive comment block on lines 9310-9330 walks through
|
|
||||||
retail's `CPartArray::Update` → `PositionManager::adjust_offset` →
|
|
||||||
`Frame::combine` per-tick pipeline this is meant to mirror).
|
|
||||||
- `PlayerMovementController.AttachCycleVelocityAccessor(() =>
|
|
||||||
playerSeq.CurrentVelocity)` (`GameWindow.cs:12917`) — wires the local
|
|
||||||
player's sequencer velocity into `MotionInterpreter.GetCycleVelocity`
|
|
||||||
as an override of the decompiled constant path (`RunAnimSpeed *
|
|
||||||
ForwardSpeed`), used because arbitrary creatures' MotionTables don't all
|
|
||||||
bake `Velocity=4.0` on RunForward the way Humanoid does.
|
|
||||||
- `MotionInterpreter.cs` — comments describe `CurrentVelocity` as "already
|
|
||||||
`MotionData.Velocity * speedMod`" (body-local) and reference it as the
|
|
||||||
accessor payload described above; also cross-checks
|
|
||||||
`AnimationSequencer.CurrentVelocity`/`CurrentOmega` doc pointers at
|
|
||||||
lines 347/376/632/662.
|
|
||||||
- `QueueCount` (int) — diagnostic only (`CurrentNodeDiag` tuple's last
|
|
||||||
field also duplicates it).
|
|
||||||
- `HasCurrentNode` (bool) — diagnostic; no external call site found in
|
|
||||||
the searched set beyond the type itself.
|
|
||||||
- `CurrentNodeDiag` / `FirstCyclicAnimRefHash` — consumed by
|
|
||||||
`GameWindow.cs:9863-9871` inside an `ACDREAM_REMOTE_VEL_DIAG`-gated
|
|
||||||
`[CURRNODE]` log block that compares `AnimRefHash` against
|
|
||||||
`FirstCyclicAnimRefHash` to detect whether the visible animation is
|
|
||||||
actually on the intended cyclic tail.
|
|
||||||
|
|
||||||
### Nested/companion API — `IAnimationLoader` / `DatCollectionLoader`
|
|
||||||
- `IAnimationLoader.LoadAnimation(uint id) : Animation?` — implemented in
|
|
||||||
production by `DatCollectionLoader` (wraps `DatCollection.Get<Animation>`),
|
|
||||||
constructed once in `RenderBootstrap.cs:138` (`animLoader`) and passed by
|
|
||||||
reference into every `SequencerFactory`-built sequencer; a
|
|
||||||
`NullAnimLoader` (referenced but not shown in this file — defined
|
|
||||||
elsewhere) is the last-resort fallback in `RenderBootstrap`'s factory.
|
|
||||||
|
|
||||||
## AnimationCommandRouter.cs — companion routing layer
|
|
||||||
|
|
||||||
Static class, no state. Cited retail anchors:
|
|
||||||
`CMotionTable::GetObjectSequence` 0x00522860,
|
|
||||||
`CMotionInterp::DoInterpretedMotion` 0x00528360, plus
|
|
||||||
`docs/research/deepdives/r03-motion-animation.md` section 3.
|
|
||||||
|
|
||||||
- `Classify(uint fullCommand) : AnimationCommandRouteKind` — classifies by
|
|
||||||
class-mask bits: `0x12000000`/`0x13000000` class → `ChatEmote`;
|
|
||||||
`ModifierMask=0x20000000` → `Modifier`; `ActionMask=0x10000000` →
|
|
||||||
`Action`; `SubStateMask=0x40000000` → `SubState`; `0` → `None`; else
|
|
||||||
`Ignored`. (Note: checks Modifier before Action, meaning any command with
|
|
||||||
both bits set classifies as Modifier — matches the `PlayAction` comment
|
|
||||||
about Jump 0x2500003B having both bits but ACE treating it as Modifier.)
|
|
||||||
- `RouteWireCommand(sequencer, currentStyle, ushort wireCommand, speedMod)` —
|
|
||||||
reconstructs the full 32-bit command via
|
|
||||||
`MotionCommandResolver.ReconstructFullCommand(wireCommand)` then delegates
|
|
||||||
to `RouteFullCommand`.
|
|
||||||
- `RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod)` —
|
|
||||||
dispatch table: `Action`/`Modifier`/`ChatEmote` → `sequencer.PlayAction(fullCommand,
|
|
||||||
speedMod)`; `SubState` → `sequencer.SetCycle(currentStyle, fullCommand,
|
|
||||||
speedMod)`. Returns the classified `AnimationCommandRouteKind` to the
|
|
||||||
caller for logging/branching.
|
|
||||||
|
|
||||||
`AnimationCommandRouteKind` enum: `None, Action, Modifier, ChatEmote,
|
|
||||||
SubState, Ignored`.
|
|
||||||
|
|
||||||
Consumers: `RemoteMotionSink.ApplyMotion` calls `AnimationCommandRouter.Classify`
|
|
||||||
directly (to special-case Turn/Sidestep before falling through) and
|
|
||||||
`RouteFullCommand` for the overlay branch. No other call sites found in the
|
|
||||||
searched consumer set for `AnimationCommandRouter` itself (GameWindow.cs
|
|
||||||
appears to call `Sequencer.SetCycle`/`PlayAction` directly rather than
|
|
||||||
through the router in most of the enumerated call sites above — the router
|
|
||||||
is primarily the remote-entity/funnel-driven path's dispatch layer).
|
|
||||||
|
|
||||||
## AnimationHookRouter.cs / IAnimationHookSink.cs — hook fan-out layer
|
|
||||||
|
|
||||||
- `IAnimationHookSink.OnHook(uint entityId, Vector3 entityWorldPosition,
|
|
||||||
AnimationHook hook)` — single-method interface. `AnimationHook` and its
|
|
||||||
subclasses (`SoundHook`, `SoundTableHook`, `SoundTweakedHook`,
|
|
||||||
`CreateParticleHook`, `DestroyParticleHook`, `StopParticleHook`,
|
|
||||||
`CallPESHook`, `DefaultScriptHook`, `DefaultScriptPartHook`,
|
|
||||||
`AttackHook`, `ReplaceObjectHook`, `TransparentHook`, `LuminousHook`,
|
|
||||||
`DiffuseHook`, `ScaleHook`, `NoDrawHook`, `SetOmegaHook`,
|
|
||||||
`TextureVelocityHook`, `SetLightHook`, `AnimationDoneHook`) are all
|
|
||||||
**external types from `DatReaderWriter.Types`**, not defined in
|
|
||||||
acdream's own tree — they're dat-format hook payload classes. The doc
|
|
||||||
comment enumerates intended downstream routing (Phase E.2 audio / E.3
|
|
||||||
particles / E.4 combat dispatcher / renderer state mutation / UI
|
|
||||||
notifications) but this file itself contains no subsystem logic — it's
|
|
||||||
purely the interface contract + a `NullAnimationHookSink` no-op
|
|
||||||
singleton for tests/headless.
|
|
||||||
- `AnimationHookRouter : IAnimationHookSink` — composite/fan-out
|
|
||||||
implementation. `Register(sink)` / `Unregister(sink)` are lock-protected
|
|
||||||
(`_gate`), copy-on-write into a `IAnimationHookSink[]` array (idempotent
|
|
||||||
register — checks `ReferenceEquals` before adding). `OnHook` iterates a
|
|
||||||
snapshot array **without locking** (explicitly commented "no lock in the
|
|
||||||
hot path (render thread)") and wraps each sink's `OnHook` call in a
|
|
||||||
`try/catch` that **silently swallows all exceptions** ("one misbehaving
|
|
||||||
sink must not take down the entire animation tick... individual
|
|
||||||
subsystems can log their own errors internally") — this is a blanket
|
|
||||||
catch-and-discard, worth flagging against CLAUDE.md's "no workarounds"
|
|
||||||
spirit if a sink is silently failing (per the user's `feedback_logger_injection_for_silent_catches.md`
|
|
||||||
memory note: libs that silently catch+return null usually should inject
|
|
||||||
an `ILogger`; this router has no logger injection point at all).
|
|
||||||
`Sinks` property exposes a read-only snapshot for diagnostics/tests.
|
|
||||||
|
|
||||||
Only one production sink registration path was located via the router's own
|
|
||||||
file (none) — sink registration call sites (`_hookRouter.Register(...)`)
|
|
||||||
were not enumerated in this pass; the single confirmed dispatch call site
|
|
||||||
is `GameWindow.cs:9890` (`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)`).
|
|
||||||
|
|
||||||
## How MotionData/anim dat structures reach the sequencer
|
|
||||||
|
|
||||||
- **Setup** (`DatReaderWriter.DBObjs.Setup`) — supplies `Parts.Count` (used
|
|
||||||
by `Advance`/`BuildBlendedFrame`/`BuildIdentityFrame` to size the
|
|
||||||
`PartTransform[]` output) and `DefaultMotionTable` id (used by
|
|
||||||
`RenderBootstrap.SequencerFactory` to look up the paired `MotionTable`).
|
|
||||||
- **MotionTable** (`DatReaderWriter.DBObjs.MotionTable`) — three
|
|
||||||
dictionaries consumed directly:
|
|
||||||
- `Cycles : Dictionary<int, MotionData>` keyed by
|
|
||||||
`(style<<16)|(adjustedMotion&0xFFFFFF)` — the looping tail source,
|
|
||||||
read in `SetCycle`/`HasCycle`.
|
|
||||||
- `Links : Dictionary<int, MotionCommandData>` where
|
|
||||||
`MotionCommandData.MotionData : Dictionary<int, MotionData>` — the
|
|
||||||
transition-frame source, read in `GetLink` (both forward and
|
|
||||||
reversed-key branches) and in `PlayAction`'s Action-mask lookup.
|
|
||||||
- `Modifiers : Dictionary<int, MotionData>` — the overlay/action source
|
|
||||||
for Modifier-mask commands in `PlayAction`, tried with a styled key
|
|
||||||
first then a plain (unstyled) key.
|
|
||||||
- `StyleDefaults : Dictionary<MotionCommand, MotionCommand>` — used only
|
|
||||||
inside `GetLink`'s reversed-direction fallback branch.
|
|
||||||
- **MotionData** (per Cycles/Links/Modifiers entry) — `Anims :
|
|
||||||
List<AnimData>`; `Velocity`/`Omega : Vector3`; `Flags :
|
|
||||||
MotionDataFlags` (`HasVelocity=0x01`, `HasOmega=0x02`) gates whether
|
|
||||||
`Velocity`/`Omega` are applied or zeroed in `EnqueueMotionData` and
|
|
||||||
`PlayAction`.
|
|
||||||
- **AnimData** — `AnimId : QualifiedDataId<Animation>` (cast to `uint` for
|
|
||||||
`IAnimationLoader.LoadAnimation`), `LowFrame`/`HighFrame` (sentinel
|
|
||||||
`HighFrame == -1` meaning "all frames", resolved in `LoadAnimNode`),
|
|
||||||
`Framerate` (float, multiplied by `speedMod` to produce the `double
|
|
||||||
Framerate` stored on `AnimNode`).
|
|
||||||
- **Animation** (loaded via `IAnimationLoader.LoadAnimation(animId)`) —
|
|
||||||
`PartFrames : List<AnimationFrame>` (drives `numFrames` bounds-clamping
|
|
||||||
in `LoadAnimNode` and the per-part lookup in `BuildBlendedFrame`);
|
|
||||||
`PosFrames : List<Frame>` (root motion, gated by `Flags.HasFlag(PosFrames)`
|
|
||||||
AND `PosFrames.Count >= numFrames`); `Flags : AnimationFlags`.
|
|
||||||
- **AnimationFrame** — `Frames : List<Frame>` (one `Frame` per Setup part —
|
|
||||||
indexed by part index in `BuildBlendedFrame`'s `f0Parts`/`f1Parts`
|
|
||||||
lookups, defaulting to identity for parts beyond the frame's list length),
|
|
||||||
`Hooks : List<AnimationHook>` (read in `ExecuteHooks`).
|
|
||||||
- **Frame** — `Origin : Vector3`, `Orientation : Quaternion` — the raw
|
|
||||||
per-part or per-posframe pose sample, lerped/slerped in
|
|
||||||
`BuildBlendedFrame` or combined in `ApplyPosFrame`.
|
|
||||||
|
|
||||||
Entry point that ties Setup+MotionTable+Loader together for a live entity:
|
|
||||||
`RenderBootstrap.cs`'s `SequencerFactory(WorldEntity e)` local function
|
|
||||||
(lines ~147-174, moved verbatim from the pre-extraction `GameWindow.cs`
|
|
||||||
~2306-2334 per its own comment) — looks up `Setup` via
|
|
||||||
`dats.Get<Setup>(e.SourceGfxObjOrSetupId)`, then `MotionTable` via
|
|
||||||
`dats.Get<MotionTable>(setup.DefaultMotionTable)`, falling back through
|
|
||||||
three tiers (real/real → real-setup/empty-mtable → empty/empty) so the
|
|
||||||
constructor's null-guards are never tripped for any spawned entity, even
|
|
||||||
ones missing dat data.
|
|
||||||
|
|
||||||
## (c) acdream-invented vs dat-driven — summary classification
|
|
||||||
|
|
||||||
**Purely dat-driven (faithful mechanical port of retail's node/frame model):**
|
|
||||||
- Node list structure, link resolution forward-path, `GetStartFramePosition`/
|
|
||||||
`GetEndFramePosition`, `multiply_framerate` semantics (modulo the
|
|
||||||
documented Start/EndFrame-swap simplification), `update_internal`'s
|
|
||||||
frame-boundary walk + hook firing + root-motion accumulation,
|
|
||||||
`advance_to_next_animation` wrap, `execute_hooks` direction matching,
|
|
||||||
the retail slerp, `clear_physics`/`add_motion` replace-semantics for
|
|
||||||
Velocity/Omega, PlayAction's Action/Modifier dual-path lookup.
|
|
||||||
|
|
||||||
**acdream-invented (no retail citation, or explicitly-flagged deviation/workaround):**
|
|
||||||
1. **K-fix18 `skipTransitionLink`** — product decision to skip the retail
|
|
||||||
transition-link pose for Falling-on-jump-start; not retail behavior.
|
|
||||||
2. **Fix B locomotion cyclic→cyclic link-skip** — scoped fix verified via
|
|
||||||
live cdb trace (so it IS retail-faithful for that subset), but the
|
|
||||||
*mechanism* (removing the enqueued link node, forcing `_currNode` onto
|
|
||||||
`_firstCyclic`) is acdream's own structural patch, not a literal port
|
|
||||||
of a retail function.
|
|
||||||
3. **Stale-head `_currNode` force-relocation** (`preEnqueueTail`/`firstNew`
|
|
||||||
tracking) — acdream bug-fix for a structural mismatch versus retail's
|
|
||||||
apparent behavior; no retail citation for the mechanism itself.
|
|
||||||
2. **Stop-anim fallback** (direction-agnostic settle-link retry) — acdream
|
|
||||||
workaround for a null-linkData gap.
|
|
||||||
5. **`GetLink`'s reversed-direction branch** — while individually justified
|
|
||||||
against an *observed* bug (X-key twitch), it's presented as acdream's
|
|
||||||
own two-branch generalization of `get_link`, cross-checked against ACE
|
|
||||||
line ranges rather than a single retail function decompile.
|
|
||||||
6. **CurrentVelocity/CurrentOmega synthesis for locomotion/turn cycles**
|
|
||||||
(`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f`
|
|
||||||
constants, turn `±π/2` synthesis) — folds a *separate* retail C++
|
|
||||||
mechanism (`CMotionInterp::get_state_velocity`, a physics-side
|
|
||||||
function) into the *sequencer's* velocity surface because the
|
|
||||||
Humanoid dat itself carries no baked velocity for these MotionData
|
|
||||||
entries. Functionally retail-faithful in intent but architecturally a
|
|
||||||
deviation (retail keeps this computation outside the Sequence object
|
|
||||||
entirely).
|
|
||||||
7. **BuildBlendedFrame's non-looping boundary-hold** (issue #61 fix) — an
|
|
||||||
empirically-motivated patch for the link→cycle boundary flash, without
|
|
||||||
a cited retail mechanism describing how the real client avoids this
|
|
||||||
flash. **This is exactly the class of bug the animation-sequencer
|
|
||||||
deep-dive skill was built to root-cause** — the current fix may be
|
|
||||||
masking rather than replicating retail's actual boundary behavior.
|
|
||||||
8. **`safety = 64` loop cap in `Advance`** — defensive engineering, no
|
|
||||||
retail citation.
|
|
||||||
9. **All `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]/[CURRNODE]` diagnostics** —
|
|
||||||
acdream-only instrumentation, per-call-site env var reads (flagged by
|
|
||||||
CLAUDE.md itself as a tech-debt pattern that shouldn't be extended
|
|
||||||
further without promotion to a diagnostic-owner class per Code
|
|
||||||
Structure Rule 5).
|
|
||||||
10. **`AnimationDoneSentinel` as a single shared static instance** — works
|
|
||||||
for "some link just finished" notifications but can't carry per-fire
|
|
||||||
identity; not verified against how retail's `AnimDoneHook` actually
|
|
||||||
carries context.
|
|
||||||
|
|
||||||
**Unwired / apparently dead API surface found during this pass:**
|
|
||||||
- `ConsumeRootMotionDelta()` — no call site found in `src/` outside the
|
|
||||||
sequencer itself. Root motion (PosFrames) is accumulated every tick but
|
|
||||||
nothing drains it into entity placement.
|
|
||||||
- `Reset()` — no external call site found in the searched consumer set.
|
|
||||||
- `MultiplyCyclicFramerate` — only self-invoked by `SetCycle`'s fast path;
|
|
||||||
no direct external callers.
|
|
||||||
- `HasCurrentNode` — no external call site found beyond its own
|
|
||||||
declaration.
|
|
||||||
|
|
||||||
## Cross-references worth pulling into the deep-dive
|
|
||||||
|
|
||||||
- `docs/research/acclient_animation_pseudocode.md` — cited pseudocode doc
|
|
||||||
for FUN_005267E0/FUN_00525EB0/FUN_00526880/FUN_005268B0/FUN_005360d0/
|
|
||||||
FUN_005261D0 (sections 5-7).
|
|
||||||
- `docs/research/deepdives/r03-motion-animation.md` section 3 — cited by
|
|
||||||
`AnimationCommandRouter.cs` for the class-mask routing scheme.
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs` and
|
|
||||||
`MotionTable.cs` — the ACE C# port cross-referenced throughout
|
|
||||||
(`Sequence.cs:127-130, 256-260, 262-270, 277-287, 351-443`;
|
|
||||||
`MotionTable.cs:100-101, 132-139, 152-153, 358-370, 372-379, 395-426`).
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs:394-428`
|
|
||||||
— `adjust_motion` source.
|
|
||||||
- `docs/research/2026-06-04-animation-sequencer-deep-dive.md` (per
|
|
||||||
MEMORY.md) — prior deep-dive already exists; ranked 8 divergences
|
|
||||||
including "missing pending_motions/MotionDone HIGH" — **this current
|
|
||||||
file does not implement any `pending_motions` concept** (no field named
|
|
||||||
that, no queueing of *future* motions ahead of the current queue beyond
|
|
||||||
the single link+cycle model) — worth checking whether that HIGH-ranked
|
|
||||||
gap from the prior research drop is still open against this current
|
|
||||||
implementation.
|
|
||||||
|
|
@ -1,583 +0,0 @@
|
||||||
# ACE CSequence port map — cross-reference vs. 2013 retail decomp
|
|
||||||
|
|
||||||
Scope: `references/ACE/Source/ACE.Server/Physics/Animation/{Sequence.cs, AnimSequenceNode.cs,
|
|
||||||
AnimData.cs, AFrame.cs}` + `MotionTable.cs`'s `add_motion/combine_motion/subtract_motion/change_cycle_speed`.
|
|
||||||
Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (`CSequence::*`,
|
|
||||||
`AnimSequenceNode::*`, free functions `add_motion`/`combine_motion`/`subtract_motion`/`change_cycle_speed`)
|
|
||||||
+ verbatim struct defs in `docs/research/named-retail/acclient.h` (line 30747 `CSequence`, line 31063
|
|
||||||
`AnimSequenceNode`).
|
|
||||||
|
|
||||||
## HEADLINE DIVERGENCE (all downstream methods inherit this)
|
|
||||||
|
|
||||||
**`Sequence.FrameNumber` is `float` in ACE; retail's `CSequence::frame_number` is `long double`
|
|
||||||
(80-bit x87 extended precision), not even `double`.**
|
|
||||||
`acclient.h:30754`: `long double frame_number;`
|
|
||||||
Every retail arithmetic op on frame_number/timeElapsed in `update_internal`,
|
|
||||||
`advance_to_next_animation`, `apply_physics` explicitly upcasts to `(long double)` before the
|
|
||||||
op and downcasts back only where storing into a `float` field (velocity/omega components,
|
|
||||||
`AnimSequenceNode::framerate`). The comparisons that decide "did we cross a frame boundary" run
|
|
||||||
at x87-extended precision in retail, at `float` precision in ACE.
|
|
||||||
Public entry point confirms the wire type: `CSequence::update(class CSequence* this, double arg2, class Frame* arg3)`
|
|
||||||
@ `0x00525b80` — the *external* `quantum` parameter is `double`, matching `MotionInterp` callers
|
|
||||||
(ACE's `Sequence.Update(float quantum, ...)` is `float`). Internally retail immediately treats it
|
|
||||||
as `long double` in the callee.
|
|
||||||
**Practical effect:** frame-boundary crossing math (`Math.Floor(frameNum) > lastFrame`,
|
|
||||||
`get_high_frame() < Math.Floor(frameNum)`) can disagree between ACE (float) and retail (80-bit)
|
|
||||||
at the ULP level near exact frame boundaries — this is a plausible root cause for
|
|
||||||
subtle frame-swap / off-by-one animation bugs when the accumulated quantum sum lands very close
|
|
||||||
to an integer frame number. acdream's own port should use `double` at minimum (C# doesn't expose
|
|
||||||
80-bit extended); pure `float` (ACE's choice) is the most divergent option available.
|
|
||||||
|
|
||||||
## `Sequence.cs` (ACE) ↔ `CSequence` (retail) — per-method map
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
| ACE field | Retail field (`acclient.h:30747`) | Type match? |
|
|
||||||
|---|---|---|
|
|
||||||
| `int ID` | (not in CSequence; ACE-only bookkeeping, no retail equivalent found in struct) | N/A |
|
|
||||||
| `LinkedList<AnimSequenceNode> AnimList` + `FirstCyclic`/`CurrAnim` as `LinkedListNode<T>` | `DLList<AnimSequenceNode> anim_list` (intrusive doubly-linked list) + `AnimSequenceNode *first_cyclic` + `AnimSequenceNode *curr_anim` | Structural match; ACE trades retail's intrusive DLList for `System.Collections.Generic.LinkedList<T>`, semantically equivalent but allocates node wrappers separately (see `apricot()` note below) |
|
|
||||||
| `Vector3 Velocity` | `AC1Legacy::Vector3 velocity` | float×3, match |
|
|
||||||
| `Vector3 Omega` | `AC1Legacy::Vector3 omega` | float×3, match |
|
|
||||||
| `PhysicsObj HookObj` | `CPhysicsObj *hook_obj` | match |
|
|
||||||
| `float FrameNumber` | `long double frame_number` | **DIVERGENT — see headline** |
|
|
||||||
| `AnimationFrame PlacementFrame` | `AnimFrame *placement_frame` | match |
|
|
||||||
| `int PlacementFrameID` | `unsigned int placement_frame_id` | match (signedness cosmetic) |
|
|
||||||
| `bool IsTrivial` | `int bIsTrivial` | match (never read/written elsewhere in ACE's Sequence.cs — dead field there too) |
|
|
||||||
|
|
||||||
### Constructor / `Init()`
|
|
||||||
- Retail `CSequence::CSequence` (`0x005249f0`): `memset(&anim_list, 0, 0x28)` then
|
|
||||||
`memset(&frame_number, 0, 0x18)` — i.e. zeroes `frame_number`, `curr_anim`, `placement_frame`,
|
|
||||||
`placement_frame_id`, `bIsTrivial` in one sweep. Velocity/omega/first_cyclic/hook_obj are
|
|
||||||
zeroed by the first memset (part of `anim_list`+`first_cyclic`+`velocity`+`omega`+`hook_obj`
|
|
||||||
span, 0x28 bytes).
|
|
||||||
- ACE `Init()`: sets `Velocity = Zero`, `Omega = Zero`, `FrameNumber = 0.0f`, `AnimList = new()`.
|
|
||||||
Does **not** reset `CurrAnim`, `FirstCyclic`, `HookObj`, `PlacementFrame`,
|
|
||||||
`PlacementFrameID`, `IsTrivial` — those are left at C# default (null/0) only because `Init()`
|
|
||||||
is only ever called from the ctor in practice. Faithful for the ctor path; would silently diverge
|
|
||||||
if `Init()` were ever called again on a live Sequence (retail's memset always re-zeros
|
|
||||||
everything; ACE's `Init()` does not).
|
|
||||||
|
|
||||||
### `Update(quantum, ref offsetFrame)` ↔ `CSequence::update` (`0x00525b80`)
|
|
||||||
Exact 1:1 structural match:
|
|
||||||
```
|
|
||||||
retail: if (anim_list.head_ != 0) { update_internal(...); apricot(); return; }
|
|
||||||
else if (arg3 != null) apply_physics(arg3, arg2, arg2);
|
|
||||||
ACE: if (AnimList.First != null) { update_internal(...); apricot(); }
|
|
||||||
else if (offsetFrame != null) apply_physics(offsetFrame, quantum, quantum);
|
|
||||||
```
|
|
||||||
Param type: retail `arg2` is `double` at this boundary (external quantum). ACE's `quantum` is
|
|
||||||
`float`. See headline.
|
|
||||||
|
|
||||||
### `advance_to_next_animation` ↔ `CSequence::advance_to_next_animation` (`0x005252b0`)
|
|
||||||
Retail signature: `(this, double arg2 /*timeElapsed*/, AnimSequenceNode** arg3 /*animNode*/,
|
|
||||||
double* arg4 /*frameNum*/, Frame* arg5 /*frame*/)`.
|
|
||||||
Structurally identical two-branch dispatch on `timeElapsed >= 0.0`:
|
|
||||||
- **Forward branch** (`timeElapsed >= 0`): if `frame != null && currAnim.Framerate < 0` (i.e.
|
|
||||||
finishing a *reverse-playing* anim), subtract `get_pos_frame(frameNum)`, apply_physics with
|
|
||||||
`1/framerate` if `|framerate| > EPSILON`. Then advance `animNode` to `.Next` or wrap to
|
|
||||||
`FirstCyclic`. `frameNum = currAnim.get_starting_frame()`. If `frame != null && framerate > 0`
|
|
||||||
(starting a *forward-playing* anim), combine pos_frame, apply_physics.
|
|
||||||
ACE's `advance_to_next_animation` (`Sequence.cs:145`) matches line-for-line, including the
|
|
||||||
`Math.Abs(currAnim.Framerate) > PhysicsGlobals.EPSILON` guards on `apply_physics`.
|
|
||||||
- **Backward branch** (`timeElapsed < 0`): mirror image — subtract when `framerate >= 0`
|
|
||||||
(finishing forward-playing), step to `.Previous` or wrap to `List.Last`, `frameNum =
|
|
||||||
get_ending_frame()`, combine when `framerate < 0` (starting reverse-playing).
|
|
||||||
ACE matches.
|
|
||||||
- EPSILON constant used for `|framerate|` compares: retail literal `0.000199999995f` ≈
|
|
||||||
`0.0002f` = `ACE.Server.Physics.PhysicsGlobals.EPSILON` (`references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9`). Confirmed identical constant.
|
|
||||||
|
|
||||||
### `append_animation` ↔ `CSequence::append_animation` (`0x00525510`)
|
|
||||||
Retail: allocate `AnimSequenceNode(arg2)`; if `has_anim()` fails, delete + return (no-op). Else
|
|
||||||
insert at tail of `anim_list`, set `first_cyclic = tail` (**every** append moves `first_cyclic`
|
|
||||||
to the newest node — i.e. "cyclic" region is always exactly the last-appended anim until removed).
|
|
||||||
If `curr_anim == null`: set `curr_anim = head`, `frame_number = get_starting_frame(head)` (or,
|
|
||||||
in an unreachable dead branch, `get_starting_frame(nullptr)` if head is somehow still null after
|
|
||||||
just inserting — BinNinja artifact, not real control flow).
|
|
||||||
ACE (`Sequence.cs:203`) matches: `if (!node.has_anim()) return;` — but ACE does NOT delete the
|
|
||||||
orphan node explicit (no-op is fine in GC'd C#, matches retail's leak-avoidance intent). `AnimList.AddLast`, `FirstCyclic = AnimList.Last`, `if (CurrAnim == null) { CurrAnim = AnimList.First; FrameNumber = CurrAnim.Value.get_starting_frame(); }`. Faithful.
|
|
||||||
|
|
||||||
### `apply_physics(frame, quantum, sign)` ↔ `CSequence::apply_physics` (`0x00524ab0`)
|
|
||||||
Retail: `quantum = sign>=0 ? |quantum| : -|quantum|` (sign-copy pattern — note retail's param
|
|
||||||
order is `(Frame*, double quantum-as-arg3, double sign-as-arg4)`, i.e. **arg3 is the magnitude
|
|
||||||
source, arg4 is only used for its sign**). Then `Origin += Velocity * quantum` per-axis (retail
|
|
||||||
does each axis as a separate cast-heavy expression — no semantic difference), `Frame::rotate(Omega
|
|
||||||
* quantum)`.
|
|
||||||
ACE (`Sequence.cs:221`): `if (sign>=0.0) quantum=Abs(quantum); else quantum=-Abs(quantum); frame.Origin += Velocity*quantum; frame.Rotate(Omega*quantum);` — exact match. All arithmetic in
|
|
||||||
retail runs at `long double`; ACE at `float`. Same headline-precision divergence as FrameNumber.
|
|
||||||
|
|
||||||
### `apricot()` ↔ `CSequence::apricot` (`0x00524b40`)
|
|
||||||
Purpose: after `update_internal` may have advanced `curr_anim` forward past older entries,
|
|
||||||
prune any anim nodes from `anim_list.head_` up to (but not including) `curr_anim`, UNLESS we hit
|
|
||||||
`first_cyclic` first (in which case stop — don't prune into the cyclic region).
|
|
||||||
Retail: `i = head; if (i != curr_anim) { while (i != first_cyclic) { if (i == first_cyclic)
|
|
||||||
break; delete-unlink(i); i = head; if (i == curr_anim) break; } }` — i.e. loop re-reads `head`
|
|
||||||
after every unlink (since unlinking changes what head is), and has a **redundant double check**
|
|
||||||
of `i == first_cyclic` (once as the while-condition, once again as the first statement inside
|
|
||||||
the loop before the delete — likely because retail's `while` condition is evaluated at the *top*,
|
|
||||||
and the body immediately re-checks in case the initial `head` already equals `first_cyclic`, which
|
|
||||||
would only be reachable if `i != curr_anim` was somehow also true — defensive but effectively the
|
|
||||||
same predicate twice).
|
|
||||||
ACE (`Sequence.cs:232`):
|
|
||||||
```csharp
|
|
||||||
var node = AnimList.First;
|
|
||||||
while (!node.Equals(CurrAnim)) {
|
|
||||||
if (node.Equals(FirstCyclic)) break;
|
|
||||||
AnimList.Remove(node);
|
|
||||||
node = AnimList.First;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Semantically equivalent to retail (loop-while-not-curr-anim, break-if-first-cyclic, remove head,
|
|
||||||
re-read head). Faithful port; the double-check redundancy in retail's disassembly collapses to
|
|
||||||
ACE's single `if` because C#'s `while(cond)` + `if(cond) break` at the top of the body is exactly
|
|
||||||
retail's structure once the duplicate compiler artifact is discounted.
|
|
||||||
|
|
||||||
### `clear_animations()` ↔ `CSequence::clear_animations` (`0x00524dc0`)
|
|
||||||
Retail: pop every node off `anim_list` (unlink+delete each), then `first_cyclic = nullptr`,
|
|
||||||
`frame_number = 0`, `curr_anim = nullptr`.
|
|
||||||
ACE: `AnimList.Clear(); FirstCyclic = null; FrameNumber = 0; CurrAnim = null;` — exact match
|
|
||||||
(GC handles the per-node delete implicitly).
|
|
||||||
|
|
||||||
### `clear_physics()` ↔ `CSequence::clear_physics` (`0x00524d50`)
|
|
||||||
Retail: zero `velocity` and `omega` component-wise. ACE: `Velocity = Vector3.Zero; Omega =
|
|
||||||
Vector3.Zero;`. Match.
|
|
||||||
|
|
||||||
### `Clear()` ↔ `CSequence::clear` (`0x005255b0`)
|
|
||||||
Retail: `clear_animations(); clear_physics();` — does **NOT** touch `placement_frame` /
|
|
||||||
`placement_frame_id` in the retail disasm shown (only two calls visible at `0x005255b3`/
|
|
||||||
`0x005255ba`). ACE's `Clear()` (`Sequence.cs:71`) additionally sets `PlacementFrame = null;
|
|
||||||
PlacementFrameID = 0;` — **this is an ACE-only addition beyond what the two-instruction retail
|
|
||||||
`clear()` body does.** Worth flagging as a possible ACE embellishment/bug if a future port
|
|
||||||
strictly mirrors retail's `clear()`; however note ACE's own `~CSequence`/dtor is not modeled at
|
|
||||||
all (C# has no destructor equivalent needed), so this may be ACE compensating for a different owner-lifecycle assumption. Flag for acdream: **do not blindly copy ACE's `Clear()` — verify whether placement-frame reset belongs here or only in `UnPack`** (retail's `UnPack` explicitly nulls `placement_frame`/`placement_frame_id` — see below).
|
|
||||||
|
|
||||||
### `remove_cyclic_anims()` ↔ `CSequence::remove_cyclic_anims` (`0x00524e40`)
|
|
||||||
Retail: iterate from `first_cyclic` forward (`AnimSequenceNode::GetNext`); for each node, if
|
|
||||||
`curr_anim == node`: set `curr_anim = GetPrev(node)`; if that prev is null, `frame_number = 0`,
|
|
||||||
else `frame_number = get_ending_frame(prev)`. Then unlink+delete `node` regardless. After the
|
|
||||||
loop, `first_cyclic = anim_list.tail_` (or null if list now empty — implied by the trailing code
|
|
||||||
not fully captured above but consistent with ACE).
|
|
||||||
ACE (`Sequence.cs:303`):
|
|
||||||
```csharp
|
|
||||||
var node = FirstCyclic;
|
|
||||||
while (node != null) {
|
|
||||||
if (CurrAnim.Equals(node)) {
|
|
||||||
CurrAnim = node.Previous;
|
|
||||||
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
|
|
||||||
else FrameNumber = 0.0f;
|
|
||||||
}
|
|
||||||
var next = node.Next;
|
|
||||||
AnimList.Remove(node.Value);
|
|
||||||
node = next;
|
|
||||||
}
|
|
||||||
FirstCyclic = AnimList.Last;
|
|
||||||
```
|
|
||||||
Faithful — matches retail's per-node dispose-then-advance and the final `first_cyclic = tail`
|
|
||||||
reset.
|
|
||||||
|
|
||||||
### `remove_link_animations(amount)` ↔ `CSequence::remove_link_animations` (`0x00524be0`)
|
|
||||||
Retail: loop `amount` times; each iteration, if `GetPrev(first_cyclic) == null` return early
|
|
||||||
(no more link anims to remove); if `curr_anim == GetPrev(first_cyclic)`, snap `curr_anim =
|
|
||||||
first_cyclic` and `frame_number = get_starting_frame(first_cyclic)`; then unlink+delete
|
|
||||||
`GetPrev(first_cyclic)`.
|
|
||||||
ACE (`Sequence.cs:324`) matches exactly, including the early-return-not-break semantics
|
|
||||||
(`if (FirstCyclic.Previous == null) return;` inside the `for` loop — matches retail's `break`-out-of-do-while-then-return-since-nothing-else-follows pattern).
|
|
||||||
|
|
||||||
### `remove_all_link_animations()` ↔ `CSequence::remove_all_link_animations` (`0x00524ca0`)
|
|
||||||
Retail: `while (first_cyclic != null && GetPrev(first_cyclic) != null) { same snap-then-delete
|
|
||||||
pattern as remove_link_animations, unbounded }`.
|
|
||||||
ACE (`Sequence.cs:289`): `while (FirstCyclic != null && FirstCyclic.Previous != null) { if
|
|
||||||
(CurrAnim.Equals(FirstCyclic.Previous)) { CurrAnim = FirstCyclic; if (CurrAnim != null)
|
|
||||||
FrameNumber = CurrAnim.Value.get_starting_frame(); } AnimList.Remove(FirstCyclic.Previous); }`
|
|
||||||
Match.
|
|
||||||
|
|
||||||
### `execute_hooks(animFrame, dir)` ↔ `CSequence::execute_hooks` (`0x00524830`)
|
|
||||||
Retail: `if (hook_obj != 0) for (hook in animFrame->hooks) if (hook.direction_ == 0 /*Both*/ ||
|
|
||||||
dir == hook.direction_) hook_obj->add_anim_hook(hook)`.
|
|
||||||
ACE (`Sequence.cs:262`): `if (animFrame == null || HookObj == null) return; foreach (hook in
|
|
||||||
animFrame.Hooks) if (hook.Direction == Both || hook.Direction == dir) HookObj.add_anim_hook(hook);`
|
|
||||||
Match (ACE adds an explicit `animFrame == null` guard retail doesn't need because retail always
|
|
||||||
passes a valid `arg2` from `AnimSequenceNode::get_part_frame` which can itself return null —
|
|
||||||
retail's caller `update_internal` still calls `execute_hooks` unconditionally with a
|
|
||||||
possibly-null frame pointer, then retail's `execute_hooks` dereferences `arg2->hooks` **without
|
|
||||||
a null check** at `0x00524844`. **This is a latent null-deref risk in retail itself** if
|
|
||||||
`get_part_frame` returns null for an out-of-range frame index — ACE's added `animFrame == null`
|
|
||||||
guard is a defensive divergence, not a bug, and is the *correct* choice for a managed port. Note
|
|
||||||
this in case a "PARTSDIAG null-guard" investigation (per project memory) surfaces this exact
|
|
||||||
retail-side latent null-deref as the root cause of a real bug.)
|
|
||||||
|
|
||||||
### `get_curr_frame_number()` ↔ `CSequence::get_curr_frame_number` (`0x005249d0`)
|
|
||||||
Retail: `floor(frame_number); return (int)floor_result` (`_ftol2` = truncating float-to-long
|
|
||||||
after `floor`). ACE: `(int)Math.Floor(FrameNumber)`. Match, modulo the float-vs-long-double
|
|
||||||
headline divergence.
|
|
||||||
|
|
||||||
### `get_curr_animframe()` ↔ `CSequence::get_curr_animframe` (`0x00524970`)
|
|
||||||
Retail: `if (curr_anim == null) return placement_frame; return curr_anim->get_part_frame((int)floor(frame_number));`
|
|
||||||
ACE `GetCurrAnimFrame()` (`Sequence.cs:89`): `if (CurrAnim == null) return PlacementFrame; return
|
|
||||||
CurrAnim.Value.get_part_frame(get_curr_frame_number());` where `get_curr_frame_number()` is the
|
|
||||||
same floor+truncate. Match.
|
|
||||||
|
|
||||||
### `set_placement_frame` / `set_velocity` / `set_omega` / `combine_physics` / `subtract_physics`
|
|
||||||
All four are trivial field setters/accumulators in both retail and ACE — verified byte-for-byte
|
|
||||||
equivalent logic (`Sequence.cs:111-130`, `:83-87`, `:345-349`). No divergence.
|
|
||||||
|
|
||||||
### `multiply_cyclic_animation_framerate(rate)` ↔ `CSequence::multiply_cyclic_animation_fr` (`0x00524940`)
|
|
||||||
Retail: `for (n = first_cyclic; n != null; n = GetNext(n)) AnimSequenceNode::multiply_framerate(n, rate);`
|
|
||||||
ACE (`Sequence.cs:277`): identical loop over `FirstCyclic`. Match.
|
|
||||||
|
|
||||||
### `update_internal(timeElapsed, ref animNode, ref frameNum, ref frame)` ↔ `CSequence::update_internal` (`0x005255d0`)
|
|
||||||
This is the core per-tick state machine; retail's decompiled x87 soup (heavy `long double`
|
|
||||||
comparison-flag reconstruction, unresolved `fld`/`fcomp` mnemonics the decompiler couldn't
|
|
||||||
symbolize) obscures direct reading, but the **control-flow skeleton is fully recoverable** and
|
|
||||||
matches ACE 1:1:
|
|
||||||
```
|
|
||||||
lastFrame = floor(frameNum)
|
|
||||||
frametime = framerate * timeElapsed
|
|
||||||
frameNum += frametime
|
|
||||||
frameTimeElapsed = 0
|
|
||||||
animDone = false
|
|
||||||
if (frametime > 0):
|
|
||||||
if (get_high_frame() < floor(frameNum)):
|
|
||||||
frameOffset = frameNum - get_high_frame() - 1; clamp to >=0
|
|
||||||
if |framerate| > EPS: frameTimeElapsed = frameOffset / framerate
|
|
||||||
frameNum = get_high_frame()
|
|
||||||
animDone = true
|
|
||||||
while floor(frameNum) > lastFrame:
|
|
||||||
if frame != null:
|
|
||||||
combine pos_frame(lastFrame) into frame (if pos_frames != null)
|
|
||||||
if |framerate| > EPS: apply_physics(frame, 1/framerate, timeElapsed)
|
|
||||||
execute_hooks(get_part_frame(lastFrame), Forward)
|
|
||||||
lastFrame++
|
|
||||||
elif (frametime < 0):
|
|
||||||
[mirror: get_low_frame(), subtract instead of combine, Backward hooks, lastFrame--]
|
|
||||||
else:
|
|
||||||
if frame != null && |timeElapsed| > EPS: apply_physics(frame, timeElapsed, timeElapsed)
|
|
||||||
|
|
||||||
if (!animDone): return
|
|
||||||
if (hook_obj != null && head != first_cyclic): hook_obj->add_anim_hook(AnimDoneHook)
|
|
||||||
advance_to_next_animation(timeElapsed, ref animNode, ref frameNum, ref frame)
|
|
||||||
timeElapsed = frameTimeElapsed
|
|
||||||
[LOOP back to top] <-- retail implements this as an actual `while(true)` loop with the
|
|
||||||
call to advance_to_next_animation + timeElapsed reassignment,
|
|
||||||
THEN re-enters the top of the function body (0x005255e8 label).
|
|
||||||
```
|
|
||||||
ACE (`Sequence.cs:351`) implements the exact same skeleton but as **explicit tail recursion**
|
|
||||||
(`update_internal(timeElapsed, ref animNode, ref frameNum, ref frame);` as the last statement)
|
|
||||||
rather than retail's `while(true)` loop. Semantically equivalent (C#'s JIT does NOT guarantee
|
|
||||||
tail-call optimization for this shape, so very long same-tick multi-anim-boundary crossings
|
|
||||||
could theoretically risk stack depth in ACE where retail would not — low practical risk since
|
|
||||||
`frameTimeElapsed` shrinks each iteration and hits `frametime == 0` quickly, but note this as
|
|
||||||
a structural (not behavioral) implementation difference).
|
|
||||||
Retail's `execute_hooks` direction constant: forward pass at `0x0052590c` passes `0xffffffff`
|
|
||||||
(-1, all-bits) not `1`; backward pass at `0x0052578c` passes `1`. **This looks inverted from a
|
|
||||||
naive reading** (forward should intuitively be "Forward" not `-1`), but cross-check against
|
|
||||||
`AnimationHookDir` enum values: ACE's port passes `AnimationHookDir.Forward` for the `lastFrame++`
|
|
||||||
(ascending, `frametime>0`) branch and `AnimationHookDir.Backward` for the `lastFrame--`
|
|
||||||
(descending, `frametime<0`) branch — i.e. ACE's C# reads correctly against the *semantic* forward/
|
|
||||||
backward regardless of retail's raw enum encoding. Need to verify `AnimationHookDir` enum's
|
|
||||||
actual underlying values in `ACE.Entity.Enum` to confirm `Forward == -1`/`0xffffffff` vs `1`,
|
|
||||||
but this is very likely just how the enum is defined (Both=0, Forward=-1, Backward=1, or similar
|
|
||||||
non-sequential encoding) rather than an ACE bug — **flag as needing confirmation, not a
|
|
||||||
confirmed divergence.**
|
|
||||||
EPSILON compares throughout use the same `0.000199999995f` literal as elsewhere. The `frametime
|
|
||||||
== 0` branch's guard is `|timeElapsed| > EPSILON` before calling `apply_physics(frame,
|
|
||||||
timeElapsed, timeElapsed)` — ACE matches exactly (`Sequence.cs:424`).
|
|
||||||
|
|
||||||
### `UnPack` (retail-only relevance)
|
|
||||||
`CSequence::UnPack` (`0x005259d0`) explicitly does `clear_animations(); clear_physics();
|
|
||||||
placement_frame = null; placement_frame_id = 0;` before deserializing — this is where retail
|
|
||||||
actually nulls the placement frame, which is the retail-verified justification for ACE's
|
|
||||||
`Clear()` including that reset (even though the disassembled 2-line `clear()` body itself does
|
|
||||||
not). ACE has no `Sequence.UnPack` in this file (no wire (de)serialization path ported) — this
|
|
||||||
is out of scope for acdream's runtime port (server-authoritative motion state, not client dat
|
|
||||||
deserialization) but is why ACE's `Clear()` and `clear()`/`clear_animations()`+`clear_physics()`
|
|
||||||
appear to disagree — they're actually modeling two different retail call sites (`clear()` proper
|
|
||||||
vs. `UnPack`'s manual clear+reset sequence). **Not a bug in ACE; a naming/scope conflation worth
|
|
||||||
noting for anyone tracing ACE's `Clear()` back to a single retail function.**
|
|
||||||
|
|
||||||
## `AnimSequenceNode.cs` (ACE) ↔ `AnimSequenceNode` (retail struct @ `acclient.h:31063`)
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
`CAnimation *anim` / `float framerate` / `int low_frame` / `int high_frame` — all match ACE's
|
|
||||||
`Animation Anim` / `float Framerate` / `int LowFrame` / `int HighFrame` exactly, including types
|
|
||||||
(both `int`, not `uint`).
|
|
||||||
|
|
||||||
### Default ctor `AnimSequenceNode()` ↔ retail `AnimSequenceNode::AnimSequenceNode()` (`0x00525d30`)
|
|
||||||
Retail: `framerate = 30f; low_frame = 0xffffffff (-1); high_frame = 0xffffffff (-1);` (both
|
|
||||||
low+high default to **-1**, not `low=0`).
|
|
||||||
ACE (`AnimSequenceNode.cs:15`): `Framerate = 30.0f; LowFrame = 0; HighFrame = -1;`
|
|
||||||
**CONFIRMED DIVERGENCE: ACE's parameterless ctor sets `LowFrame = 0` where retail sets
|
|
||||||
`low_frame = -1`.** This constructor is never actually invoked by ACE's own runtime path (the
|
|
||||||
only call site is `new AnimSequenceNode(animData)`, the parameterized overload, which explicitly
|
|
||||||
sets `LowFrame = animData.LowFrame`), so this divergence is currently dormant/unreachable in
|
|
||||||
ACE's code — but it is a real textual mismatch against retail's default-construct semantics and
|
|
||||||
would matter if any future code path constructs a bare `AnimSequenceNode()` and relies on default
|
|
||||||
`LowFrame`.
|
|
||||||
|
|
||||||
### `get_starting_frame()` ↔ `AnimSequenceNode::get_starting_frame` (`0x00525c80`)
|
|
||||||
Retail: `if (framerate < 0) return high_frame + 1; else return low_frame;` — **returns a plain
|
|
||||||
`int32_t`**, i.e. `high_frame + 1` with NO epsilon subtraction.
|
|
||||||
ACE (`AnimSequenceNode.cs:72`):
|
|
||||||
```csharp
|
|
||||||
public float get_starting_frame() {
|
|
||||||
if (Framerate >= 0.0f) return LowFrame;
|
|
||||||
else return HighFrame + 1 - PhysicsGlobals.EPSILON;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**CONFIRMED DIVERGENCE (significant):** ACE subtracts `PhysicsGlobals.EPSILON` (0.0002) from
|
|
||||||
`HighFrame + 1` when framerate is negative — retail does **not**; retail returns the exact
|
|
||||||
integer `high_frame + 1`. Also note the boundary condition flip: retail's branch condition is
|
|
||||||
`framerate < 0` (strict) with the `>= 0` case falling to `low_frame`; ACE's condition is
|
|
||||||
`Framerate >= 0.0f` returning `LowFrame` (equivalent boundary, `framerate==0` behaves the same
|
|
||||||
in both — returns `low_frame`/`LowFrame`). The boundary logic itself is faithful; **only the
|
|
||||||
epsilon subtraction is a fabricated addition not present in retail.** This likely exists in ACE
|
|
||||||
to avoid `Math.Floor` landing exactly on `HighFrame+1` and reading one frame past the end when
|
|
||||||
used as a float frame-cursor, but retail doesn't need it because retail's `get_starting_frame`
|
|
||||||
return value is immediately truncated back to an `int` in most call sites — however note retail's
|
|
||||||
`CSequence::advance_to_next_animation` and `remove_cyclic_anims` etc. store this int return value
|
|
||||||
directly into `frame_number` (a `long double`), so no fractional epsilon ever appears in retail's
|
|
||||||
frame_number for this codepath. **acdream should NOT copy this epsilon subtraction if porting
|
|
||||||
`get_starting_frame` faithfully** — investigate whether ACE added it to work around a downstream
|
|
||||||
float-precision issue introduced by ACE's OWN `float FrameNumber` choice (i.e. a workaround for
|
|
||||||
ACE's own divergence, compounding on top of it) rather than something retail does.
|
|
||||||
|
|
||||||
### `get_ending_frame()` ↔ `AnimSequenceNode::get_ending_frame` (`0x00525cb0`)
|
|
||||||
Retail: `if (framerate >= 0) return high_frame + 1; else return low_frame;` — again plain
|
|
||||||
integer, no epsilon.
|
|
||||||
ACE (`AnimSequenceNode.cs:31`):
|
|
||||||
```csharp
|
|
||||||
public float get_ending_frame() {
|
|
||||||
if (Framerate >= 0.0f) return HighFrame + 1 - PhysicsGlobals.EPSILON;
|
|
||||||
else return LowFrame;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Same confirmed divergence as `get_starting_frame`** — ACE subtracts EPSILON from `HighFrame+1`
|
|
||||||
where retail returns the bare int. Branch condition (`>= 0` → high+1 path) matches retail exactly
|
|
||||||
this time (mirrors correctly — `get_starting_frame` and `get_ending_frame` are exact opposites of
|
|
||||||
each other by design, both in retail and ACE); only the epsilon fabrication persists.
|
|
||||||
|
|
||||||
### `get_high_frame()` / `get_low_frame()`
|
|
||||||
Trivial accessors in both — direct field reads. No retail decompiled body found by name (likely
|
|
||||||
inlined/not separately emitted, or address not matched by this grep pass), but ACE's are pure
|
|
||||||
passthroughs (`return HighFrame;` / `return LowFrame;`) which cannot diverge from the struct field
|
|
||||||
values already confirmed to match. No risk.
|
|
||||||
|
|
||||||
### `get_part_frame(frameIdx)` ↔ `AnimSequenceNode::get_part_frame` (`0x00525c40`)
|
|
||||||
Retail: `if (anim != null && arg2 >= 0 && arg2 < anim->num_frames) return &anim->part_frames[arg2];
|
|
||||||
else return null;`
|
|
||||||
ACE (`AnimSequenceNode.cs:49`): `if (Anim == null) return null; if (frameIdx < 0 || frameIdx >=
|
|
||||||
Anim.NumFrames) return null; return Anim.PartFrames[frameIdx];` Logically equivalent (De Morgan's
|
|
||||||
of the same guard). Match. **Note the retail-side latent null-deref risk flagged above in
|
|
||||||
`execute_hooks`**: retail's `get_part_frame` DOES null-check bounds here, so a null `AnimFrame*`
|
|
||||||
can legitimately flow into `execute_hooks(this, get_part_frame(...), dir)` when `frameIdx` is
|
|
||||||
out of `[0, num_frames)` — retail's `execute_hooks` then dereferences it unconditionally. ACE
|
|
||||||
avoids this crash class entirely via its own `animFrame == null` guard in `execute_hooks`.
|
|
||||||
|
|
||||||
### `get_pos_frame(int frameIdx)` ↔ `AnimSequenceNode::get_pos_frame(int32_t)` (`0x00525c10`)
|
|
||||||
Retail: same null/bounds guard as `get_part_frame` but against `PosFrames`/`pos_frames`, returns
|
|
||||||
`&anim->pos_frames[arg2 * 0x1c]` (0x1c = sizeof(AFrame) = 28 bytes: Vector3 origin (12) +
|
|
||||||
Quaternion (16) = 28 — confirms `AFrame` layout) on success, else... retail returns `0` (null
|
|
||||||
pointer) on failure, whereas **ACE returns `new AFrame()`** (identity frame) instead of null:
|
|
||||||
```csharp
|
|
||||||
public AFrame get_pos_frame(int frameIdx) {
|
|
||||||
if (Anim == null) return new AFrame();
|
|
||||||
if (frameIdx < 0 || frameIdx >= Anim.PosFrames.Count) return new AFrame();
|
|
||||||
return Anim.PosFrames[frameIdx];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**CONFIRMED DIVERGENCE:** retail can return a null `AFrame*` from `get_pos_frame`; ACE always
|
|
||||||
returns a non-null identity `AFrame`. This is almost certainly intentional/necessary in ACE
|
|
||||||
because C#'s callers (`update_internal`, `advance_to_next_animation`) call
|
|
||||||
`AFrame.Combine(frame, currAnim.get_pos_frame(...))` / `frame.Subtract(...)` unconditionally when
|
|
||||||
`currAnim.Anim.PosFrames.Count > 0` is already true (guarding the call site) — so in practice the
|
|
||||||
only way retail's null path is hit is if `pos_frames` is non-null overall but the specific index
|
|
||||||
is out of the current `[0, num_frames)` bounds, an edge retail's callers appear to avoid by
|
|
||||||
construction. ACE's identity-frame fallback is a defensive substitute for retail's null (which
|
|
||||||
would otherwise NPE `AFrame.Combine`/`Subtract` in C#) — functionally converges to a no-op combine
|
|
||||||
in the one path where it could differ, matching retail's *intended* behavior (no-op) via a
|
|
||||||
different mechanism (identity frame vs. skipped call). Low risk, but textually a real divergence
|
|
||||||
worth listing.
|
|
||||||
There's also a `float`-overload convenience wrapper `get_pos_frame(float frameIdx)` in ACE
|
|
||||||
(`:67`, `=> get_pos_frame((int)Math.Floor(frameIdx))`) with no direct 1:1 retail counterpart found
|
|
||||||
by this pass — likely inlined at each call site in retail rather than a separate overload; no
|
|
||||||
behavioral risk since it's a pure convenience delegator.
|
|
||||||
|
|
||||||
### `has_anim(int appraisalProfile = 0)` ↔ `AnimSequenceNode::has_anim` (`0x00525c70`)
|
|
||||||
Retail: `return anim != 0;` (no parameter). ACE: `return Anim != null;` with a vestigial unused
|
|
||||||
`appraisalProfile` parameter (default 0, never read in the body) — **ACE-only dead parameter**,
|
|
||||||
harmless (matches retail's actual logic; the extra param appears to be scaffolding for a
|
|
||||||
different unrelated retail overload elsewhere, not a behavioral difference here).
|
|
||||||
|
|
||||||
### `multiply_framerate(multiplier)` ↔ `AnimSequenceNode::multiply_framerate` (`0x00525be0`)
|
|
||||||
Retail: `if (multiplier < 0) swap(low_frame, high_frame); framerate *= multiplier;`
|
|
||||||
ACE (`:85`): `if (multiplier < 0.0f) { swap LowFrame/HighFrame } Framerate *= multiplier;` Exact
|
|
||||||
match, including the swap-BEFORE-multiply ordering (doesn't matter for correctness here since the
|
|
||||||
swap doesn't depend on the post-multiply framerate value, but confirms ACE preserved retail's
|
|
||||||
instruction order faithfully).
|
|
||||||
|
|
||||||
### `set_animation_id(animID)` ↔ `AnimSequenceNode::set_animation_id` (`0x00525d60`, body continues past what this pass read in full — only header + first 3 lines captured)
|
|
||||||
ACE (`:96`): looks up `Anim = new Animation(DBObj.GetAnimation(animID))`; if `Anim == null`
|
|
||||||
return; clamps `HighFrame` to `-1 -> NumFrames-1` if still default, clamps `LowFrame`/`HighFrame`
|
|
||||||
individually if `>= NumFrames`, and clamps `LowFrame > HighFrame` by raising `HighFrame =
|
|
||||||
LowFrame`. This clamping logic was not fully re-derived from the retail disasm in this pass
|
|
||||||
(truncated read) — **recommend a follow-up grep of `AnimSequenceNode::set_animation_id` body
|
|
||||||
past `0x00525d60` before treating ACE's clamp order as verified**; flagged as unverified rather
|
|
||||||
than confirmed-matching.
|
|
||||||
|
|
||||||
### Parameterized ctor `AnimSequenceNode(AnimData animData)` ↔ retail `AnimSequenceNode::AnimSequenceNode(AnimData const*)` (`0x00525f90`, referenced at `0x00525f80` calling `set_animation_id`)
|
|
||||||
ACE (`:22`): `Framerate = animData.Framerate; LowFrame = animData.LowFrame; HighFrame =
|
|
||||||
animData.HighFrame; set_animation_id(animData.AnimID);` — order (set framerate/low/high fields
|
|
||||||
FIRST, then resolve+clamp via `set_animation_id`) matches the retail call sequence implied by
|
|
||||||
`0x00525f80` calling `set_animation_id` from within the ctor body (consistent with fields being
|
|
||||||
pre-populated by the ctor's other init statements before the call, standard C++ member-init-list
|
|
||||||
ordering). Considered faithful pending the same `set_animation_id` body caveat above.
|
|
||||||
|
|
||||||
## `AnimData.cs` (ACE) ↔ retail `AnimData`/`operator*`
|
|
||||||
|
|
||||||
Retail default ctor `AnimData::AnimData` (`0x00525ce0`): `anim_id.id = 0; low_frame = 0; high_frame
|
|
||||||
= 0xffffffff (-1); framerate = 30f;`
|
|
||||||
ACE `AnimData` (this file, `references/ACE/.../Animation/AnimData.cs`) is just a plain data holder
|
|
||||||
with a parameterized ctor `AnimData(DatLoader.Entity.AnimData animData, float speed = 1.0f)` that
|
|
||||||
does `AnimID = animData.AnimId; LowFrame = animData.LowFrame; HighFrame = animData.HighFrame;
|
|
||||||
Framerate = animData.Framerate * speed;` — this matches retail's `operator*(float speed, AnimData
|
|
||||||
const* src)` (`0x00525d00`, invoked from `add_motion` at `0x0052255b`):
|
|
||||||
```
|
|
||||||
retail: dst.id = src.id; dst.low_frame = src.low_frame; dst.high_frame = src.high_frame;
|
|
||||||
dst.framerate = src.framerate * speed;
|
|
||||||
```
|
|
||||||
Field-for-field, operation-for-operation match, including that `Framerate` is the ONLY field
|
|
||||||
scaled by `speed` (low/high frame bounds pass through unscaled). No parameterless-ctor
|
|
||||||
default-value divergence to flag since ACE's `AnimData()` here is a no-op empty ctor (all fields
|
|
||||||
default to C# zero values: `0, 0, 0, 0f`) — **diverges from retail's `low_frame=0,
|
|
||||||
high_frame=0xffffffff, framerate=30f` defaults**, but this parameterless ctor does not appear to
|
|
||||||
be invoked anywhere in the `add_motion` call chain (only the parameterized ctor is used at the
|
|
||||||
`MotionTable.add_motion` call site), so — like `AnimSequenceNode()`'s bare ctor — this is a
|
|
||||||
dormant/unreachable-in-practice divergence, not an active bug.
|
|
||||||
|
|
||||||
## `AFrame.cs` — spot notes (not the primary ask, but touched by Sequence/AnimSequenceNode)
|
|
||||||
|
|
||||||
`AFrame` is ACE's C# port of retail's `Frame`/`AFrame` (28-byte struct = Vector3 origin (12B) +
|
|
||||||
Quaternion orientation (16B), confirmed via the `0x1c` (28) stride multiplier in
|
|
||||||
`AnimSequenceNode::get_pos_frame` at `0x00525c2c`). `Combine`/`Subtract`/`Rotate`/`apply_physics`
|
|
||||||
call sites all operate on this type consistently between ACE and retail's `Frame::combine`,
|
|
||||||
`Frame::subtract1`, `Frame::rotate` (referenced by name at `0x0052541b`, `0x005254c2`,
|
|
||||||
`0x00524b2d` etc. — not independently re-derived body-for-body in this pass; flagged out of scope
|
|
||||||
per the task's method list, but the call-site shapes into/out of `Sequence`/`AnimSequenceNode`
|
|
||||||
were confirmed consistent).
|
|
||||||
|
|
||||||
## `MotionTable.cs` (ACE, class name `MotionTable`) ↔ retail `CMotionTable` — the 4 requested methods
|
|
||||||
|
|
||||||
Retail's class is named `CMotionTable` (not `MotionTable`) — ACE renamed it during the port. The
|
|
||||||
4 target methods are **retail FREE FUNCTIONS** (not `CMotionTable::` member functions) that take
|
|
||||||
a `CSequence*` as their first parameter — ACE ported them as instance methods on `MotionTable`
|
|
||||||
taking a `Sequence` parameter, a structural (not behavioral) reshaping.
|
|
||||||
|
|
||||||
### `add_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `add_motion` (`0x005224b0`)
|
|
||||||
Retail: `if (motionData == null) return; set_velocity(motionData.velocity * speed); set_omega(
|
|
||||||
motionData.omega * speed); for each anim in motionData.anims: append_animation(AnimData(speed *
|
|
||||||
anim))` — i.e. append_animation is called with a **freshly speed-scaled `AnimData` value**
|
|
||||||
(via `operator*`), never the raw `motionData.Anims[i]`.
|
|
||||||
ACE (`MotionTable.cs:358`):
|
|
||||||
```csharp
|
|
||||||
if (motionData == null) return;
|
|
||||||
sequence.SetVelocity(motionData.Velocity * speed);
|
|
||||||
sequence.SetOmega(motionData.Omega * speed);
|
|
||||||
for (i in motionData.Anims.Count) {
|
|
||||||
var animData = new AnimData(motionData.Anims[i], speed);
|
|
||||||
sequence.append_animation(animData);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Exact match, including the crucial detail that velocity/omega REPLACE (via `set_velocity`/
|
|
||||||
`SetVelocity`, not accumulate) the sequence's existing physics vector, unlike `combine_motion`/
|
|
||||||
`subtract_motion` below.
|
|
||||||
|
|
||||||
### `combine_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `combine_motion` (`0x00522580`)
|
|
||||||
Retail: `if (motionData == null) return; combine_physics(velocity*speed, omega*speed);` (ADDS
|
|
||||||
into existing sequence velocity/omega via `CSequence::combine_physics`).
|
|
||||||
ACE (`MotionTable.cs:381`): `if (motionData == null) return; sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
|
|
||||||
|
|
||||||
### `subtract_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `subtract_motion` (`0x00522600`)
|
|
||||||
Retail: `if (motionData == null) return; subtract_physics(velocity*speed, omega*speed);`
|
|
||||||
ACE (`MotionTable.cs:388`): `if (motionData == null) return; sequence.subtract_physics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
|
|
||||||
|
|
||||||
### `change_cycle_speed(Sequence sequence, MotionData motionData, float substateMod, float speedMod)` ↔ free fn `change_cycle_speed` (`0x00522290`)
|
|
||||||
Retail: `if (|substateMod| > EPSILON) multiply_cyclic_animation_fr(speedMod / substateMod); else
|
|
||||||
if (|speedMod| < EPSILON) multiply_cyclic_animation_fr(0);` — **note the retail param order is
|
|
||||||
`(CSequence*, MotionData* [UNUSED in this function's body], float substateMod, float speedMod)`
|
|
||||||
— `motionData` is passed but never dereferenced inside `change_cycle_speed` itself** (it's there
|
|
||||||
for signature consistency with the other 3 sibling functions / call-site uniformity, not because
|
|
||||||
the function needs it).
|
|
||||||
ACE (`MotionTable.cs:372`):
|
|
||||||
```csharp
|
|
||||||
if (Math.Abs(substateMod) > PhysicsGlobals.EPSILON)
|
|
||||||
sequence.multiply_cyclic_animation_framerate(speedMod / substateMod);
|
|
||||||
else if (Math.Abs(speedMod) < PhysicsGlobals.EPSILON)
|
|
||||||
sequence.multiply_cyclic_animation_framerate(0);
|
|
||||||
```
|
|
||||||
Exact match, including that ACE's `motionData` parameter is likewise unused in the method body
|
|
||||||
(faithfully preserved as dead/unused, mirroring retail's own unused parameter — not an ACE bug,
|
|
||||||
an intentional signature-parity choice already present in retail).
|
|
||||||
**Boundary-condition note:** if `substateMod` is exactly `EPSILON` or between `EPSILON` and some
|
|
||||||
tiny nonzero value such that neither branch's condition is strictly satisfied (i.e.
|
|
||||||
`|substateMod| <= EPSILON` AND `|speedMod| >= EPSILON`), **neither branch fires and the cyclic
|
|
||||||
framerate is left unchanged** in both retail and ACE — this is retail's actual (if slightly odd)
|
|
||||||
behavior, faithfully reproduced, not a port bug.
|
|
||||||
|
|
||||||
## Call-site context confirmed (not itself divergence-graded, informational)
|
|
||||||
|
|
||||||
`MotionTable.GetObjectSequence` (ACE) corresponds to retail's `CMotionTable::GetObjectSequence`
|
|
||||||
(referenced at `0x00522347` from `CMotionTable::re_modify`, and the `sequence.clear_physics();
|
|
||||||
sequence.remove_cyclic_anims();` pairing before each `add_motion` burst matches retail's
|
|
||||||
`CSequence::clear_physics(arg4); CSequence::remove_cyclic_anims(arg4);` pattern visible at
|
|
||||||
`0x005229cf`/`0x005229d8`, `0x00522be3`/`0x00522bec`, `0x00522d6d`/`0x00522d74`,
|
|
||||||
`0x00522e5d`/`0x00522e64` — four separate call sites in retail's `GetObjectSequence`, matching
|
|
||||||
ACE's four corresponding branches (`Style` / `SubState` / `Action` / the default-state reset in
|
|
||||||
`SetDefaultState` which additionally calls `clear_animations()` instead of `remove_cyclic_anims()`
|
|
||||||
— confirmed intentional, `SetDefaultState` is a full reset not an in-place cycle swap).
|
|
||||||
`re_modify` in retail (`0x005222e0`, `CMotionTable::re_modify`) reapplies queued modifiers by
|
|
||||||
popping `MotionState.modifier_head` and re-calling `GetObjectSequence` — this exists in ACE too
|
|
||||||
(referenced in `MotionTable.cs` but not in the requested method list; noted only for completeness
|
|
||||||
of the call graph around the 4 target functions).
|
|
||||||
|
|
||||||
## Summary of confirmed divergences (ranked by likely runtime impact)
|
|
||||||
|
|
||||||
1. **`FrameNumber`/`frame_number`: `float` (ACE) vs `long double`/80-bit-extended (retail).**
|
|
||||||
Pervasive — affects every frame-boundary comparison in `update_internal`,
|
|
||||||
`advance_to_next_animation`, `apply_physics`. Highest-impact, hardest to fix in C# (no native
|
|
||||||
80-bit float type; `double` is the closest available and still not bit-exact to retail).
|
|
||||||
2. **`AnimSequenceNode.get_starting_frame()` / `get_ending_frame()` subtract
|
|
||||||
`PhysicsGlobals.EPSILON` from `HighFrame + 1` in ACE; retail returns the bare integer with NO
|
|
||||||
epsilon.** Directly affects where a reverse-playing animation's start/end frame lands —
|
|
||||||
potential off-by-epsilon frame read at cycle boundaries. Medium-high impact, easy to fix (just
|
|
||||||
drop the `- PhysicsGlobals.EPSILON` term) if porting fresh.
|
|
||||||
3. **`AnimSequenceNode()` parameterless ctor: `LowFrame=0` (ACE) vs `low_frame=-1` (retail).**
|
|
||||||
Dormant in ACE's current call graph (only the parameterized ctor is actually invoked), but a
|
|
||||||
real textual mismatch. Low impact unless something starts calling the bare ctor.
|
|
||||||
4. **`AnimData()` parameterless ctor: all-zero defaults (ACE) vs `low_frame=0, high_frame=-1,
|
|
||||||
framerate=30f` (retail).** Same dormant-but-real-mismatch profile as #3.
|
|
||||||
5. **`AnimSequenceNode.get_pos_frame` returns identity `AFrame` on failure (ACE) vs `null`
|
|
||||||
pointer (retail).** Functionally converges to a no-op in practice given how call sites guard
|
|
||||||
invocation; textual divergence only.
|
|
||||||
6. **ACE's `Clear()` additionally nulls `PlacementFrame`/`PlacementFrameID`, which retail's own
|
|
||||||
2-instruction `clear()` body does NOT do** — that reset actually belongs to retail's separate
|
|
||||||
`UnPack` function. Scope conflation, not a behavioral bug, but worth knowing which retail
|
|
||||||
function ACE's `Clear()` is really modeling.
|
|
||||||
7. **`update_internal`: retail loops (`while(true)`), ACE recurses (tail call).** Structural only;
|
|
||||||
equivalent output, theoretical (very unlikely in practice) stack-depth difference in
|
|
||||||
pathological same-tick multi-boundary-crossing cases.
|
|
||||||
8. Retail's `execute_hooks` has a latent null-deref if `get_part_frame` returns null for an
|
|
||||||
out-of-range frame index (no null check before `arg2->hooks`); ACE's `animFrame == null` guard
|
|
||||||
avoids this crash class — a safe defensive divergence, not something to "fix" toward retail.
|
|
||||||
9. `AnimSequenceNode::set_animation_id` clamp-order in ACE was NOT independently re-verified
|
|
||||||
against the full retail body in this pass (only the call header + first lines were read) —
|
|
||||||
flag for a follow-up targeted grep before treating ACE's clamping as ground truth.
|
|
||||||
|
|
||||||
## Constants confirmed identical between ACE and retail
|
|
||||||
|
|
||||||
- `EPSILON = 0.0002f` (retail literal `0.000199999995f`, ACE `PhysicsGlobals.EPSILON`) — used
|
|
||||||
identically in `advance_to_next_animation`, `update_internal`, `apply_physics` guards, and
|
|
||||||
`change_cycle_speed`.
|
|
||||||
- `AFrame`/`Frame` struct size = 28 bytes (0x1c) = Vector3(12) + Quaternion(16), confirmed via
|
|
||||||
`AnimSequenceNode::get_pos_frame`'s `arg2 * 0x1c` index stride.
|
|
||||||
- `AnimSequenceNode` struct layout: `CAnimation* anim; float framerate; int low_frame; int
|
|
||||||
high_frame;` — exact field-for-field match with ACE's C# class (types included).
|
|
||||||
- `CSequence` struct layout (`acclient.h:30747`): confirms `frame_number` is genuinely `long
|
|
||||||
double`, not a decompiler artifact — this is the verbatim retail header, authoritative.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,224 +0,0 @@
|
||||||
# R1 gap map — retail CSequence vs acdream AnimationSequencer
|
|
||||||
|
|
||||||
Inputs: `r1-csequence-decomp.md` (verbatim retail extraction, line anchors into
|
|
||||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`), `r1-ace-sequence.md`
|
|
||||||
(ACE port cross-reference), `r1-acdream-sequencer.md` (current-code map).
|
|
||||||
Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (R1 stage).
|
|
||||||
Current code: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines) +
|
|
||||||
`AnimationCommandRouter.cs` / `AnimationHookRouter.cs` / `IAnimationHookSink.cs`.
|
|
||||||
|
|
||||||
**Conflicts between the two research passes RESOLVED in this synthesis (re-read raw decomp):**
|
|
||||||
|
|
||||||
- **Hook direction constants** — r1-ace-sequence.md's claim ("forward pass at 0x0052590c passes
|
|
||||||
0xffffffff") was a branch misattribution. Raw decomp verified this pass:
|
|
||||||
`0x0052578c` `execute_hooks(..., ebx_2, 1)` with `ebx_2 += 1` after = FORWARD crossing → **+1**;
|
|
||||||
`0x0052590c` `execute_hooks(..., ebx_1, 0xffffffff)` with `ebx_1 -= 1` after = REVERSE → **-1**.
|
|
||||||
Retail encoding == ACE `AnimationHookDir` (`references/ACE/Source/ACE.Entity/Enum/AnimationHookDir.cs`:
|
|
||||||
Backward=-1, Both=0, Forward=1) == the DatReaderWriter enum acdream already uses. No enum-remap needed.
|
|
||||||
- **update_internal fine structure** — r1-csequence-decomp.md §21's cleaned flow is garbled in two
|
|
||||||
places (an "early return" where retail clamps+continues; `hit_boundary=true` placed
|
|
||||||
unconditionally after the per-frame loop). The authoritative skeleton is ACE
|
|
||||||
`Sequence.cs:351-443` (verified verbatim this pass, quoted below in P4), which matches the raw
|
|
||||||
decomp's branch layout at `0x005255d0`-`0x005259ca`: overshoot check → clamp `frameNum` to
|
|
||||||
`get_high_frame()` (fwd) / `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
|
|
||||||
`animDone=true` → per-frame crossing loop → `if (!animDone) return` → AnimDone gate
|
|
||||||
(`head != first_cyclic`) → `advance_to_next_animation` → carry leftover → loop.
|
|
||||||
- **ONE remaining unresolved decomp ambiguity** — raw decomp at `0x0052598a-0x0052598d` appears to
|
|
||||||
zero `arg2` (elapsed) after `advance_to_next_animation` before looping, while ACE carries
|
|
||||||
`timeElapsed = frameTimeElapsed` (the leftover). If retail truly zeroed it, a lag spike could
|
|
||||||
never fast-forward through multiple queued nodes in one tick (observable). ACE's reading is far
|
|
||||||
more plausible (BN likely lost the var reassignment through the x87 stack slot). **Pin in the R1
|
|
||||||
pseudocode commit (P0)** — cdb breakpoint on `CSequence::advance_to_next_animation` counting
|
|
||||||
invocations per `CSequence::update` call under induced multi-node overshoot.
|
|
||||||
|
|
||||||
Severity key: **BLOCKER** = must be right or R1's conformance harness is meaningless;
|
|
||||||
**HIGH** = visible animation wrongness / blocks R2+; **MED** = edge-case visible or blocks a later
|
|
||||||
stage; **LOW** = dormant/textual.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ITEMIZED GAP LIST
|
|
||||||
|
|
||||||
| # | Retail behavior acdream lacks/diverges on | Decomp anchor | Current-code anchor | Severity |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| G1 | **Direction-aware boundary pair with bare-int values.** Retail `get_starting_frame` = `framerate<0 ? high+1 : low`, `get_ending_frame` = `framerate<0 ? low : high+1` — plain ints, **NO epsilon**. acdream (following ACE) returns `(EndFrame+1) - FrameEpsilon` with `FrameEpsilon = 1e-5` (ACE uses 0.0002); the epsilon is an ACE fabrication compensating for ACE's own `float FrameNumber`. | `0x00525c80`/`0x00525cb0`, pseudo-C lines 302483/302501 (r1-csequence-decomp §26/27); ACE divergence #2 (r1-ace-sequence) | `AnimationSequencer.cs:154,164` (`GetStartFramePosition`/`GetEndFramePosition`), `:171` (`FrameEpsilon=1e-5`) | **BLOCKER** — every boundary decision keys off these values |
|
|
||||||
| G2 | **`multiply_framerate` swaps `low_frame`↔`high_frame` on negative factor.** acdream keeps `StartFrame ≤ EndFrame` invariant and encodes direction only in Framerate sign, compensating in `Advance` — explicitly documented divergence. Verbatim port requires the swap + the direction-aware G1 pair; the two mechanisms are coupled. | `0x00525be0`, line 302425 (§14a) | `AnimationSequencer.cs` `AnimNode.MultiplyFramerate` (r1-acdream map row "multiply_framerate") | **HIGH** — reverse playback (WalkBackward links, reverse stops) correctness |
|
|
||||||
| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** |
|
|
||||||
| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) |
|
|
||||||
| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ − 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20` → `Hook_AnimDone 0x0050fda0` → `CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** |
|
|
||||||
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); a separate `CPhysicsObj::process_hooks` drains + `Execute()`s once per physics tick then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar (queue then drain) but the drain point is GameWindow's render-tick loop immediately after `Advance` — not positioned per retail's `UpdateObjectInternal` order (`process_hooks` runs LAST, after MovementManager.UseTime etc.). R1 must expose the queue through a host seam so R6 can place the drain correctly. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18) | `AnimationSequencer.cs:1371-1388` (`ExecuteHooks` → `_pendingHooks`), `GameWindow.cs:9882` (drain) | MED for R1 (seam), HIGH by R6 |
|
|
||||||
| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it |
|
|
||||||
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED |
|
|
||||||
| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete |
|
|
||||||
| G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct |
|
|
||||||
| G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms |
|
|
||||||
| G12 | **`combine_physics`/`subtract_physics` accumulators absent.** Retail `velocity += / -=` element-wise (x87-widened). acdream only has replace (`EnqueueMotionData`) + `ClearPhysics`. Needed by R2's fast path (`change_cycle_speed` + `subtract_motion(old)` + `combine_motion(new)`) and by jump/knockback physics later. | `0x005248c0`/`0x00524900` (lines 300818/300832, §12/13) | no equivalent in `AnimationSequencer.cs` | MED (trivial; part of the verbatim class) |
|
|
||||||
| G13 | **`multiply_cyclic_animation_fr` must touch ONLY node framerates.** Retail walks `first_cyclic`→tail calling `multiply_framerate`. acdream's `MultiplyCyclicFramerate` additionally scales `CurrentVelocity/CurrentOmega` — algebraically equivalent to retail's `change_cycle_speed`+`subtract/combine_motion` composite (an R2 mechanism folded in). Core port must separate them or R2's verbatim fast path double-applies. | `0x00524940` (line 300846, §14) | `AnimationSequencer.cs` `MultiplyCyclicFramerate` (r1-acdream map row) | MED (correct today by accident; wrong the moment R2 lands) |
|
|
||||||
| G14 | **Placement frames absent.** Retail `set_placement_frame`/`placement_frame_id` + `get_curr_animframe` returning `placement_frame` when `curr_anim == null` (static pose for object with no active anims). acdream has no placement concept (identity frames only). Explicit R1 scope item in the plan. | `0x005249b0`/`0x00524970` (lines 300872/300855, §15/16) | no equivalent (grep "placement" hits only a doc comment at `AnimationSequencer.cs:979`) | MED |
|
|
||||||
| G15 | **`frame_number` precision.** Retail: x87 `long double` (80-bit; verbatim `acclient.h:30747`). acdream: `double` (`AnimationSequencer.cs:303`) — the best C# can do; ACE's `float` is worse. Residual double-vs-extended ULP divergence at exact frame boundaries is an unavoidable adaptation → **needs a divergence-register row in the R1 core commit**. | `acclient.h:30747`; headline of r1-ace-sequence | `AnimationSequencer.cs:302-303` | LOW runtime / **process-MANDATORY** register row |
|
|
||||||
| G16 | **Node ctor defaults + `set_animation_id` clamp order.** Retail defaults: `framerate=30f, low_frame=-1, high_frame=-1`; `AnimData` defaults `low=0, high=-1, framerate=30f`; clamp order: `high<0→num-1`, `low>=num→num-1`, `high>=num→num-1`, `low>high→high=low`. acdream `LoadAnimNode` handles the `-1` sentinel + `low>high` but not the full order; per-node it also stores `IsLooping/HasPosFrames/Velocity/Omega` fields retail nodes don't have (retail velocity/omega live on the SEQUENCE only). NOTE: r1-ace-sequence flagged set_animation_id as unverified, but r1-csequence-decomp §25 captured the FULL body — resolved, no follow-up grep needed. | `0x00525d30`/`0x00525f90`/`0x00525d60` (lines 302547/302744/302561, §25); `AnimData` ctor `0x00525ce0` | `AnimationSequencer.cs` `AnimNode` + `LoadAnimNode` (r1-acdream map §0, row "Placement / root frames") | MED |
|
|
||||||
| G17 | **`add_motion` velocity semantics: unconditional REPLACE.** Retail free fn `add_motion` (0x005224b0) calls `set_velocity(motionData.velocity*speed)`/`set_omega(...)` **unconditionally** (a MotionData without the dat HasVelocity bit carries zero → replace-with-zero). acdream gates on `Flags.HasFlag(HasVelocity)` and otherwise LEAVES the previous value (`AnimationSequencer.cs:1288-1294` — comment claims "matches retail's conditional behavior", which the decomp contradicts). Retail avoids the zero-a-running-cycle problem via call-graph (modifiers go through `combine_motion`, not `add_motion`) — an R2 distinction acdream compensates for with this flag gate. | `0x005224b0` (r1-ace-sequence `add_motion` section); `combine_motion 0x00522580` | `AnimationSequencer.cs:1288-1294` | MED — port unconditional replace in the R1 core; keep the gate in the adapter until R2 routes modifiers through combine_motion, then delete (register row if the adapter gate outlives R1) |
|
|
||||||
| G18 | **`get_pos_frame` returns null out-of-range** (retail), not identity — and retail's `execute_hooks` has a latent null-deref on `arg2->hooks` (no null check). Port: null-return + the ACE-style null guard in execute_hooks as a documented safe divergence (crash-parity with retail is not a goal). acdream's ExecuteHooks already bounds-checks (`:1373`) — keep the guard, cite it. | `0x00525c10`/`0x00524830` (§28/§18); ACE divergences #5/#8 | `AnimationSequencer.cs:1373` | LOW |
|
|
||||||
| G19 | **`update` entry contract.** Retail `update(double quantum, Frame*)`: non-empty → `update_internal` then **`apricot`**; empty → physics-only. acdream `Advance(float dt)` returns blended `PartTransform[]` and never trims consumed nodes structurally (rebuilds hide it). Core port needs the retail entry + apricot; the blended-frame render output stays an adapter/render-side concern (retail renders off `get_curr_animframe`'s FLOORED index; interpolation lives in CPartArray-land, out of R1 core scope). | `0x00525b80` + apricot `0x00524b40` (§20/22) | `AnimationSequencer.cs:872+` (`Advance`), `BuildBlendedFrame` | **HIGH** (apricot + entry contract); blend seam MED |
|
|
||||||
| G20 | **`clear()` scope.** Retail `clear` = `clear_animations()+clear_physics()` ONLY (2 calls, 0x005255b0); the placement_frame reset belongs to `UnPack` (0x005259d0). Do not copy ACE's `Clear()` which folds the placement reset in. | line 301828 (§3); ACE divergence #6 | acdream `Reset()` (no external callers) | LOW |
|
|
||||||
|
|
||||||
Invented behaviors NOT in the gap list because they are R2/R3 scope and survive R1 **in the
|
|
||||||
adapter, unchanged**: K-fix18 `skipTransitionLink` (retire in R3 jump family), Fix B
|
|
||||||
locomotion link-skip (retail mechanism = `remove_redundant_links 0x0051bf20`, R2), stop-anim
|
|
||||||
fallback + GetLink reversed branch (R2 `get_link`/`GetObjectSequence`), velocity-synthesis
|
|
||||||
constants Walk=3.12/Run=4.0/Side=1.25 (R3 `get_state_velocity`), `HasCycle` probe (R2),
|
|
||||||
retail slerp + BuildBlendedFrame (render-side, not CSequence). Each keeps/gets its
|
|
||||||
divergence-register row when touched.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. KEEP LIST — already matching retail
|
|
||||||
|
|
||||||
| Behavior | Retail anchor | acdream anchor |
|
|
||||||
|---|---|---|
|
|
||||||
| `execute_hooks` direction filter `dir==0(Both) \|\| dir==caller` | `0x00524830` line 300780; constants verified: fwd=+1 @0x0052578c, rev=-1 @0x0052590c | `AnimationSequencer.cs:1371-1388`; DatReaderWriter `AnimationHookDir` Backward=-1/Both=0/Forward=1 == retail encoding |
|
|
||||||
| Queue-then-drain hook model (hooks NOT executed inline during frame advance) | `add_anim_hook 0x00514c20` + `process_hooks 0x00511550` | `_pendingHooks` + `ConsumePendingHooks()` (drain placement moves in R6, mechanism correct) |
|
|
||||||
| Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) |
|
|
||||||
| Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` |
|
|
||||||
| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | ACE `Sequence.cs:436-442` (`timeElapsed = frameTimeElapsed` + recurse); decomp loop-back @0x005255e8 (see open ambiguity above) | `Advance`'s `timeRemaining`/overflow continue (r1-acdream map row "update_internal") |
|
|
||||||
| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) |
|
|
||||||
| `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) |
|
|
||||||
| `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") |
|
|
||||||
| `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) |
|
|
||||||
| `HighFrame == -1` sentinel → last frame; `low > high → high = low` degenerate guard | `set_animation_id 0x00525d60` clamps | `LoadAnimNode` (r1-acdream map row "Placement / root frames") — partial (see G16 for full order) |
|
|
||||||
| Fast-path re-speed without restart on same motion (concept) | ACE `MotionTable.cs:132-139`; retail `change_cycle_speed 0x00522290` | `SetCycle` early-return → `MultiplyCyclicFramerate` (G13 caveat) |
|
|
||||||
| `frame_number` at `double` ≥ ACE's float | `acclient.h:30747` (`long double`) | `AnimationSequencer.cs:303` — already the best-available C# type |
|
|
||||||
| Retail slerp incl. validation-fallback quirk (render blend, not CSequence) | `FUN_005360d0` (chunk_00530000.c:4799-4846) | `SlerpRetailClient` — keep untouched |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. PORT ORDER — R1 commit sequence (tests-first, each one commit)
|
|
||||||
|
|
||||||
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Naming: retail names
|
|
||||||
(`CSequence`, `AnimSequenceNode`) or thin C# equivalents — decided in P1, consistent after.
|
|
||||||
Every commit: build+test green; register rows added/retired in-commit.
|
|
||||||
|
|
||||||
**P0 — pseudocode + ambiguity pinning (docs only).**
|
|
||||||
Write `docs/research/2026-07-xx-csequence-pseudocode.md` from r1-csequence-decomp.md,
|
|
||||||
CORRECTING §21 to the ACE-verified skeleton (this doc's header), and pin the ONE open ambiguity:
|
|
||||||
leftover-time carry vs `arg2=0` at `0x0052598a`.
|
|
||||||
Fixture source: **cdb trace** — breakpoint `acclient!CSequence::advance_to_next_animation` +
|
|
||||||
`acclient!CSequence::update` with hit counters (pattern: `tools/cdb/l2g-observer.cdb`); ratio >1
|
|
||||||
advance-per-update under a stall/lag proves the carry. Also capture
|
|
||||||
`append_animation`/`remove_cyclic_anims` arg logs here (they feed P2/P5 goldens — one cdb session
|
|
||||||
serves all of R1).
|
|
||||||
Dependencies: none. This is the workflow's mandatory step-3 artifact.
|
|
||||||
|
|
||||||
**P1 — `AnimSequenceNode` verbatim.** (closes G1, G2, G16, G18-node-half)
|
|
||||||
Fields `anim/framerate(float)/low_frame/high_frame` only (NO IsLooping/Velocity/Omega per-node);
|
|
||||||
ctors with retail defaults (30f/-1/-1); `set_animation_id` full clamp order (§25);
|
|
||||||
`get_starting_frame`/`get_ending_frame` bare-int direction-aware pair (NO epsilon);
|
|
||||||
`multiply_framerate` with low/high swap on negative; `get_pos_frame` (null OOB, both overloads) /
|
|
||||||
`get_part_frame` / `has_anim`. Uses existing `IAnimationLoader` for the DBObj::Get seam.
|
|
||||||
Tests first: synthetic (all clamp branches; negative-multiply swap; direction-aware boundary
|
|
||||||
mirror table) + **dat fixtures** (real Humanoid MotionTable AnimData via DatCollection: resolve,
|
|
||||||
clamp, verify against `Animation.PartFrames.Count`).
|
|
||||||
Dependencies: P0.
|
|
||||||
|
|
||||||
**P2 — `CSequence` container + list surgery.** (closes G10, G11, G12, G14, G15, G20; G5's structural precondition)
|
|
||||||
`anim_list` (LinkedList), `first_cyclic`, `curr_anim`, `frame_number:double`, `velocity/omega`,
|
|
||||||
`placement_frame/placement_frame_id`, `hook_obj`-seam. Methods: `append_animation`
|
|
||||||
(first_cyclic-slides-to-tail-every-call + curr_anim seed), `clear/clear_animations/clear_physics`,
|
|
||||||
`remove_cyclic_anims` (snap-back + `get_ending_frame(prev)`), `remove_link_animations(n)`,
|
|
||||||
`remove_all_link_animations`, `has_anims`, `apricot`, `set_velocity/set_omega/combine_physics/
|
|
||||||
subtract_physics`, `set_placement_frame/get_curr_animframe/get_curr_frame_number`,
|
|
||||||
`multiply_cyclic_animation_fr` (framerates ONLY — G13).
|
|
||||||
Register rows in-commit: double-vs-long-double (G15); managed LinkedList vs intrusive DLList.
|
|
||||||
Tests first: list-surgery state tables (curr_anim/first_cyclic/frame_number after every op, incl.
|
|
||||||
curr_anim-inside-removed-range snaps; apricot bounded by curr_anim AND first_cyclic).
|
|
||||||
Fixture source: **synthetic** + **cdb goldens from P0** (`append_animation`/`remove_cyclic_anims`
|
|
||||||
arg sequences from a live Walk→Run→Stop cycle — replay the call sequence, assert list shape).
|
|
||||||
Dependencies: P1.
|
|
||||||
|
|
||||||
**P3 — `apply_physics` + Frame math.** (closes G7's math half)
|
|
||||||
`apply_physics(Frame, quantum, sign)` with copysign semantics; verbatim `Frame.combine`/
|
|
||||||
`Frame.subtract1`/`Frame.rotate` equivalents (port beside the existing ApplyPosFrame math, which
|
|
||||||
becomes call-compatible).
|
|
||||||
Tests first: numeric goldens — hand-computed copysign cases (±quantum × ±sign), combine∘subtract1
|
|
||||||
= identity round-trips, rotate vs quaternion reference; cross-check values against ACE
|
|
||||||
`Sequence.cs:221` + `AFrame.cs`.
|
|
||||||
Fixture source: **synthetic** (pure math; no dat needed).
|
|
||||||
Dependencies: P2 (fields), parallel-safe with P1 internals.
|
|
||||||
|
|
||||||
**P4 — `update_internal` + `update` + `advance_to_next_animation`.** (closes G3, G4, G5, G6-queue, G8, G9, G19)
|
|
||||||
Verbatim per the ACE-verified skeleton: floor(lastFrame) → advance frame_number → overshoot clamp
|
|
||||||
to `get_high_frame()`/`get_low_frame()` + `frameTimeElapsed` leftover + animDone → per-frame
|
|
||||||
crossing loop (combine/subtract pos_frame if `pos_frames != null`; `apply_physics(1/framerate,
|
|
||||||
elapsed)` if `|framerate| ≥ 0.000199999995f`; `execute_hooks(part_frame, +1/-1)`) → `if
|
|
||||||
(!animDone) return` → AnimDone gate `head != first_cyclic` → queue global AnimDoneHook →
|
|
||||||
`advance_to_next_animation` (four pose ops; fwd wrap first_cyclic, rev wrap TAIL) → carry leftover
|
|
||||||
(per P0's pin) → loop (iterative, not ACE's recursion). `update`: non-empty → internal+`apricot`;
|
|
||||||
empty → `apply_physics(frame, elapsed, elapsed)`. `execute_hooks` queues into an
|
|
||||||
`IAnimHookQueue` host seam (stands in for `CPhysicsObj.anim_hooks`; GameWindow drain point
|
|
||||||
unchanged until R6). NO safety cap.
|
|
||||||
Tests first — the R1 conformance harness core:
|
|
||||||
(a) **dat fixture**: Humanoid walk cycle advanced at fixed 1/30s quanta N ticks — golden
|
|
||||||
`frame_number` series + hook-fire (frame,direction) sequence;
|
|
||||||
(b) **synthetic**: multi-node fast-forward in one tick (lag spike) — hook ORDER across nodes +
|
|
||||||
AnimDone timing; reverse playback; exact-integer boundary landings (the G3 class); zero-framerate
|
|
||||||
node; empty-list physics fallback;
|
|
||||||
(c) **cdb goldens from P0**: advance-per-update counts + frame_number progression trace.
|
|
||||||
Dependencies: P1+P2+P3.
|
|
||||||
|
|
||||||
**P5 — adapter cutover: `AnimationSequencer` rehosted on the core.** (closes G13-split, G17-core; DELETES stale-head relocation, ClearCyclicTail surgery, per-node Velocity/Omega, safety cap, boundary-epsilon)
|
|
||||||
`SetCycle` rebuild becomes: `remove_cyclic_anims()` [+ `remove_all_link_animations` where the old
|
|
||||||
code cleared] → per-AnimData `append_animation(speed-scaled AnimData)` (= retail free-fn
|
|
||||||
`add_motion 0x005224b0`, unconditional `set_velocity/set_omega` in core) → fast path =
|
|
||||||
`change_cycle_speed`-equivalent (`multiply_cyclic_animation_fr` framerates-only) + adapter-level
|
|
||||||
velocity rescale (the R2 subtract/combine composite, kept at adapter, register row). Invented
|
|
||||||
behaviors that SURVIVE at adapter level, byte-identical: K-fix18, Fix B, stop-anim fallback,
|
|
||||||
GetLink reversed branch, velocity synthesis (each verified to still have/get its register row).
|
|
||||||
`Advance` becomes `update(dt, frame)` + `BuildBlendedFrame` reading core `curr_anim`/
|
|
||||||
`frame_number`. `PlayAction` inserts via core list ops.
|
|
||||||
Tests first: FULL existing suite green (behavior-parity is the acceptance bar) + adapter parity
|
|
||||||
tests (same SetCycle call sequences → same selected cycle/link + same hook stream as pre-cutover
|
|
||||||
recordings taken BEFORE this commit). #61's boundary-hold re-tested against verbatim boundary
|
|
||||||
math — if the flash is gone with the hold removed, delete the hold (register row retired); if
|
|
||||||
not, keep + re-file #61 with the new evidence.
|
|
||||||
Fixture source: recorded pre-cutover adapter traces (**synthetic harness**) + user visual smoke.
|
|
||||||
Dependencies: P4.
|
|
||||||
|
|
||||||
**P6 — root-motion/placement wiring + API narrowing + register sweep.** (closes G7-wiring, G14-consumers, dead-API cleanup)
|
|
||||||
`update(quantum, Frame)` output exposed to the GameWindow tick as the entity root-motion delta
|
|
||||||
(replaces dead `ConsumeRootMotionDelta` — delete it); placement-frame path wired for
|
|
||||||
anim-less objects (`get_curr_animframe` fallback); delete `Reset()`/`HasCurrentNode` or map to
|
|
||||||
`clear()`/`curr_anim != null`; `MultiplyCyclicFramerate` public surface delegates to core.
|
|
||||||
Consumers keep reading `CurrentVelocity/CurrentOmega` (adapter mirrors core `velocity/omega` +
|
|
||||||
synthesis until R3). Register reconciliation + roadmap stage-table update + memory digest note.
|
|
||||||
Fixture source: existing launch-protocol smoke (`ACDREAM_REMOTE_VEL_DIAG` off/on parity) + suite.
|
|
||||||
Dependencies: P5.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API MIGRATION — consumer survival through the cutover
|
|
||||||
|
|
||||||
R1 is **adapter-preserving**: every public `AnimationSequencer` member keeps its signature through
|
|
||||||
P5; the core replaces the internals. Narrowing happens only in P6 and touches only dead surface.
|
|
||||||
|
|
||||||
| Consumer (from r1-acdream-sequencer map) | Call sites | R1 impact |
|
|
||||||
|---|---|---|
|
|
||||||
| `GameWindow` spawn/fallback cycles | `:3723/3728/3732/3824` (`HasCycle`), `:3751/:3825` (`SetCycle`) | none — adapter unchanged |
|
|
||||||
| `GameWindow` jump/land/stop | `:4830` (K-fix18), `:5155/:5309/:9817` | none — K-fix18 param preserved at adapter until R3 |
|
|
||||||
| `GameWindow` NPC legacy path | `:4936` (`ApplyServerControlledVelocityCycle`) | none — path dies in R2/R6, not R1 |
|
|
||||||
| `GameWindow` local-player cycle | `:10223` | none |
|
|
||||||
| `GameWindow` anim tick | `:9876` (`Advance`), `:9882` (`ConsumePendingHooks`) | signatures unchanged; `Advance` internally becomes `update()`+blend. Hook STREAM content must be parity-tested (P5) since AnimDone timing changes gate (G5) — RemoteMotionSink/GameWindow don't consume AnimDone yet (R2 does), so risk is bounded to the hook fan-out sinks |
|
|
||||||
| `RemoteMotionSink.Commit` | `RemoteMotionSink.cs:215` (`SetCycle`), + `HasCycle`, `ApplyMotion→PlayAction` | none — sink dissolves in R2, not R1 |
|
|
||||||
| `AnimationCommandRouter` | `RouteFullCommand → SetCycle/PlayAction` | none |
|
|
||||||
| `CurrentVelocity/CurrentOmega` readers | `GameWindow.cs:9331-9334` (remote body translation), `:12917` (`AttachCycleVelocityAccessor` → `MotionInterpreter.GetCycleVelocity`), `MotionInterpreter` docs | semantics preserved: adapter keeps replace-gate + locomotion synthesis EXACTLY as today (G17 core/adapter split); values must be bit-identical for locomotion low-bytes — covered by P5 parity tests |
|
|
||||||
| `CurrentStyle/CurrentMotion/CurrentSpeedMod` readers | `GameWindow.cs:3723/4827/4915/4919`, `RemoteMotionSink` ctor+Commit | adapter-owned bookkeeping, untouched |
|
|
||||||
| Diagnostics `CurrentNodeDiag`/`FirstCyclicAnimRefHash`/`QueueCount` | `GameWindow.cs:9863-9871` `[CURRNODE]` block | re-expressed over core list (AnimRefHash from core node's `anim`); tuple shape kept |
|
|
||||||
| `ConsumeRootMotionDelta` | **zero callers** | deleted in P6; replaced by `update(quantum, Frame)` output |
|
|
||||||
| `Reset` / `HasCurrentNode` / external `MultiplyCyclicFramerate` | zero external callers | P6: map to `clear()` / `curr_anim != null` / core delegate, or delete |
|
|
||||||
| `AnimationHookRouter` / `IAnimationHookSink` sinks | `GameWindow.cs:9890` fan-out | unchanged in R1; hook payload type stays DatReaderWriter's `AnimationHook`. (Side note for a separate issue, NOT R1: router's silent catch-all has no logger seam — `feedback_logger_injection_for_silent_catches.md`) |
|
|
||||||
| `RenderBootstrap.SequencerFactory` | `:138/:147-174` (3-tier Setup/MotionTable fallback) | ctor signature unchanged; empty-MotionTable tier must still yield a working do-nothing sequencer (add a P5 test: empty table → `has_anims()==false` → physics-only update path, no throw) |
|
|
||||||
|
|
||||||
**Cutover invariants (P5 acceptance):** (1) full existing test suite green untouched; (2) recorded
|
|
||||||
SetCycle→hook/pose traces byte-parity vs pre-cutover for the standard protocol (walk/run/toggle/
|
|
||||||
turn/stop/jump, player+NPC); (3) every deleted invented mechanism's register row retired in the
|
|
||||||
deleting commit; every surviving adapter-level invention has a row; (4) one user visual pass at
|
|
||||||
R1 end (plan: eyes are final sanity only).
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# R2-Q0 — ambiguity pins and ACE-oddity adjudications
|
|
||||||
|
|
||||||
The verbatim extraction is `r2-motiontable-decomp.md` (1,603 lines, line-anchored);
|
|
||||||
the port plan with the full ambiguity table is `r2-port-plan.md` §0. This note
|
|
||||||
records the PINNED resolutions the Q1–Q5 ports code against.
|
|
||||||
|
|
||||||
## Pinned
|
|
||||||
|
|
||||||
- **A1 — `get_link` branch predicate: PINNED to ACE's reading** ("EITHER speed
|
|
||||||
negative → swapped-key branch"). Three independent corroborations: ACE
|
|
||||||
`MotionTable.cs:395-426`; the working adapter's field-validated `GetLink`
|
|
||||||
(the reversed-key branch fixed the Ready→WalkBackward glitch); call-site arg
|
|
||||||
roles. The BN "both negative" reading is the same x87-flag-noise class as
|
|
||||||
`is_newer`'s garbled setcc and the R1 hook-direction swap. cdb confirmation
|
|
||||||
(bp `CMotionTable::get_link`, Ready→WalkBackward vs Ready→WalkForward)
|
|
||||||
folds into the next live retail session — non-blocking.
|
|
||||||
- **A2 — Branch-2 `signedSpeed`: PINNED to ACE** (`SubstateMod < 0 &&
|
|
||||||
speedMod > 0 → -speedMod`, the single-direction flip) pending the same cdb
|
|
||||||
session (golden: a Walk(−)→Walk(+) flip).
|
|
||||||
- **A3 — outTicks** = sum of each appended MotionData's `num_anims` (+ base
|
|
||||||
cycle in Branch 1/2) − 1. The `action_head` rendering in BN is the packed
|
|
||||||
`num_anims` byte (decomp's own note).
|
|
||||||
- **A4 — ACE oddities adjudicated:** (1) Action-branch numAnims double-count =
|
|
||||||
ACE BUG, do not copy (retail sums outHop + actionLink [+ returnHop] only);
|
|
||||||
(2) `change_cycle_speed` old≈0 silent no-op = RETAIL (port verbatim,
|
|
||||||
including the gap); (3) `GetLinkData` 0xFFFF mask = ACE-only helper, not
|
|
||||||
ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 :
|
|
||||||
anyModifierStopOk`, port verbatim; (5) `re_modify` snapshot = deep-copied
|
|
||||||
MotionState used ONLY as the loop-termination bound — C# port deep-copies,
|
|
||||||
pops both, terminates on snapshot empty.
|
|
||||||
- **A5 — `MotionData.Bitfield`** (bit1 = substate-gated for `is_allowed`,
|
|
||||||
bit0 = clear-modifiers-on-entry): confirm on DatReaderWriter
|
|
||||||
`Types.MotionData` with a one-line Humanoid-table test at Q2.
|
|
||||||
|
|
||||||
## Q0 cdb capture (pending, non-blocking)
|
|
||||||
|
|
||||||
One live session feeds all R2 goldens: bp GetObjectSequence / get_link /
|
|
||||||
StopSequenceMotion / add_to_queue / remove_redundant_links /
|
|
||||||
truncate_animation_list / AnimationDone / CheckForCompletedMotions with
|
|
||||||
arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`); protocol per
|
|
||||||
`r2-port-plan.md` §0. Until then Q2/Q3 rest on dat fixtures + synthetic
|
|
||||||
state tables + the archived 2026-05-03 walk→run trace golden (quoted in the
|
|
||||||
old Fix B comment block).
|
|
||||||
|
|
@ -1,548 +0,0 @@
|
||||||
# ACE MotionTable / MotionTableManager port — cross-reference map
|
|
||||||
|
|
||||||
Files read in full:
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs` (615 lines)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (251 lines)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Animation/AnimNode.cs` (16 lines)
|
|
||||||
- `references/ACE/Source/ACE.DatLoader/Entity/MotionData.cs` (34 lines)
|
|
||||||
- Cross-refs: `PhysicsObj.cs` (L296-300, L653-657, L899-902), `MovementManager.cs` (L118-121),
|
|
||||||
`MotionInterp.cs` (L210-231, L260), `PartArray.cs` (L52-56, L72-76, L135, L253-293, L425-435, L577-586),
|
|
||||||
`WeenieObject.cs` (L256-259)
|
|
||||||
|
|
||||||
**NOTE (important for the parent report):** ACE has TWO parallel "pending motion" trackers that
|
|
||||||
both descend from a `PhysicsObj`, and they are easy to conflate:
|
|
||||||
|
|
||||||
1. `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`) — driven by
|
|
||||||
`Table.DoObjectMotion`/`GetObjectSequence` (the **interpreted-command / high-level motion**
|
|
||||||
path). Its `AnimationDone`/`CheckForCompletedMotions` are the ones requested for this doc.
|
|
||||||
2. `MotionInterp.PendingMotions` — a *separate* list inside `MotionInterp.cs` (not covered file,
|
|
||||||
but referenced at MotionInterp.cs:210-231) that also has a `MotionDone(bool success)` method.
|
|
||||||
`MovementManager.MotionDone` calls `MotionInterpreter.MotionDone(success)`, i.e. the **raw**
|
|
||||||
motion-interp side, NOT `MotionTableManager.AnimationDone`. `MotionTableManager` and
|
|
||||||
`MotionInterp` are wired independently; `MotionTableManager` lives under `PartArray`, while
|
|
||||||
`MotionInterp` lives under `MovementManager`. Both ultimately get fed by `PhysicsObj.MotionDone`
|
|
||||||
but through different owner objects (`PartArray.MotionTableManager` vs
|
|
||||||
`MovementManager.MotionInterpreter`), and only one of the two is authoritative depending on
|
|
||||||
whether the object is server-simulated purely by object-broadcast Motion commands (uses
|
|
||||||
MotionTableManager via PartArray) vs. by the mover's own physics timestep + MoveToManager
|
|
||||||
(uses MotionInterp). Do not assume `MotionTableManager.AnimationDone` is "the" MotionDone path
|
|
||||||
for player-driven movement — cross-check which owner actually gets ticked for the acdream use
|
|
||||||
case before porting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MotionTable.cs (`ACE.Server.Physics.Animation.MotionTable`)
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
```
|
|
||||||
uint ID
|
|
||||||
Dictionary<uint,uint> StyleDefaults // style -> default substate
|
|
||||||
Dictionary<uint,MotionData> Cycles // key = (style<<16)|substate -> cycle anim data
|
|
||||||
Dictionary<uint,MotionData> Modifiers // key = (style<<16)|modifierID or just modifierID -> modifier anim data
|
|
||||||
Dictionary<uint,Dictionary<uint,MotionData>> Links // key = (style<<16)|fromSubstate -> { toMotion -> transition MotionData }
|
|
||||||
uint DefaultStyle
|
|
||||||
static ConcurrentDictionary<uint,float> WalkSpeed / RunSpeed / TurnSpeed // per-motionTableID cache
|
|
||||||
```
|
|
||||||
Constructed either empty (`Allocator()`) or from the dat-loaded
|
|
||||||
`DatLoader.FileTypes.MotionTable` (straight field copy, no transform — L40-48).
|
|
||||||
|
|
||||||
### `DoObjectMotion(motion, currState, sequence, speedMod, ref numAnims)`
|
|
||||||
Trivial forwarder: `return GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers: false);`
|
|
||||||
(L55-58)
|
|
||||||
|
|
||||||
### `GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers)` — L60-257
|
|
||||||
The core state-transition dispatcher. `numAnims` is reset to 0 up front.
|
|
||||||
|
|
||||||
Early-out: if `currState.Style == 0 || currState.Substate == 0` → `false` (uninitialized state).
|
|
||||||
|
|
||||||
Looks up `substate = StyleDefaults[currState.Style]` (the *default substate for the current
|
|
||||||
style*, e.g. "Standing" under style "NonCombat").
|
|
||||||
|
|
||||||
**Guard (L73-74):** if `motion == substate` (i.e. caller is asking to enter the style's own
|
|
||||||
default substate) AND `!stopModifiers` AND current substate already has the Modifier bit set
|
|
||||||
(`CommandMask.Modifier`) → return `true` immediately (no-op — already effectively there via a
|
|
||||||
modifier).
|
|
||||||
|
|
||||||
Then four command-mask branches, checked with **plain OR semantics — NOT else-if.** Each branch
|
|
||||||
can independently fire and `return true` from inside if it succeeds; falling out of a branch
|
|
||||||
(motionData null, is_allowed false, etc.) falls through to the next mask check.
|
|
||||||
|
|
||||||
**`CommandMask.Style` branch (L76-120)** — motion requests a stance/style change (e.g. switch
|
|
||||||
combat stance):
|
|
||||||
- If `currState.Style == motion` already → `true` (no-op).
|
|
||||||
- If `substate != currState.Substate`: compute `motionData = get_link(currState.Style,
|
|
||||||
currState.Substate, currState.SubstateMod, substate, speedMod)` — the transition INTO the new
|
|
||||||
style's default substate from the current substate.
|
|
||||||
- If `substate != 0`: look up `cycles = Cycles[(motion<<16)|substate]` — the cycle anim for the
|
|
||||||
new style's default substate.
|
|
||||||
- If found:
|
|
||||||
- `(cycles.Bitfield & 1) != 0` → `currState.clear_modifiers()` (bit 1 = "clears modifiers on
|
|
||||||
entry", e.g. entering a stance that can't carry over swimming/etc modifiers).
|
|
||||||
- `link = get_link(currState.Style, substate, currState.SubstateMod, motion, speedMod)` —
|
|
||||||
transition from (old style, NEW style's default substate) to the target style itself.
|
|
||||||
- **Fallback chain if `link == null` and `currState.Style != motion`:** re-resolve via
|
|
||||||
`DefaultStyle` — `link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f)`,
|
|
||||||
then `motionData_ = get_link(DefaultStyle, StyleDefaults[DefaultStyle], 1.0f, motion, 1.0f)`.
|
|
||||||
This is the "no direct link exists, route through the global default style" path (e.g.
|
|
||||||
Standing).
|
|
||||||
- `sequence.clear_physics(); sequence.remove_cyclic_anims();` — wipe outstanding velocity/
|
|
||||||
omega contributions and any looping cycle anims before splicing in the new chain.
|
|
||||||
- Append in strict order: `add_motion(motionData)`, `add_motion(link)`,
|
|
||||||
`add_motion(motionData_)`, `add_motion(cycles)` — i.e. [transition-to-default-substate] →
|
|
||||||
[transition-to-target-style] → [fallback-via-default-style, usually null] →
|
|
||||||
[new cycle]. Each `add_motion` no-ops silently on null `motionData`.
|
|
||||||
- Commits state: `currState.Substate = substate; currState.Style = motion; currState.SubstateMod = speedMod;`
|
|
||||||
- `re_modify(sequence, currState)` — replays any still-active modifiers (see below) on top
|
|
||||||
of the newly spliced sequence.
|
|
||||||
- `numAnims = sum of each non-null MotionData's Anims.Count, minus 1` (the `-1` is
|
|
||||||
because the queue entry itself represents completion of ONE playthrough of the LAST
|
|
||||||
appended motion's cycle, not a raw anim count — cross-check against `add_to_queue` in
|
|
||||||
MotionTableManager, which stores this count as `AnimNode.NumAnims`, i.e. the number of
|
|
||||||
"hook" firings, one per queued sub-anim minus a terminal borrow).
|
|
||||||
- Returns `true`.
|
|
||||||
|
|
||||||
**`CommandMask.SubState` branch (L121-188)** — motion requests a substate change within the
|
|
||||||
current style (e.g. Standing → Crouching), OR a coalesced-speed no-op update to the CURRENT
|
|
||||||
cycle:
|
|
||||||
- `motionID = motion & 0xFFFFFF` (strip command-class bits).
|
|
||||||
- `motionData = Cycles[(currState.Style<<16)|motionID]`, falling back to
|
|
||||||
`Cycles[(DefaultStyle<<16)|motionID]` if the current style doesn't define that substate.
|
|
||||||
- If found and `is_allowed(motion, motionData, currState)` (see below):
|
|
||||||
- **Speed-only fast path (L132-139):** if `motion == currState.Substate` AND
|
|
||||||
`sequence.HasAnims()` AND `Math.Sign(speedMod) == Math.Sign(currState.SubstateMod)` — i.e.
|
|
||||||
caller is re-issuing the SAME substate at a new speed, same direction of travel (fwd vs
|
|
||||||
reverse) — then: `change_cycle_speed` (rescale the already-playing cyclic anim's framerate),
|
|
||||||
`subtract_motion` (remove the old velocity/omega contribution at the old speed),
|
|
||||||
`combine_motion` (add back at the new speed), update `currState.SubstateMod = speedMod`,
|
|
||||||
return `true`. **No new anims queued, no sequence splice** — this is the "already running,
|
|
||||||
just changing speed" branch (e.g. walk→run without breaking stride).
|
|
||||||
- Otherwise (new substate, or sign flip == direction reversal):
|
|
||||||
- `(motionData.Bitfield & 1) != 0` → `currState.clear_modifiers()`.
|
|
||||||
- `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`
|
|
||||||
— direct transition from current substate to target substate.
|
|
||||||
- **Fallback (L145-151):** if `link == null` OR the sign of `speedMod` differs from
|
|
||||||
`currState.SubstateMod` (direction reversal, e.g. forward↔backward) — route through the
|
|
||||||
style's default substate: `link = get_link(currState.Style, currState.Substate,
|
|
||||||
currState.SubstateMod, defaultMotion, 1.0f)` (transition out to default),
|
|
||||||
`motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod)`
|
|
||||||
(transition from default into target).
|
|
||||||
- `sequence.clear_physics(); sequence.remove_cyclic_anims();`
|
|
||||||
- If `motionData_ != null` (fallback path taken): append `link` at `currState.SubstateMod`,
|
|
||||||
then `motionData_` at `speedMod`.
|
|
||||||
- Else (direct link path): `newSpeedMod = speedMod`, but **flip sign** if
|
|
||||||
`currState.SubstateMod < 0 && speedMod > 0` (i.e. reversing FROM negative — asymmetric
|
|
||||||
handling, only corrects one direction of sign mismatch, not both) — append `link` at
|
|
||||||
`newSpeedMod`.
|
|
||||||
- Always append `motionData` (the new cycle) at `speedMod`.
|
|
||||||
- **Modifier carry-over (L170-176):** if the OLD substate differs from the new `motion` AND
|
|
||||||
the old substate had the Modifier bit set, AND the old substate isn't just the style's
|
|
||||||
default motion, re-add it as an active modifier via `currState.add_modifier_no_check`
|
|
||||||
(i.e. modifiers riding on a substate survive a substate change unless they equal the new
|
|
||||||
target or the style default).
|
|
||||||
- Commit `currState.SubstateMod = speedMod; currState.Substate = motion;`, then `re_modify`.
|
|
||||||
- `numAnims = motionData.Anims.Count + link.Anims.Count + motionData_.Anims.Count - 1`
|
|
||||||
(nulls treated as 0).
|
|
||||||
- Returns `true`.
|
|
||||||
|
|
||||||
**`CommandMask.Action` branch (L189-233)** — one-shot action motions (attacks, jumps, etc,
|
|
||||||
things layered on TOP of a cycle without replacing it):
|
|
||||||
- `cycleKey = (currState.Style<<16)|(currState.Substate & 0xFFFFFF)`; `motionData =
|
|
||||||
Cycles[cycleKey]` (the CURRENT cycle, must exist).
|
|
||||||
- If found:
|
|
||||||
- `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`.
|
|
||||||
- **If `link != null` (direct action link exists):** `currState.add_action(motion, speedMod)`
|
|
||||||
(push onto the action stack — see MotionState, not read this pass), clear physics/cyclic,
|
|
||||||
append `link` at `speedMod` then re-append `motionData` (the ORIGINAL cycle, at the OLD
|
|
||||||
`currState.SubstateMod` — i.e. resume the cycle after the action plays), `re_modify`,
|
|
||||||
`numAnims = link.Anims.Count` (note: NOT including motionData's count here — only the
|
|
||||||
action-link portion counts toward completion tracking, the re-appended base cycle is
|
|
||||||
presumably a normal looping anim not subject to the same "done" semantics).
|
|
||||||
- **Else (no direct action link — fallback via style default, L209-231):**
|
|
||||||
`motionData = get_link(currState.Style, currState.Substate, currState.SubstateMod, substate, 1.0f)`
|
|
||||||
(transition current substate → style's own default substate) as a NEW `motionData`
|
|
||||||
(shadows the cycle lookup above). If that resolves: `link = get_link(currState.Style,
|
|
||||||
substate, 1.0f, motion, speedMod)` (default substate → action), and re-fetch `cycles =
|
|
||||||
Cycles[cycleKey]` (original cycle again). If `link != null` and `cycles` found:
|
|
||||||
`motionData_ = get_link(currState.Style, substate, 1.0f, currState.Substate,
|
|
||||||
currState.SubstateMod)` (default substate → back to original substate, for resuming after).
|
|
||||||
`currState.add_action(...)`, clear physics/cyclic, append in order `motionData` (→default),
|
|
||||||
`link` (default→action), `motionData_` (default→back), `cycles` (resume original cycle at
|
|
||||||
`currState.SubstateMod`). `re_modify`. `numAnims = motionData.Anims.Count +
|
|
||||||
link.Anims.Count + (motionData_ != null ? motionData.Anims.Count : 0)` — **NOTE: this last
|
|
||||||
term reads `motionData.Anims.Count` again, NOT `motionData_.Anims.Count` — looks like a copy-
|
|
||||||
paste bug in ACE's port** (compare to the Style/SubState branches which correctly sum each
|
|
||||||
distinct MotionData). Flag this explicitly when cross-checking against 2013 retail —
|
|
||||||
likely a genuine ACE divergence, not a retail behavior to replicate.
|
|
||||||
|
|
||||||
**`CommandMask.Modifier` branch (L234-255)** — continuous modifiers layered on the current cycle
|
|
||||||
(e.g. sneaking, aiming overlay) that don't interrupt it:
|
|
||||||
- `styleKey = currState.Style<<16`; `cycles = Cycles[styleKey|(currState.Substate&0xFFFFFF)]`
|
|
||||||
(current cycle must exist).
|
|
||||||
- If found AND `(cycles.Bitfield & 1) == 0` (cycle does NOT forbid modifiers):
|
|
||||||
- `motionData = Modifiers[styleKey|(motion&0xFFFFFF)]`, falling back to
|
|
||||||
`Modifiers[motion&0xFFFFFF]` (style-agnostic modifier) if not found.
|
|
||||||
- If found:
|
|
||||||
- `if (!currState.add_modifier(motion, speedMod))` — if the state rejects adding this
|
|
||||||
modifier (e.g. already present / list full):
|
|
||||||
- `StopSequenceMotion(motion, 1.0f, currState, sequence, ref numAnims)` — force-remove it
|
|
||||||
first, then retry `add_modifier`. If STILL fails, return `false`.
|
|
||||||
- `combine_motion(sequence, motionData, speedMod)` — layer the modifier's velocity/omega +
|
|
||||||
anims onto the sequence WITHOUT clearing physics or cyclic anims (additive, unlike the
|
|
||||||
other three branches which splice/replace).
|
|
||||||
- Returns `true`. **No numAnims write here — stays whatever it was reset to (0) at entry,
|
|
||||||
i.e. modifiers are not tracked for animation-completion purposes.**
|
|
||||||
|
|
||||||
Falls through all four branches unmatched → `return false`.
|
|
||||||
|
|
||||||
### `Get(uint motionTableID)` — static factory, L259-264
|
|
||||||
Directly `DatManager.PortalDat.ReadFromDat<FileTypes.MotionTable>(id)` wrapped in `new
|
|
||||||
MotionTable(...)`. Comment `//return ObjCache.GetMotionTable(mtableID);` — retail apparently
|
|
||||||
caches motion tables by ID; ACE re-reads from dat every call (no caching layer here, though the
|
|
||||||
dat reader itself likely caches file reads elsewhere).
|
|
||||||
|
|
||||||
### `SetDefaultState(state, sequence, ref numAnims)` — L266-291
|
|
||||||
Resets to the table's global default (style, substate) pair:
|
|
||||||
- `defaultSubstate = StyleDefaults[DefaultStyle]`; if missing → `false`.
|
|
||||||
- `state.clear_modifiers(); state.clear_actions();`
|
|
||||||
- `cycle = (DefaultStyle<<16)|defaultSubstate`; `motionData = Cycles[cycle]`; if missing → `false`.
|
|
||||||
- `numAnims = motionData.Anims.Count - 1`.
|
|
||||||
- `state.Style = DefaultStyle; state.Substate = defaultSubstate; state.SubstateMod = 1.0f;`
|
|
||||||
- `sequence.clear_physics(); sequence.clear_animations();` (note: `clear_animations`, not
|
|
||||||
`remove_cyclic_anims` — a harder reset, used only here and presumably at spawn/enter-world).
|
|
||||||
- `add_motion(sequence, motionData, 1.0f)`.
|
|
||||||
|
|
||||||
### `StopObjectCompletely(currState, sequence, ref numAnims)` — L293-313
|
|
||||||
Iterates and removes ALL active modifiers via `StopSequenceMotion` (loop drains
|
|
||||||
`currState.Modifiers.First` until empty — relies on `StopSequenceMotion` removing the head each
|
|
||||||
call), tracking whether ANY modifier stop succeeded (`success`). Then stops the current substate
|
|
||||||
itself: `StopSequenceMotion(currState.Substate, currState.SubstateMod, ...)`. Returns `true` if
|
|
||||||
EITHER the substate-stop succeeded OR any earlier modifier-stop succeeded (`success ||
|
|
||||||
substateStopSucceeded`, though written as an if/else that returns `true` whenever the final
|
|
||||||
substate-stop call returns non-... — re-read: `if (!StopSequenceMotion(...)) return success; else
|
|
||||||
return true;` — i.e. final result is `true` unless the LAST stop call fails, in which case it
|
|
||||||
falls back to whatever `success` was from the modifier loop).
|
|
||||||
|
|
||||||
### `StopObjectMotion` / `StopSequenceMotion` — L315-356
|
|
||||||
`StopObjectMotion` is a trivial forwarder to `StopSequenceMotion`.
|
|
||||||
|
|
||||||
`StopSequenceMotion(motion, speed, currState, sequence, ref numAnims)`:
|
|
||||||
- `numAnims = 0`.
|
|
||||||
- **SubState case:** if `(motion & CommandMask.SubState) != 0 && currState.Substate == motion` —
|
|
||||||
i.e. caller wants to stop the CURRENT substate → resolve the style's default substate and
|
|
||||||
re-enter it via `GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims,
|
|
||||||
stopModifiers: true)` (recursion into the main dispatcher with `stopModifiers=true`, which
|
|
||||||
suppresses the early "already at default via modifier" guard at L73). Returns `true`
|
|
||||||
unconditionally after this call (return value of the inner `GetObjectSequence` is discarded).
|
|
||||||
- **Non-modifier, non-matching-substate case:** if `(motion & CommandMask.Modifier) == 0` →
|
|
||||||
`false` (nothing to stop — motion isn't a substate-stop or a modifier).
|
|
||||||
- **Modifier case:** linear-scan `currState.Modifiers` linked list for a node whose `.ID ==
|
|
||||||
motion`. On match:
|
|
||||||
- `key = (currState.Style<<16)|(motion&0xFFFFFF)`; `Modifiers[key]`, falling back to
|
|
||||||
`Modifiers[motion&0xFFFFFF]`. If neither resolves → `false`.
|
|
||||||
- `subtract_motion(sequence, motionData, modifier.Value.SpeedMod)` — reverse the modifier's
|
|
||||||
velocity/omega contribution using its ORIGINAL speed (not the `speed` parameter passed in —
|
|
||||||
`speed` param appears unused in this branch; only used implicitly via the SubState-case call
|
|
||||||
above). `currState.remove_modifier(modifier)`. Returns `true`.
|
|
||||||
- No match found after scanning entire list → `false`.
|
|
||||||
|
|
||||||
### `add_motion` / `combine_motion` / `subtract_motion` / `change_cycle_speed` — L358-393
|
|
||||||
- **`add_motion(sequence, motionData, speed)`:** no-op if `motionData == null`. Otherwise
|
|
||||||
`sequence.SetVelocity(motionData.Velocity * speed)`, `sequence.SetOmega(motionData.Omega *
|
|
||||||
speed)` (REPLACES, not adds — "Set" not "Combine"), then for each anim in `motionData.Anims`
|
|
||||||
wraps as `new AnimData(anim, speed)` and `sequence.append_animation(animData)`.
|
|
||||||
- **`combine_motion(sequence, motionData, speed)`:** no-op if null. Otherwise
|
|
||||||
`sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed)` — additive
|
|
||||||
variant used by the Modifier branch (doesn't touch the anim queue at all, only velocity/omega).
|
|
||||||
- **`subtract_motion(sequence, motionData, speed)`:** no-op if null. `sequence.subtract_physics
|
|
||||||
(motionData.Velocity * speed, motionData.Omega * speed)` — inverse of combine, used when
|
|
||||||
removing a modifier or an old-speed cycle contribution.
|
|
||||||
- **`change_cycle_speed(sequence, motionData, substateMod, speedMod)`:** if
|
|
||||||
`|substateMod| > PhysicsGlobals.EPSILON` → `sequence.multiply_cyclic_animation_framerate
|
|
||||||
(speedMod/substateMod)` (rescale by the RATIO of new to old speed). Else-if `|speedMod| <
|
|
||||||
EPSILON` → `multiply_cyclic_animation_framerate(0)` (freeze the anim — old speed was ~0 so no
|
|
||||||
ratio is definable, new speed is also ~0). **Gap: if `substateMod` ~0 AND `speedMod` is
|
|
||||||
NON-zero, neither branch fires — no framerate change is applied.** Worth checking against
|
|
||||||
retail whether this is a real edge case (resuming a stopped cycle at nonzero speed without a
|
|
||||||
ratio) — could be a silent no-op bug carried from retail or an ACE gap.
|
|
||||||
|
|
||||||
### `get_link(style, substate, substateSpeed, motion, speed)` — L395-426
|
|
||||||
Direction-aware transition lookup with two symmetric lookup CHAINS depending on sign of the
|
|
||||||
speeds:
|
|
||||||
- **If EITHER `speed < 0` or `substateSpeed < 0` (reverse-direction transition):** look up
|
|
||||||
`Links[(style<<16)|(motion&0xFFFFFF)]` (keyed by DESTINATION motion) then `.TryGetValue
|
|
||||||
(substate, ...)` (indexed by SOURCE substate) — i.e. reversed key order vs the forward case.
|
|
||||||
If that fails, fall back through `StyleDefaults[style]` and
|
|
||||||
`Links[(style<<16)|(substate&0xFFFFFF)]` → `.TryGetValue(defaultMotion, ...)`.
|
|
||||||
- **Else (forward / non-negative speeds):** look up `Links[(style<<16)|(substate&0xFFFFFF)]`
|
|
||||||
(keyed by SOURCE substate) then `.TryGetValue(motion, ...)` (indexed by DESTINATION). Fallback:
|
|
||||||
`Links[style<<16]` (style-wide, substate-agnostic) → `.TryGetValue(motion, ...)`.
|
|
||||||
- Returns `null` if nothing resolves in either chain.
|
|
||||||
This encodes retail's dat-side Links table having asymmetric (from,to) vs (to,from) storage
|
|
||||||
depending on animation reversibility — reverse playback (e.g. walking backward) reuses the
|
|
||||||
FORWARD anim's link table keyed the other way around rather than storing a mirrored copy.
|
|
||||||
|
|
||||||
### `is_allowed(motion, motionData, state)` — L428-438
|
|
||||||
`false` if `motionData == null`. `true` if `(motionData.Bitfield & 2) == 0` (bit 2 = "always
|
|
||||||
allowed" flag) OR `motion == state.Substate` (re-entering the same substate is always allowed
|
|
||||||
regardless of the bit). Otherwise: only allowed if the CURRENT substate IS the style's own
|
|
||||||
default substate (`StyleDefaults[state.Style] == state.Substate`) — i.e. bit-2-gated substates
|
|
||||||
can only be entered FROM the style's default/neutral substate, not chained from an arbitrary
|
|
||||||
other substate.
|
|
||||||
|
|
||||||
### `re_modify(sequence, pstate)` — L440-458
|
|
||||||
No-op if `pstate.Modifiers.First == null`. Otherwise snapshots `pstate` into a NEW `MotionState
|
|
||||||
state = new MotionState(pstate)` (deep-ish copy incl. its own Modifiers list), then drains
|
|
||||||
`pstate`'s modifier list one node at a time: pops `speedMod`/`motion` off `pstate.Modifiers.First`,
|
|
||||||
removes the SAME logical entry from BOTH `pstate` and the snapshot `state` (odd double-removal —
|
|
||||||
removing from the snapshot copy seems purposeless unless `MotionState`'s copy ctor shares the
|
|
||||||
underlying list nodes, in which case this is defensive against aliasing), then calls
|
|
||||||
`GetObjectSequence(motion, pstate, sequence, speedMod, ref numAnims, stopModifiers: false)` to
|
|
||||||
RE-APPLY each modifier on top of the now-current (post-transition) `pstate`. This is how the
|
|
||||||
Style/SubState branches "carry forward" active modifiers across a cycle-changing transition —
|
|
||||||
after splicing the new base cycle in, `re_modify` walks the still-active modifier list (which at
|
|
||||||
that point is whatever the branch didn't already clear) and re-runs each one through the full
|
|
||||||
dispatcher so its layered anims/physics get re-appended onto the NEW sequence.
|
|
||||||
|
|
||||||
### Static helpers (L460-613)
|
|
||||||
- **`GetAttackFrames(motionTableId, stance, motion)`** — dat-lookup passthrough to
|
|
||||||
`DatLoader.FileTypes.MotionTable.GetAttackFrames` (not in this file). Returns cached
|
|
||||||
`emptyList` for `motionTableId == 0`.
|
|
||||||
- **`GetAnimationLength(motionTableId, stance, motion, speed=1)`** / the 2-motion overload that
|
|
||||||
also takes `currentMotion` — the latter, if `motion` has the Style bit set and `currentMotion
|
|
||||||
!= Ready`, first adds the Ready→currentMotion transition length, forces `currentMotion =
|
|
||||||
Ready`, THEN adds `currentMotion→motion`. Divides everything by `speed`.
|
|
||||||
- **`GetCycleLength`** — dat passthrough / speed.
|
|
||||||
- **`GetRunSpeed(motionTableID)`** — cached in static `RunSpeed` dict. Computes via
|
|
||||||
`GetMotionData(id, MotionCommand.RunForward)` → `GetAnimDist(motionData)`.
|
|
||||||
- **`GetTurnSpeed(motionTableID)`** — cached in static `TurnSpeed` dict.
|
|
||||||
`Math.Abs(GetMotionData(id, MotionCommand.TurnRight).Omega.Z)`.
|
|
||||||
- **`GetMotionData(motionTableID, motion, currentStyle=null)`** — resolves `currentStyle` to
|
|
||||||
`motionTable.DefaultStyle` if unspecified, strips command bits (`motion & 0xFFFFFF`), looks up
|
|
||||||
`Cycles[(style<<16)|motionID]`.
|
|
||||||
- **`GetLinkData(motionTableID, motion, currentStyle=null)`** — looks up
|
|
||||||
`Links[(style<<16)|((int)MotionCommand.Ready & 0xFFFF)]` (**NOTE: masks with `0xFFFF` here,
|
|
||||||
NOT `0xFFFFFF` like everywhere else in this file — inconsistent mask width, likely harmless
|
|
||||||
since `MotionCommand.Ready`'s low bits fit in 16 bits, but worth flagging as an ACE
|
|
||||||
inconsistency if porting verbatim**), then `.TryGetValue(motion, ...)`.
|
|
||||||
- **`GetAnimDist(motionData)`** — sums `frame.Origin` across every `PosFrames` frame of every
|
|
||||||
anim in `motionData.Anims` (reads each `Animation` fresh from dat by `anim.AnimId`), takes
|
|
||||||
`.Length()` of the summed offset vector, divides by `totalFrames`, multiplies by
|
|
||||||
`motionData.Anims[0].Framerate` → "distance per second". Returns 0 if the vector length is 0.
|
|
||||||
- **`HasDefaultScript(motionTableID, motion, currentStyle)`** — `GetLinkData` then scans every
|
|
||||||
anim's `PartFrames[*].Hooks` for `AnimationHookType.DefaultScript`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MotionTableManager.cs (`ACE.Server.Physics.Managers.MotionTableManager`) — owned by `PartArray`
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
```
|
|
||||||
PhysicsObj PhysicsObj
|
|
||||||
MotionTable Table
|
|
||||||
MotionState State
|
|
||||||
uint AnimationCounter
|
|
||||||
LinkedList<AnimNode> PendingAnimations
|
|
||||||
```
|
|
||||||
`AnimNode { uint Motion; uint NumAnims; }` (trivial struct-like class, `AnimNode.cs`).
|
|
||||||
|
|
||||||
### `AnimationDone(bool success)` — L28-61
|
|
||||||
Called from `PartArray.AnimationDone` ← `PhysicsObj.Hook_AnimDone()` (fired when a per-frame
|
|
||||||
animation HOOK signals completion — the frame-based `Hook_AnimDone` callback, NOT a polling
|
|
||||||
check). Logic:
|
|
||||||
- If `PendingAnimations.First == null` → no-op (nothing pending).
|
|
||||||
- `AnimationCounter++` (one hook fired, counts toward the FIRST queued `AnimNode`'s
|
|
||||||
`NumAnims` threshold).
|
|
||||||
- **Loop** while `node != null`:
|
|
||||||
- `entry = node.Value`. If `entry.NumAnims > AnimationCounter` → **break** (head entry hasn't
|
|
||||||
accumulated enough hook-fires yet — stop, this increment wasn't enough to finish it).
|
|
||||||
- If entry's motion has the Action bit set → `State.remove_action_head()` (pop the action
|
|
||||||
stack — the completed queue entry was an action, so remove its tracking entry from
|
|
||||||
`MotionState`).
|
|
||||||
- `motionID = entry.Motion`; `PhysicsObj.MotionDone(motionID, success)` — fires the
|
|
||||||
completion callback chain (→ `MovementManager.MotionDone` → `MotionInterpreter.MotionDone`,
|
|
||||||
see note at top: this is the OTHER pending-motion tracker, decoupled from this one except by
|
|
||||||
sharing the `PhysicsObj` "owner").
|
|
||||||
- `AnimationCounter -= entry.NumAnims` (consume the threshold — any EXCESS hooks beyond this
|
|
||||||
entry's requirement roll over to satisfy the NEXT queued entry, hence the outer `do...while`
|
|
||||||
loop can complete MULTIPLE queue entries from ONE `AnimationDone` call if `AnimationCounter`
|
|
||||||
still exceeds the next entry's `NumAnims`).
|
|
||||||
- If `PendingAnimations.First != null` → `RemoveFirst()` (dequeue the just-completed entry).
|
|
||||||
- If `PhysicsObj.WeenieObj != null` → `WeenieObj.OnMotionDone(motionID, success)` (separate
|
|
||||||
weenie-level hook, forwards to `WorldObject.HandleMotionDone` — the game-logic-facing
|
|
||||||
callback, distinct from the physics-level `PhysicsObj.MotionDone`).
|
|
||||||
- `node = PendingAnimations.First` (re-check for another completable entry).
|
|
||||||
- After the loop: if `AnimationCounter != 0 && node == null` (queue fully drained but counter
|
|
||||||
still has leftover) → **reset `AnimationCounter = 0`** (defensive clamp — discards any
|
|
||||||
leftover hook-credit once nothing remains queued, rather than carrying it forward to a future
|
|
||||||
`add_to_queue` call).
|
|
||||||
|
|
||||||
### `CheckForCompletedMotions()` — L63-85
|
|
||||||
Called from `PartArray.CheckForCompletedMotions` ← `PhysicsObj.CheckForCompletedMotions()` —
|
|
||||||
this one is POLLED (search found no evidence of a per-frame automatic caller within these two
|
|
||||||
files; likely ticked once per physics update from `PhysicsObj.UpdateObjectInternal` or similar,
|
|
||||||
not confirmed in this pass — flag for follow-up if the caller chain matters). Distinct from
|
|
||||||
`AnimationDone`: this drains any HEAD entries whose `NumAnims == 0` **already** (i.e. entries
|
|
||||||
that never needed any hook fires to complete — e.g. zero-length transitions), NOT
|
|
||||||
counter-driven:
|
|
||||||
- Loop while `PendingAnimations.First != null`:
|
|
||||||
- If `pendingAnimation.Value.NumAnims != 0` → **return** (head isn't a zero-length entry —
|
|
||||||
stop, do NOT touch `AnimationCounter`, this is purely for immediate 0-anim entries).
|
|
||||||
- Otherwise: pop the same way as `AnimationDone` (Action-bit → `RemoveActionHead`,
|
|
||||||
`PhysicsObj.MotionDone(motionID, true)` (always `success=true` here — no `success` param on
|
|
||||||
this method), remove from list, `WeenieObj.OnMotionDone(motionID, true)`.
|
|
||||||
- Continues looping (could drain several consecutive 0-`NumAnims` entries in one call).
|
|
||||||
|
|
||||||
### `PerformMovement(mvs, seq)` — L116-145
|
|
||||||
Dispatches on `mvs.Type` (`MovementStruct.Type`):
|
|
||||||
- `Table == null` → `WeenieError.NoAnimationTable`.
|
|
||||||
- **`InterpretedCommand`:** `Table.DoObjectMotion(mvs.Motion, State, seq, mvs.Params.Speed, ref
|
|
||||||
counter)`; if it returns `false` → `WeenieError.NoMtableData`. Else `add_to_queue(mvs.Motion,
|
|
||||||
counter, seq)`, return `None`.
|
|
||||||
- **`StopInterpretedCommand`:** `Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State,
|
|
||||||
seq, ref counter)`; failure → `NoMtableData`. Success → `add_to_queue((uint)MotionCommand.Ready,
|
|
||||||
counter, seq)` (**note: queues under `MotionCommand.Ready`, NOT `mvs.Motion`** — a stop always
|
|
||||||
enqueues completion-tracking keyed to Ready, regardless of what was stopped).
|
|
||||||
- **`StopCompletely`:** `Table.StopObjectCompletely(State, seq, ref counter)` (return value
|
|
||||||
ignored), `add_to_queue((uint)MotionCommand.Ready, counter, seq)`, return `None`
|
|
||||||
unconditionally.
|
|
||||||
- **default:** `WeenieError.None` (comment `// ??` — ACE itself flags this as uncertain; other
|
|
||||||
`MovementType` values like `RawCommand`/`StopRawCommand` fall through here untouched by this
|
|
||||||
manager, presumably handled instead by `MotionInterp.PerformMovement`).
|
|
||||||
|
|
||||||
### `SetMotionTableID(mtableID)` — `Table = MotionTable.Get(mtableID); return Table != null;`
|
|
||||||
|
|
||||||
### `UseTime()` — `CheckForCompletedMotions();` — the manager's per-tick entry point (called
|
|
||||||
from `PartArray.cs:265`, itself presumably ticked from `PhysicsObj`'s per-update pass — same
|
|
||||||
follow-up caveat as above).
|
|
||||||
|
|
||||||
### `add_to_queue(motion, num_anims, sequence)` — L163-167
|
|
||||||
`PendingAnimations.AddLast(new AnimNode(motion, num_anims))`, then immediately
|
|
||||||
`remove_redundant_links(sequence)` — every enqueue triggers a redundancy pass over the WHOLE
|
|
||||||
list (not just checking the new tail against its immediate predecessor blindly; see below).
|
|
||||||
|
|
||||||
### `initialize_state(sequence)` — L169-177
|
|
||||||
`numAnims = 0`; if `Table != null` → `Table.SetDefaultState(State, sequence, ref numAnims)`.
|
|
||||||
Always `add_to_queue((uint)MotionCommand.Ready, numAnims, sequence)` regardless of whether
|
|
||||||
`SetDefaultState` succeeded (numAnims stays 0 on failure, so this enqueues a same-tick-complete
|
|
||||||
Ready entry).
|
|
||||||
|
|
||||||
### `remove_redundant_links(sequence)` — L179-205
|
|
||||||
Walks `PendingAnimations` from **tail to head** (`node = PendingAnimations.Last`, then
|
|
||||||
`node.Previous`). For each entry with `NumAnims != 0`:
|
|
||||||
- If the entry's motion does NOT have the SubState bit set, OR it DOES have the Modifier bit set
|
|
||||||
(i.e. it's a Style/Action/Modifier-class entry, not a plain substate cycle): only proceed if
|
|
||||||
it also has the Style bit set (`entry.Motion & CommandMask.Style`), else **return** (stop
|
|
||||||
scanning entirely — non-style, non-substate-eligible entries block further redundancy
|
|
||||||
checking). If Style-bit set: call `remove_redundant_links_inner(node, sequence, first: true)`;
|
|
||||||
if it returns `true` → **return** (done, something was truncated).
|
|
||||||
- Else (a plain substate-cycle entry): `remove_redundant_links_inner(node, sequence, first:
|
|
||||||
false)`; if `true` → return.
|
|
||||||
- Otherwise continue to `node = node.Previous` (walk further back toward the head).
|
|
||||||
|
|
||||||
### `remove_redundant_links_inner(node, sequence, first)` — L207-231
|
|
||||||
Scans BACKWARD from `node.Previous` looking for an earlier entry with the SAME `Motion` value:
|
|
||||||
- `motion = first ? 0x70000000 : 0xB0000000` — these are raw `CommandMask`-shaped bit patterns
|
|
||||||
used as an early-abort guard (0x70000000 covers Style|SubState|Action bits per typical AC
|
|
||||||
command-mask layout; 0xB0000000 a different combination — exact bit semantics live in
|
|
||||||
`CommandMask` enum, not read this pass, but functionally: differing abort masks depending on
|
|
||||||
whether we're scanning for a Style-class or SubState-class duplicate).
|
|
||||||
- While walking back (`prev = prev.Previous` each iteration):
|
|
||||||
- If `prevEntry.Motion == entry.Motion` AND (`first` is true, OR `prevEntry.NumAnims != 0`) →
|
|
||||||
found a duplicate → `trancuate_animation_list(prev, sequence)`, return `true`.
|
|
||||||
- Else if `prevEntry.NumAnims != 0 && (prevEntry.Motion & motion) != 0` → an intervening entry
|
|
||||||
of the "abort mask" class with pending anims blocks the search → return `true` WITHOUT
|
|
||||||
truncating (stops the outer loop but did nothing — prevents redundancy removal across a
|
|
||||||
still-animating Style/Action boundary).
|
|
||||||
- Else continue.
|
|
||||||
- If the walk exhausts (`prev == null`) without matching → return `false` (caller's outer loop
|
|
||||||
continues scanning further back from the ORIGINAL node).
|
|
||||||
|
|
||||||
### `trancuate_animation_list(node, sequence)` — L233-249 (note: "trancuate" — misspelling of
|
|
||||||
"truncate" preserved verbatim from ACE source, matches the mismatched name used at the call
|
|
||||||
site `remove_redundant_links_inner`)
|
|
||||||
Walks from `PendingAnimations.Last` backward toward (but not including) `node`: sums every
|
|
||||||
visited entry's `NumAnims` into `totalAnims`, then **zeroes each entry's `NumAnims` in place**
|
|
||||||
(`entry.Value.NumAnims = 0` — does NOT remove them from the list, just neuters their
|
|
||||||
completion-tracking contribution). Finally `sequence.remove_link_animations(totalAnims)` — tells
|
|
||||||
the `Sequence` to physically drop that many trailing "link" animations from its playback queue
|
|
||||||
(the actual anim-clip splice), while `PendingAnimations` keeps the now-inert `AnimNode` entries
|
|
||||||
in place with `NumAnims=0` (they'll be silently skipped/instantly-completed by
|
|
||||||
`CheckForCompletedMotions`'s 0-check, or bypass `AnimationDone`'s counter entirely since their
|
|
||||||
threshold is unreachable-but-trivially-satisfied at 0... actually re-check: `AnimationDone`'s
|
|
||||||
break condition is `entry.NumAnims > AnimationCounter`; with `NumAnims == 0` this is never true,
|
|
||||||
so a zeroed entry always immediately qualifies for completion processing on the NEXT
|
|
||||||
`AnimationDone` call, consistent with `CheckForCompletedMotions` also draining 0-count heads).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MotionDone hook chain (full call graph, both trackers)
|
|
||||||
|
|
||||||
```
|
|
||||||
PhysicsObj.Hook_AnimDone() [per-frame anim-hook fire from Sequence/AFrame dispatch]
|
|
||||||
-> PartArray.AnimationDone(true)
|
|
||||||
-> MotionTableManager.AnimationDone(true) [drains PendingAnimations by counter]
|
|
||||||
-> PhysicsObj.MotionDone(motionID, success)
|
|
||||||
-> MovementManager.MotionDone(motion, success)
|
|
||||||
-> MotionInterpreter.MotionDone(success) [SEPARATE tracker: MotionInterp.PendingMotions]
|
|
||||||
-> PhysicsObj.WeenieObj.OnMotionDone(motionID, success)
|
|
||||||
-> WorldObject.HandleMotionDone(motionID, success) [game-logic level]
|
|
||||||
|
|
||||||
PhysicsObj.CheckForCompletedMotions() [polled, presumably per physics tick]
|
|
||||||
-> PartArray.CheckForCompletedMotions()
|
|
||||||
-> MotionTableManager.CheckForCompletedMotions() [drains 0-NumAnims heads only]
|
|
||||||
-> (same PhysicsObj.MotionDone / WeenieObj.OnMotionDone chain as above)
|
|
||||||
|
|
||||||
MotionInterp.MotionDone(success) [the OTHER path — NOT reached via MotionTableManager]
|
|
||||||
-> pops MotionInterp.PendingMotions.First
|
|
||||||
-> if Action bit set: PhysicsObj.unstick_from_object(), InterpretedState.RemoveAction(),
|
|
||||||
RawState.RemoveAction()
|
|
||||||
-> PhysicsObj.IsAnimating = PendingMotions.Count > 0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key divergence risk for acdream:** `MovementManager.MotionDone` ALWAYS routes to
|
|
||||||
`MotionInterpreter.MotionDone`, never to `MotionTableManager`. The two trackers are fed by
|
|
||||||
DIFFERENT owners (`PartArray.MotionTableManager` vs `MovementManager.MotionInterpreter`) and
|
|
||||||
`PhysicsObj.MotionDone(motion, success)` only calls the `MovementManager` one
|
|
||||||
(`PhysicsObj.cs:899-902`). `MotionTableManager.AnimationDone`/`CheckForCompletedMotions` DO call
|
|
||||||
`PhysicsObj.MotionDone` internally (feeding MotionInterp), but nothing in these two files calls
|
|
||||||
BACK from `MotionInterp` into `MotionTableManager` — i.e. `MotionTableManager` is upstream of
|
|
||||||
`MotionInterp` in the completion-notification chain, not a peer. When porting to acdream's
|
|
||||||
`CMotionInterp`-based unification (per the D6.2a work already landed), confirm which ACE class
|
|
||||||
maps to which acdream responsibility — likely `MotionTableManager` ≈ acdream's discrete-command
|
|
||||||
completion queue (attached per-PartArray/per-object), `MotionInterp` ≈ acdream's
|
|
||||||
`CMotionInterp`-normalized local-player path (attached per-MovementManager). Do not assume they
|
|
||||||
merge into one queue — ACE keeps them structurally separate even though both trace back to the
|
|
||||||
same `PhysicsObj`.
|
|
||||||
|
|
||||||
## Flagged ACE-side oddities (verify against 2013 retail decomp before porting verbatim)
|
|
||||||
|
|
||||||
1. **Action-branch numAnims miscalculation** (`GetObjectSequence` L227): fallback Action path
|
|
||||||
sums `motionData.Anims.Count` TWICE instead of once for `motionData` and once for
|
|
||||||
`motionData_` — looks like a copy-paste error in ACE's port, not necessarily retail-faithful.
|
|
||||||
2. **`change_cycle_speed` silent no-op gap** (L372-379): when `substateMod ≈ 0` AND `speedMod`
|
|
||||||
is nonzero, neither branch fires — no framerate adjustment applied. Check retail's
|
|
||||||
`change_cycle_speed` equivalent for a third branch.
|
|
||||||
3. **`GetLinkData` mask width inconsistency** (L562): uses `& 0xFFFF` where every other
|
|
||||||
motion-ID mask in this file uses `& 0xFFFFFF`. Likely harmless (Ready's ID fits in 16 bits)
|
|
||||||
but inconsistent.
|
|
||||||
4. **`StopObjectCompletely` return-value semantics** (L293-313): returns `true` unless the FINAL
|
|
||||||
substate-stop call fails, in which case it falls back to whatever `success` was from the
|
|
||||||
modifier-draining loop — easy to misport if simplified to a single boolean accumulate.
|
|
||||||
5. **`re_modify` double-removal from both `pstate` and a snapshot `state`** (L440-458): only
|
|
||||||
makes sense if `MotionState`'s copy constructor shares the underlying `LinkedList<Motion>`
|
|
||||||
nodes with the source, which would make the second `state.remove_modifier(...)` call either
|
|
||||||
redundant or (if lists are NOT shared) load-bearing to unlink from the snapshot's OWN list —
|
|
||||||
not verifiable without reading `MotionState.cs`'s copy ctor (out of scope this pass).
|
|
||||||
|
|
||||||
## Files NOT read this pass (would need separate grep if pursued)
|
|
||||||
- `MotionState.cs` (referenced constantly: `.Modifiers`, `.add_modifier`,
|
|
||||||
`.add_modifier_no_check`, `.remove_modifier`, `.clear_modifiers`, `.add_action`,
|
|
||||||
`.remove_action_head`, `.clear_actions`, copy constructor semantics)
|
|
||||||
- `MotionInterp.cs` full file (only L190-260 read; `PendingMotions`, `enter_default_state`,
|
|
||||||
`apply_current_movement`, `get_leave_ground_velocity` not traced)
|
|
||||||
- `Sequence.cs` (`SetVelocity`, `SetOmega`, `CombinePhysics`, `subtract_physics`,
|
|
||||||
`append_animation`, `clear_physics`, `remove_cyclic_anims`, `clear_animations`,
|
|
||||||
`remove_link_animations`, `multiply_cyclic_animation_framerate`, `HasAnims`,
|
|
||||||
`remove_all_link_animations`)
|
|
||||||
- `CommandMask` enum exact bit values (Style/SubState/Action/Modifier)
|
|
||||||
- `WeenieObject.HandleMotionDone` in `AC.Server` (game-logic side, outside Physics/)
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,299 +0,0 @@
|
||||||
# R2 port work-list — GetObjectSequence + MotionTableManager
|
|
||||||
|
|
||||||
Inputs: `r2-motiontable-decomp.md` (verbatim retail extraction, this scratchpad),
|
|
||||||
`r2-ace-motiontable.md` (ACE cross-ref, this scratchpad), plan of record
|
|
||||||
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R2), R1 gap map
|
|
||||||
`docs/research/2026-07-02-r1-csequence/r1-gap-map.md`, R1 core
|
|
||||||
`src/AcDream.Core/Physics/Motion/{CSequence,AnimSequenceNode,FrameOps}.cs`, adapter
|
|
||||||
`src/AcDream.Core/Physics/AnimationSequencer.cs`, S2b sink
|
|
||||||
`src/AcDream.App/Rendering/RemoteMotionSink.cs`.
|
|
||||||
|
|
||||||
**Precondition / state at R2 start:** R1 commits P0–P4 are landed (`1371c2a1`,
|
|
||||||
`778744bf`, `5138b8fb`, `658b91d8`); the P5 adapter cutover is IN the working tree
|
|
||||||
(AnimationSequencer already rehosted on `_core: CSequence`) but **uncommitted**, and
|
|
||||||
P6 (root-motion wiring + register sweep) is not started. **R2 depends on P5+P6 being
|
|
||||||
committed first.** Every CSequence primitive R2 needs already exists in the R1 core:
|
|
||||||
`AppendAnimation` (first_cyclic-slides, G10), `RemoveCyclicAnims`,
|
|
||||||
`RemoveLinkAnimations(count)`, `RemoveAllLinkAnimations`, `ClearPhysics`,
|
|
||||||
`Combine/SubtractPhysics`, `MultiplyCyclicAnimationFramerate` (framerates-only, G13),
|
|
||||||
`HasAnims`, and the `IAnimHookQueue.AddAnimDoneHook` seam (G5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Decomp ambiguities to pin BEFORE porting (the Q0 pseudocode commit)
|
|
||||||
|
|
||||||
| # | Ambiguity | Evidence each way | Pin method |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A1 | **`get_link` branch predicate is likely INVERTED in the BN extraction.** r2-motiontable-decomp §4 reads "both speed_mods negative → branch B (swap keys)". But under that reading the NORMAL forward case (both positive) takes branch A = outer hash keyed by **to**-substate, which contradicts (a) the dat Links layout the working adapter uses (outer key = `(style<<16)\|fromSubstate`, `AnimationSequencer.cs:989`), (b) ACE `MotionTable.cs:395-426` ("EITHER negative → reversed keys"), and (c) the call sites' arg roles (`get_link(style, currentSubstate, mod, targetSubstate, speed)`). If the predicate is "**either** negative → swapped-key branch", retail branch A == ACE's reverse branch and branch B == ACE's forward branch, and BOTH fallback blocks align exactly (either-neg fallback = style_defaults hop = ACE reverse fallback; both-pos fallback = `links[style<<16][to]` = ACE forward fallback). Same x87-flag-noise class as `same_sign` (§2) and the r1 gap-map hook-direction fix. | BN §4 vs ACE L395-426 + adapter GetLink (user-validated in the field: the reversed-key branch fixed the "left leg twitches" Ready→WalkBackward glitch) | Re-read the raw pseudo-C at @298552 for the flag test; if still ambiguous, cdb bp `acclient!CMotionTable::get_link` logging args + which hash bucket is probed during a Ready→WalkBackward start vs Ready→WalkForward start. Default resolution if cdb unavailable: ACE's reading (three independent corroborations). |
|
|
||||||
| A2 | **Branch-2 `signedSpeed` block** (no-double-hop leg): decomp cleans it as `(substate_mod==0 \|\| same_sign(substate_mod,arg5)) ? arg5 : -arg5` (§5 line ~550); ACE flips only one direction (`SubstateMod < 0 && speedMod > 0 → -speedMod`, MotionTable.cs:155-166). | BN x87 noise vs ACE asymmetry | Re-read raw @~0x00522bXX; conformance test both readings against a cdb golden of a Walk(-)→Walk(+) flip. |
|
|
||||||
| A3 | **outTicks field decode**: BN renders one term as `arg3->action_head` — actually MotionData's packed `num_anims` byte (decomp's own note, §5). All outTicks arithmetic = sum of each appended MotionData's `num_anims` (link chains) [+ base-cycle num_anims in Branch 1/2] − 1. | decomp note + ACE numAnims sums | Textual pin only; assert in tests: outTicks(walk→run direct) == link.num_anims + cycle.num_anims − 1. |
|
|
||||||
| A4 | **Adjudicate the 5 flagged ACE oddities** (r2-ace-motiontable "Flagged ACE-side oddities") against the decomp: (1) Action-branch numAnims double-count (ACE L227) — retail sums `outHop + actionLink [+ returnHop]` (never the base cycle, never one MotionData twice, §5 Branch 3) → **ACE bug, do not copy**; (2) `change_cycle_speed` silent no-op when old≈0 & new≠0 — retail §2 has the SAME structure → **port verbatim including the gap** (it's retail); (3) `GetLinkData` 0xFFFF mask — ACE-only helper, not ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : anyModifierStopOk` (§9) — port verbatim; (5) `re_modify` snapshot double-removal — retail copy-ctor deep-copies the chains, the snapshot exists ONLY as the loop-termination bound (§6) → C# port: deep-copy MotionState, pop both, terminate on snapshot empty. | — | Documented adjudications in the pseudocode doc. |
|
|
||||||
| A5 | **DatReaderWriter `MotionData.Bitfield` availability** — `is_allowed` needs bit1 ("substate-gated") and Branches 1/2/4 need bit0 ("clear modifiers on entry"). `Bitfield` string is present in the DLL (`chorizite.datreaderwriter/1.0.0`); confirm it's on `Types.MotionData` (not just GfxObj etc.) at Q2 time. | DLL strings grep | One-line test against the Humanoid table (a known bit-2-gated cycle, e.g. TurnRight). |
|
|
||||||
|
|
||||||
**Q0 cdb capture (one session serves all of R2):** bp
|
|
||||||
`CMotionTable::GetObjectSequence` / `get_link` / `StopSequenceMotion`,
|
|
||||||
`MotionTableManager::add_to_queue` / `remove_redundant_links` /
|
|
||||||
`truncate_animation_list` / `AnimationDone` / `CheckForCompletedMotions` with arg+ret
|
|
||||||
logging (pattern: `tools/cdb/l2g-observer.cdb`), user protocol: walk / run / shift-toggle
|
|
||||||
(fast path) / backward / turn-in-place / run-while-turning / sidestep / emote-while-running
|
|
||||||
/ attack / stop / stance change / rapid W-tapping (redundant-link collapse) / jump.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ITEMIZED GAPS — current vs retail (R2 scope)
|
|
||||||
|
|
||||||
Severity: **BLOCKER** = R2's conformance harness meaningless without it; **HIGH** =
|
|
||||||
visible animation wrongness / blocks R3; **MED** = edge-visible; **LOW** = textual.
|
|
||||||
|
|
||||||
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| H1 | **No `CMotionTable::GetObjectSequence` — motion selection is a partial adapter hybrid.** Retail (0x00522860, decomp §5) is one dispatcher with 4 class branches (style `(int)id<0` / cycle `0x40000000` / action `0x10000000` / modifier `0x20000000`), plain-OR fallthrough, `is_allowed` gating, style-default double-hop link routing, modifier replay (`re_modify`), and outTicks. acdream's `SetCycle` implements only a subset of Branch 2 and its inventions (below) stand in for the rest. | decomp §5 @298636 | `AnimationSequencer.SetCycle` (:321-636), `PlayAction` (:791-888) | **BLOCKER** |
|
|
||||||
| H2 | **No `MotionState`** — no style/substate/substate_mod struct, no modifier stack (push-front `add_modifier_no_check` / dup-guarded `add_modifier` / `remove_modifier(node,prev)` / `clear_modifiers`), no action FIFO (`add_action` tail-append / `remove_action_head`). The adapter's `CurrentStyle/CurrentMotion/CurrentSpeedMod` is a 3-field flat approximation; modifiers/actions have no bookkeeping at all. | acclient.h:31081; decomp §13/§14 (all 9 members, 0x00525fd0-0x00526340) | `AnimationSequencer.cs` CurrentStyle/CurrentMotion/CurrentSpeedMod props; `RemoteMotionSink.cs:39-45` (per-UM axis duplicates) | **BLOCKER** |
|
|
||||||
| H3 | **No `MotionTableManager`** — no `pending_animations` DLList, no `animation_counter`, no `add_to_queue` (0x0051bfe0), no `remove_redundant_links` (0x0051bf20, 0xb0000000/0x70000000 block masks), no `truncate_animation_list` (0x0051bca0, zero-in-place + `remove_link_animations(ticks)`), no `AnimationDone` (0x0051bce0, counter-driven countdown-chain multi-pop + action-head pop + MotionDone), no `CheckForCompletedMotions`/`UseTime` (0x0051be00/0x0051bfd0, zero-tick sweep, success=1 hardcoded), no `initialize_state` (0x0051c030, `0x41000003` sentinel), no `HandleEnterWorld/HandleExitWorld` drains, no `PerformMovement` (0x0051c0b0, error codes 7/0x43/0). | decomp §11 (all 16 members) | nothing — the AnimationDoneSentinel the R1 core queues (CSequence.cs:413-418 → AnimationSequencer.cs:925) is consumed by NOBODY (gap map API table: "RemoteMotionSink/GameWindow don't consume AnimDone yet") | **BLOCKER** |
|
|
||||||
| H4 | **RemoteMotionSink's single-cycle pick** — axis collection (`_substate/_sidestep/_turn`), priority pick (fwd > sidestep > turn), `Commit()` one-SetCycle resolution. Retail: each funnel dispatch is its own `DoObjectMotion` → GetObjectSequence; turn/sidestep-while-moving resolve via `is_allowed` rejection of the (bitfield&2-gated) turn cycle → Branch 4 modifier combine (physics-only overlay), run cycle keeps playing; turn-in-place resolves via Branch 2 (cycle exists, allowed from the style default). Register row **AP-73** (retail-divergence-register.md:181). | decomp §3 (is_allowed) + §5 Branch 2/4 | `RemoteMotionSink.cs:141-216` (`Commit`), `:55-119` (`ApplyMotion` classify/collect) | **HIGH** — plan of record: "single-cycle pick DELETED — GetObjectSequence decides" |
|
|
||||||
| H5 | **HasCycle probe + Run→Walk→Ready missing-cycle fallback chain.** Existed because SetCycle clears the cyclic tail BEFORE knowing the cycle resolves ("torso on the ground"). Retail never has the problem: GetObjectSequence resolves ALL MotionData first, `clear_physics`/`remove_cyclic_anims` happen only inside a success path, missing cycles retry under `default_style` (Branch 2 second lookup) and otherwise `return 0` leaving the sequence untouched (PerformMovement → 0x43). | decomp §5 Branch 2 lines ~494-505 + return-0 tail | `RemoteMotionSink.cs:169-204`, `AnimationSequencer.HasCycle` (:281-295), GameWindow spawn fallbacks :3723-3825 | **HIGH** |
|
|
||||||
| H6 | **Fix B locomotion link-skip** (cyclic→cyclic transitions call `RemoveAllLinkAnimations` for the locomotion low-byte subset). Retail mechanism = `remove_redundant_links` on the PENDING QUEUE (tail-anchored backward scan for an earlier same-motion node; collapse via `truncate_animation_list` → zero ticks + `remove_link_animations(removedTicks)`; blocked by intervening non-zero 0xb0000000-class (cycle-tail scan) / 0x70000000-class (style-tail scan) nodes). The 2026-05-03 cdb trace in the Fix B comment block is the golden: cyclic→cyclic = `add_to_queue(45000005)` + `add_to_queue(44000007)`, truncate NOT firing — Fix B's outcome falls out of retail's structure (the link nodes the queue would truncate never get double-enqueued once GetObjectSequence owns link selection). | decomp §11 remove_redundant_links @290771 + truncate @290533 | `AnimationSequencer.cs:468-514` (Fix B block + `IsLocomotionCycleLowByte`) | **HIGH** — delete in favor of the queue mechanism |
|
|
||||||
| H7 | **Stop-anim fallback** (SetCycle low-byte remap WalkBackward→WalkForward etc. when linkData null). Retail: `adjust_motion` normalization happens UPSTREAM in CMotionInterp (already ported, D6.2a `0f099bb6`) so GetObjectSequence receives 0x05/0x0D/0x0F + signed speed; direction flips route via Branch 2's `link==null \|\| !same_sign` style-default double-hop (`get_link(...,styleDefault,1f)` + `get_link(styleDefault,1f,target,speed)`). | decomp §5 Branch 2 lines ~533-540 | `AnimationSequencer.cs:402-423` | **HIGH** |
|
|
||||||
| H8 | **Adapter fast path ≠ retail fast path.** Retail (§5 Branch 2): gate = `target==substate && same_sign(newSpeed, substate_mod) && has_anims()`, then `change_cycle_speed` (ratio, 0.0002f epsilon guards) + `subtract_motion(old)` + `combine_motion(new)` + commit substate_mod. acdream: gate keyed on Current* fields with its own 1e-4/1e-6 epsilons, and `MultiplyCyclicFramerate` folds a velocity/omega rescale composite in (the G13 stand-in, `AnimationSequencer.cs:673-686`). | decomp §5 lines ~513-522, §2 change_cycle_speed @298276 | `AnimationSequencer.cs:345-388`, `:673-686` | **HIGH** — G13/G17 retire here |
|
|
||||||
| H9 | **`add_motion` velocity gate (G17 adapter half).** Core `EnqueueMotionData` still gates `SetVelocity/SetOmega` on `MotionDataFlags.HasVelocity/HasOmega`; retail `add_motion` (0x005224b0) sets unconditionally (dat-silent MotionData carries zero → replace-with-zero), safe once modifiers route through `combine_motion` (Branch 4) instead of `add_motion`. | decomp §2 @298437 | `AnimationSequencer.cs:1044-1058` (documented "R2 stand-in") + `PlayAction:836-839,872-875` | MED — mechanical once Branch 4 exists |
|
|
||||||
| H10 | **PlayAction inventions**: (a) actions resolved via `GetLink` direct only — no default-substate out-and-back 4-layer chain (outHop@1.0 → actionLink@speed → returnHop@1.0 → base cycle@old substate_mod); (b) no `add_action` FIFO bookkeeping (so no completion pop, H3); (c) no `clear_physics`/`remove_cyclic_anims` rebuild — nodes are INSERTED before the cyclic tail + an invented cursor-jump (`AnimationSequencer.cs:877-887`); retail REBUILDS (base cycle restarts after the action); (d) modifier-class ids ENQUEUE ANIMS from the Modifiers dict — retail Branch 4 is **physics-only** (`combine_motion` velocity/omega; anims untouched) + `add_modifier` bookkeeping with the stop-then-re-add toggle. | decomp §5 Branch 3 (~591-647) / Branch 4 (~652-687) | `AnimationSequencer.PlayAction` :791-888 | **HIGH** |
|
|
||||||
| H11 | **No stop machinery**: `StopSequenceMotion` (0x00522fc0: cycle-stop = re-drive GetObjectSequence toward the style default with arg7=1; modifier-stop = `subtract_motion` + unlink), `StopObjectCompletely` (strip all modifiers then stop the substate), `SetDefaultState` (0x005230a0: full baseline reset, `clear_animations` not remove_cyclic). "Stop" today is whoever calls SetCycle(Ready 0x41000003). | decomp §7/§8/§9 | funnel `StopMotion` → `RemoteMotionSink.cs:121-131` (ObservedOmega zero only); GameWindow SetCycle(Ready) sites | **BLOCKER** for stop conformance |
|
|
||||||
| H12 | **The GetObjectSequence guard set**: entry guards (`style==0 \|\| substate==0` → 0), the modifier-class no-op fast path (`target==styleDefault && !stopCall && (substate & 0x20000000)` → 1), Branch 2's "leaving a modifier-class substate re-registers it as a modifier" (`add_modifier_no_check`) — none exist anywhere in acdream. | decomp §5 lines ~384-404, ~565-572 | — | HIGH (part of H1, called out because each is an easy silent omission) |
|
|
||||||
| H13 | **`re_modify` modifier replay across transitions** — every substate/style-changing branch replays the active modifier stack through recursive GetObjectSequence calls. This is DEV-9 / AP-73's "retail BLENDS modifiers over the substate cycle". | decomp §6 @298300 | — | **HIGH** — the plan of record's "(modifier blend — retires AP-73)" |
|
|
||||||
| H14 | **Style-change transitions absent** (Branch 1): stance switches never play the exit-link + style-to-style link + double-hop-via-default_style chain; RemoteMotionSink just stores `_style` and SetCycle keys the cycle dict with it. | decomp §5 Branch 1 (~411-487) | `RemoteMotionSink.cs:57-61`; `SetCycle` (style used only as key material) | MED-HIGH (visible on combat-stance changes) |
|
|
||||||
| H15 | **Spawn baseline**: retail `initialize_state` → `SetDefaultState` + queue `0x41000003` sentinel; enter/exit-world drain the queue (`AnimationDone(0)` loop; enter also `remove_all_link_animations`). acdream: RenderBootstrap 3-tier fallback + GameWindow SetCycle(Ready)/HasCycle chains. | decomp §11 initialize_state/HandleEnterWorld/HandleExitWorld | GameWindow :3723-3825; `RenderBootstrap.SequencerFactory` | MED |
|
|
||||||
| H16 | **MotionDone signal dead-ends.** Retail chain: CSequence AnimDone gate → AnimDoneHook singleton → `Hook_AnimDone` → `CPartArray::AnimationDone(1)` → `MotionTableManager::AnimationDone` → countdown pop → `CPhysicsObj::MotionDone(motion, success)` → (R3) CMotionInterp pending_motions. acdream stops at `AnimationDoneSentinel` in `_pendingHooks`; nothing counts it. | decomp §11 AnimationDone @290558 + gap map G5 | `AnimationSequencer.cs:918-931`; GameWindow :9882 drain ignores the sentinel type | **HIGH** — R2's named deliverable; see §4 below |
|
|
||||||
| H17 | **ObservedOmega side-write** — the sink seeds `RemoteMotion.ObservedOmega` from wire turns (`RemoteMotionSink.cs:95-101`) so remote rotation starts same-tick. Retail: turn omega enters the sequence via Branch 2 add_motion (turn cycle omega + adapter synthesis) or Branch 4 combine_motion, and body rotation comes from `CSequence.omega` through apply_physics per tick (R6 tick-order territory). | decomp §2 combine_motion; plan R6 | `RemoteMotionSink.cs:95-101`, `:127` | MED — carry the side-write into the replacement sink verbatim (register row), retire in R6 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. KEEP LIST — already matching retail (do not re-port)
|
|
||||||
|
|
||||||
| Behavior | Retail anchor | acdream anchor |
|
|
||||||
|---|---|---|
|
|
||||||
| `get_link` two-branch sign-aware lookup + both fallbacks (pending A1 pin, which almost certainly CONFIRMS it) | 0x00522710 §4 (predicate per A1) | `AnimationSequencer.GetLink` :961-1005 — re-home into CMotionTable verbatim, do not rewrite |
|
|
||||||
| Cycle/link/modifier hash keying incl. 32-bit `<<16` truncation of full command words (`(0x8000003D<<16)==0x003D0000`) | §5 key math throughout | `SetCycle`/`HasCycle`/`PlayAction` key builds (`:426,293,824`) — carry the full-command-word convention into CMotionTable |
|
|
||||||
| `AnimData` speed scaling: framerate only (`AnimData::operator*`) | 0x00525d00 (r1 §25) | `BuildNode` :1014-1024 / `EnqueueMotionData` append path |
|
|
||||||
| `append_animation` first_cyclic-slides-to-tail + curr_anim seed (the structural base every add_motion depends on) | 0x00525510 (r1 §24) | `CSequence.AppendAnimation` (Motion/CSequence.cs:109-123) |
|
|
||||||
| Full remove-family + apricot + combine/subtract_physics + multiply_cyclic_animation_fr (framerates-only) | r1 §5-§14 | `CSequence` :163-253 — R2 calls these, zero changes |
|
|
||||||
| AnimDone LIST-STRUCTURE gate (head != first_cyclic) + IAnimHookQueue seam | 0x00525943 (r1 G5) | `CSequence.UpdateInternal` :407-422 — R2 only adds the CONSUMER |
|
|
||||||
| adjust_motion normalization upstream of dispatch (left→right / backward→forward + sign) | CMotionInterp (D6.2a port) | `MotionInterpreter` normalization (commit `0f099bb6`) — stays upstream; Q4 verifies single-site and deletes the SetCycle-head duplicate |
|
|
||||||
| Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` / `DispatchInterpretedMotion` / `contact_allows_move` + 183-case conformance suite | S2a (7b0cbbda) | `MotionInterpreter.cs:1312-1420` — R2 sits BELOW it (replaces only the sink) |
|
|
||||||
| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), outbound packers | plan "absorbed" list | untouched |
|
|
||||||
| K-fix18 `skipTransitionLink` instant-engage (jump/Falling) | none — invented; retires in **R3** jump family | `AnimationSequencer.cs:321,398,441-444`; GameWindow :4817-4831, :10224 — SURVIVES R2 at adapter, byte-identical, register row kept |
|
|
||||||
| Locomotion velocity synthesis (Walk 3.12 / Run 4.0 / Side 1.25 m·s⁻¹) + turn omega synthesis (π/2 rad·s⁻¹) | retail `get_state_velocity` (R3 scope) | `SetCycle` :539-635 — SURVIVES R2 (runs after PerformMovement), register rows kept |
|
|
||||||
| Retail slerp + BuildBlendedFrame render blend | FUN_005360d0 | `SlerpRetailClient` / `BuildBlendedFrame` — untouched |
|
|
||||||
| `0x41000003` == full-word MotionCommand.Ready == retail's stop/default sentinel | decomp §15 | adapter already uses 0x41000003 as the Ready id |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
|
|
||||||
|
|
||||||
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Tests:
|
|
||||||
`tests/AcDream.Core.Tests/Physics/Motion/` (pattern: R1's
|
|
||||||
`AnimSequenceNodeTests`/`CSequenceTests`). Every commit: build+test green, register
|
|
||||||
rows added/retired in-commit.
|
|
||||||
|
|
||||||
**Q0 — pseudocode + ambiguity pinning (docs only).**
|
|
||||||
`docs/research/2026-07-0x-motiontable-pseudocode.md` from r2-motiontable-decomp.md,
|
|
||||||
resolving A1–A5 (§0 above) and adjudicating the 5 ACE oddities (A4). Run the ONE cdb
|
|
||||||
capture session (protocol in §0) — it feeds Q2/Q3/Q4 goldens.
|
|
||||||
Fixture source: **cdb** (live retail, l2g-observer.cdb pattern).
|
|
||||||
Deps: none (R1-P5/P6 committed is a precondition for Q4+, not Q0).
|
|
||||||
|
|
||||||
**Q1 — `MotionState` verbatim.** (closes H2)
|
|
||||||
`Motion/MotionState.cs`: `Style/Substate/SubstateMod(=1f)` + modifier STACK
|
|
||||||
(`AddModifierNoCheck` push-front 0x00525ff0; `AddModifier` dup-guard + `substate==id`
|
|
||||||
refuse 0x00526340; `RemoveModifier(node, prev)` 0x00526040; `ClearModifiers`
|
|
||||||
0x00526070) + action FIFO (`AddAction` tail-append 0x005260a0; `RemoveActionHead`
|
|
||||||
0x00526120; `ClearActions` 0x005260f0) + deep-copy ctor (A4-#5: chains copied, not
|
|
||||||
shared — re_modify's snapshot is a termination bound).
|
|
||||||
Tests first: stack-vs-FIFO discipline tables; AddModifier rejection (already-present /
|
|
||||||
equals-substate); copy independence (mutate original, snapshot unchanged).
|
|
||||||
Fixture source: **synthetic**.
|
|
||||||
Deps: Q0.
|
|
||||||
|
|
||||||
**Q2 — `CMotionTable` verbatim (pure selection logic, no queue).** (closes H1, H5-resolve-side, H7-routing, H8-core, H10-core, H11, H12, H13, H14; A1/A2/A5 land here)
|
|
||||||
`Motion/CMotionTable.cs` wrapping the DatReaderWriter `MotionTable` DBObj
|
|
||||||
(style_defaults/cycles/modifiers/links/default_style; keys = `(style<<16)|(id&0xFFFFFF)`
|
|
||||||
full-command-word convention). Free functions in the same file (retail free fns):
|
|
||||||
`add_motion` 0x005224b0 (**unconditional** SetVelocity/SetOmega — G17 core — +
|
|
||||||
AppendAnimation per speed-scaled AnimData), `combine_motion` 0x00522580 /
|
|
||||||
`subtract_motion` 0x00522600 (CombinePhysics/SubtractPhysics only — never anims),
|
|
||||||
`change_cycle_speed` 0x00522290 (0.0002f epsilons, verbatim incl. the A4-#2 gap),
|
|
||||||
`same_sign` 0x00522260. Members: `get_link` 0x00522710 (per A1 pin — expected: the
|
|
||||||
adapter's existing port re-homed), `is_allowed` 0x005226c0 (`Bitfield & 2` gate),
|
|
||||||
`GetObjectSequence` 0x00522860 (ALL of: entry guards; modifier-class no-op fast path;
|
|
||||||
Branch 1 style-change with exit-link + direct link + default_style double-hop + commit
|
|
||||||
+ re_modify + outTicks; Branch 2 with default_style cycle retry, is_allowed, re-speed
|
|
||||||
fast path (change_cycle_speed + subtract + combine), clear-modifiers bit0, direct-link
|
|
||||||
vs `!same_sign` double-hop, A2 signedSpeed, outgoing-modifier-substate re-registration,
|
|
||||||
re_modify, outTicks; Branch 3 action direct + 4-layer out-and-back, add_action,
|
|
||||||
outTicks; Branch 4 modifier physics-only combine + stop-then-re-add toggle),
|
|
||||||
`re_modify` 0x005222e0, `StopSequenceMotion` 0x00522fc0, `SetDefaultState` 0x005230a0
|
|
||||||
(`clear_animations` hard reset), `DoObjectMotion`/`StopObjectMotion`/
|
|
||||||
`StopObjectCompletely` 0x00523e90/ec0/ed0 (A4-#4 return semantics).
|
|
||||||
Tests first — the R2 conformance harness core:
|
|
||||||
(a) **dat fixtures** (Humanoid MotionTable via DatCollection, R1-P1 pattern):
|
|
||||||
Ready→Walk link+cycle chain shape; walk↔run re-speed fast path (framerates rescaled,
|
|
||||||
velocity = subtract-old+combine-new, NO list change); Walk→WalkBackward-normalized
|
|
||||||
(0x05, −speed) sign-flip → style-default double-hop; stance change → Branch 1 chain;
|
|
||||||
emote-while-running → 4-layer out-and-back with base cycle re-added at OLD
|
|
||||||
substate_mod; turn-in-place → Branch 2 cycle; **run-while-turning → is_allowed rejects
|
|
||||||
the gated turn cycle → Branch 4 physics-only combine, run anims untouched** (the AP-73
|
|
||||||
mechanism test); modifier stop = subtract + unlink; StopObjectCompletely drains
|
|
||||||
modifiers then re-drives to style default; missing cycle → return 0, sequence
|
|
||||||
UNTOUCHED (H5); outTicks values per A3.
|
|
||||||
(b) **cdb goldens from Q0**: GetObjectSequence arg/ret + resolved-MotionData-key
|
|
||||||
conformance for the captured protocol.
|
|
||||||
Deps: Q1 (MotionState), R1 core.
|
|
||||||
|
|
||||||
**Q3 — `MotionTableManager` + pending_animations.** (closes H3, H15-core)
|
|
||||||
`Motion/MotionTableManager.cs` + `AnimNode {Motion, NumAnims}`. Fields per `Create`
|
|
||||||
0x0051bc50: table, state, animation_counter, pending queue (LinkedList<AnimNode>;
|
|
||||||
register row: managed list vs intrusive DLList — reuse the R1 AD-34 wording), plus an
|
|
||||||
`IMotionDoneSink` seam (stands in for `CPhysicsObj::MotionDone`; see §4). Methods:
|
|
||||||
`add_to_queue` 0x0051bfe0 (append + immediate `remove_redundant_links`),
|
|
||||||
`remove_redundant_links` 0x0051bf20 — **port retail's tail-anchored single scan**
|
|
||||||
(skip trailing zero-tick nodes; cycle-class-not-modifier tail: match earlier same-motion
|
|
||||||
non-zero node, blocked by intervening non-zero `0xb0000000`-class; style-class
|
|
||||||
(`(int)motion<0`) tail: exact match, blocked by non-zero `0x70000000`-class), NOT
|
|
||||||
ACE's restructured outer loop; `truncate_animation_list` 0x0051bca0 (zero `NumAnims`
|
|
||||||
in place — nodes stay queued — + `CSequence.RemoveLinkAnimations(removedTicks)`);
|
|
||||||
`AnimationDone(success)` 0x0051bce0 (counter += 1; pop every head with
|
|
||||||
`NumAnims <= counter`, action-class → `state.RemoveActionHead()`, fire
|
|
||||||
`sink.MotionDone(motion, success)`, counter −= NumAnims; drained-list counter reset);
|
|
||||||
`CheckForCompletedMotions` 0x0051be00 (zero-tick heads only, success=1, no counter
|
|
||||||
touch) + `UseTime` alias; `initialize_state` 0x0051c030 (SetDefaultState + queue
|
|
||||||
`0x41000003`/outTicks + redundancy pass); `HandleEnterWorld` (remove_all_link_animations
|
|
||||||
+ drain via AnimationDone(0)) / `HandleExitWorld` (drain only); `PerformMovement`
|
|
||||||
0x0051c0b0 (InterpretedCommand → DoObjectMotion → add_to_queue(motion, outTicks);
|
|
||||||
StopInterpretedCommand → StopObjectMotion → add_to_queue(**0x41000003**, outTicks) on
|
|
||||||
success; StopCompletely → StopObjectCompletely + **unconditional**
|
|
||||||
add_to_queue(0x41000003); error codes 7 / 0x43 / 0; other MovementTypes untouched).
|
|
||||||
Tests first: countdown-chain tables (one AnimationDone completing MULTIPLE entries via
|
|
||||||
counter rollover; leftover-counter reset on drain); truncate blocked/allowed matrices
|
|
||||||
for both masks; zero-tick sweep vs counter sweep distinction; enter/exit-world drains
|
|
||||||
fire MotionDone(success=0) for every queued motion; rapid same-motion re-issue →
|
|
||||||
collapse (the Fix B replacement proof); the Q0 golden: cyclic→cyclic walk→run yields
|
|
||||||
`add_to_queue(0x45000005)` + `add_to_queue(0x44000007)` with truncate NOT firing
|
|
||||||
(2026-05-03 trace, quoted in the Fix B comment block).
|
|
||||||
Fixture source: **synthetic** + **cdb goldens from Q0**.
|
|
||||||
Deps: Q2.
|
|
||||||
|
|
||||||
**Q4 — adapter cutover: SetCycle/PlayAction rehosted on PerformMovement; DELETE Fix B + stop-anim fallback + fast-path composite + G17 gate; wire the queue drain.** (closes H6, H7, H8, H9, H10-adapter, H16-wiring; H12 guards live via Q2)
|
|
||||||
`AnimationSequencer` gains a `MotionTableManager` (constructed with the same
|
|
||||||
DatReaderWriter table + the CSequence core). `SetCycle(style, motion, speedMod,
|
|
||||||
skipTransitionLink)` becomes: dispatch style-class id then motion through
|
|
||||||
`PerformMovement(InterpretedCommand)` (MotionState now OWNS style/substate/
|
|
||||||
substate_mod; `CurrentStyle/CurrentMotion/CurrentSpeedMod` become read-only mirrors —
|
|
||||||
GameWindow :3723/4827/4915/4919 + sink ctor keep compiling). `PlayAction` → the same
|
|
||||||
dispatch (action/modifier ids hit Branch 3/4). K-fix18 preserved byte-identical at
|
|
||||||
adapter: `skipTransitionLink` → post-dispatch `ClearAnimations`-of-links exactly as
|
|
||||||
today (register row survives → R3). Velocity/omega synthesis blocks run AFTER dispatch,
|
|
||||||
unchanged (→ R3). **DELETE:** the SetCycle-head adjust_motion duplicate (verify every
|
|
||||||
caller pre-normalizes via MotionInterpreter; if any GameWindow raw call site doesn't,
|
|
||||||
normalize at the adapter boundary ONCE and note it), the adapter fast-path block
|
|
||||||
(:345-388), the stop-anim low-byte fallback (:402-423), Fix B +
|
|
||||||
`IsLocomotionCycleLowByte` (:468-514), `MultiplyCyclicFramerate`'s velocity-rescale
|
|
||||||
composite (:681-686 — change_cycle_speed+subtract/combine are now real; G13 row
|
|
||||||
retired), `EnqueueMotionData`'s HasVelocity/HasOmega gate (:1055-1058 — add_motion
|
|
||||||
unconditional; G17 row retired), PlayAction's insert-before-tail + cursor-jump +
|
|
||||||
modifier-anim-enqueue (:836-887). **Queue drain wiring (same commit — the queue must
|
|
||||||
not grow unbounded):** GameWindow anim tick (:9876-9890) counts drained
|
|
||||||
`AnimationDoneSentinel` instances → `manager.AnimationDone(true)` per sentinel, and
|
|
||||||
calls `manager.UseTime()` once per tick (zero-tick completions: stop-with-no-link,
|
|
||||||
fast-path re-speed outTicks=0). `IMotionDoneSink` bound to a diagnostic recorder
|
|
||||||
(ACDREAM_DUMP_MOTION line) — consumed for real in R3 (register row: MotionDone
|
|
||||||
observed-not-consumed, retire R3).
|
|
||||||
Tests first: FULL existing suite green (parity bar) + pre-cutover recorded
|
|
||||||
SetCycle-sequence traces (captured BEFORE this commit) replayed → same selected
|
|
||||||
cycle/link identities + same hook stream, with EXPECTED-DIFF annotations documented
|
|
||||||
per case (known intentional changes: action overlays now rebuild → base cycle restarts;
|
|
||||||
direction flips route the double-hop) + #61 boundary-flash re-check under the new link
|
|
||||||
path. Empty-MotionTable tier: PerformMovement returns 7, sequencer stays do-nothing
|
|
||||||
(RenderBootstrap invariant, r1 API table).
|
|
||||||
Fixture source: **pre-cutover recorded adapter traces (synthetic harness)** + suite.
|
|
||||||
Deps: Q2+Q3 (+ R1-P5/P6 committed).
|
|
||||||
|
|
||||||
**Q5 — RemoteMotionSink DELETED; funnel dispatches straight into PerformMovement; spawn/world lifecycle; AP-73 retired.** (closes H4, H5-callers, H11-callers, H15, H17-carry)
|
|
||||||
Replace `RemoteMotionSink` with a thin Core `IInterpretedMotionSink` implementation
|
|
||||||
(`Motion/MotionTableDispatchSink.cs` or direct on the entity's manager):
|
|
||||||
`ApplyMotion(motion, speed)` → `PerformMovement(InterpretedCommand{motion, speed})`;
|
|
||||||
`StopMotion(motion)` → `PerformMovement(StopInterpretedCommand{motion, 1f})`. No axis
|
|
||||||
collection, no priority pick, no Commit, no HasCycle probe, no Run→Walk→Ready chain —
|
|
||||||
GetObjectSequence + is_allowed decide (H4/H5). The ObservedOmega turn seed (H17) moves
|
|
||||||
verbatim into the new sink (register row updated: retire in R6 when apply_physics
|
|
||||||
drives remote rotation). GameWindow: `RemoteMotionSink` ctor sites (:4643-4646) swap
|
|
||||||
to the new sink; spawn/fallback SetCycle(Ready)/HasCycle chains (:3723-3825) →
|
|
||||||
`manager.initialize_state`; teleport/despawn/enter-world → HandleEnterWorld/
|
|
||||||
HandleExitWorld. Delete `AnimationSequencer.HasCycle` if caller-free after this (else
|
|
||||||
keep as diagnostic, note it).
|
|
||||||
Tests first: S2a 183-case funnel conformance suite green with sink assertions
|
|
||||||
re-targeted (dispatch order is funnel-owned and unchanged; assertions move from
|
|
||||||
"collected axes" to "PerformMovement call sequence"); MotionInterpreterFunnelTests
|
|
||||||
green; live-protocol smoke via ACDREAM_DUMP_MOTION + ACDREAM_REMOTE_VEL_DIAG
|
|
||||||
(walk/run/toggle/backward/turn/circle/sidestep/emote/attack/stop, player+NPC+monster);
|
|
||||||
**ONE user visual pass** (walk↔run stride continuity, turn-while-running legs, emote
|
|
||||||
overlay, stop settle — the stage acceptance).
|
|
||||||
Registers: **AP-73 DELETED in this commit**; rows added for ObservedOmega side-write
|
|
||||||
(if not already covered) and any adapter boundary normalization from Q4.
|
|
||||||
Deps: Q4.
|
|
||||||
|
|
||||||
**Q6 — register sweep + roadmap + digest (docs/cleanup only).**
|
|
||||||
Grep-sweep dead code (IsLocomotionCycleLowByte remnants, HasCycle callers, fallback
|
|
||||||
chains, SCFAST/SCFULL diag re-anchoring); reconcile every touched register row
|
|
||||||
(retired: AP-73, Fix B, stop-anim fallback, G13, G17; surviving with rows: K-fix18→R3,
|
|
||||||
velocity/omega synthesis→R3, ObservedOmega seed→R6, MotionDone-unconsumed→R3,
|
|
||||||
managed-LinkedList-vs-DLList); roadmap stage table (R2 shipped); memory digest note
|
|
||||||
(animation sequencer deep-dive cross-link: pending_animations gap CLOSED).
|
|
||||||
Deps: Q5.
|
|
||||||
|
|
||||||
Parallelization note: Q1 and the Q0 cdb capture are independent; Q2/Q3 are
|
|
||||||
sequential-coupled (Q3 calls Q2); Q4/Q5 each touch GameWindow — do NOT fan out
|
|
||||||
(feedback_dont_parallelize_coupled_plan_slices).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. MotionDone → S2-funnel pending_motions (the R3 boundary contract)
|
|
||||||
|
|
||||||
What R2 ships and where R3 plugs in:
|
|
||||||
|
|
||||||
```
|
|
||||||
CSequence.UpdateInternal [R1, shipped]
|
|
||||||
└─ G5 gate (head != first_cyclic) → IAnimHookQueue.AddAnimDoneHook
|
|
||||||
└─ AdapterHookQueue → AnimationDoneSentinel into _pendingHooks [R1-P5]
|
|
||||||
GameWindow anim tick (:9876-9890) [R2-Q4 wiring]
|
|
||||||
├─ per drained AnimationDoneSentinel → manager.AnimationDone(success: true)
|
|
||||||
│ [retail: AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone 0x0050fda0
|
|
||||||
│ → CPartArray::AnimationDone(1) — one call per queued hook]
|
|
||||||
└─ once per tick → manager.UseTime() ≡ CheckForCompletedMotions()
|
|
||||||
[retail call sites 0x00517d57/0x00517d67; drains zero-tick entries:
|
|
||||||
stops-without-links, fast-path re-speeds (outTicks=0), truncated nodes]
|
|
||||||
MotionTableManager.AnimationDone / CheckForCompletedMotions [R2-Q3]
|
|
||||||
├─ action-class head (0x10000000) → MotionState.RemoveActionHead() [R2-OWNED —
|
|
||||||
│ the action FIFO pop lives HERE, not in R3]
|
|
||||||
└─ IMotionDoneSink.MotionDone(uint motion, bool success) [R2 seam]
|
|
||||||
R2: diagnostic recorder only (register row: unconsumed until R3)
|
|
||||||
R3: MotionInterpreter.MotionDone — pops CMotionInterp.pending_motions,
|
|
||||||
action-class → RemoveAction from raw+interpreted state, recomputes
|
|
||||||
IsAnimating (ACE MotionInterp.cs:210-231; MovementManager.MotionDone
|
|
||||||
relay). The funnel's own note at MotionInterpreter.cs:1395 marks the
|
|
||||||
attachment point.
|
|
||||||
```
|
|
||||||
|
|
||||||
Two structural facts R3 must respect (from r2-ace-motiontable's headline finding,
|
|
||||||
decomp-confirmed): **MotionTableManager is UPSTREAM of CMotionInterp in the
|
|
||||||
completion chain, never a peer** — retail keeps TWO pending trackers
|
|
||||||
(`MotionTableManager.pending_animations` under CPartArray vs
|
|
||||||
`CMotionInterp.pending_motions` under MovementManager) and `CPhysicsObj::MotionDone`
|
|
||||||
feeds only the interp side; do not merge the queues. And the per-tick PLACEMENT of
|
|
||||||
both the sentinel drain and UseTime is provisional until **R6** installs retail's
|
|
||||||
`UpdateObjectInternal` order (process_hooks LAST; MovementManager.UseTime/
|
|
||||||
CPartArray.HandleMovement mid-tick) — R2 documents the current GameWindow drain point
|
|
||||||
as the G6 seam, unchanged.
|
|
||||||
|
|
||||||
Success-flag semantics to preserve: AnimationDone passes the CALLER's flag (true from
|
|
||||||
Hook_AnimDone; false from enter/exit-world drains); CheckForCompletedMotions hardcodes
|
|
||||||
true. R3's jump/HitGround logic keys off this flag — getting it wrong is invisible
|
|
||||||
until R3.
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
# R3-W0 — ambiguity pins and ACE-oddity adjudications
|
|
||||||
|
|
||||||
The verbatim extraction is `r3-motioninterp-decomp.md` (line-anchored); the port
|
|
||||||
plan with the full ambiguity table is `r3-port-plan.md` §0. This note records the
|
|
||||||
PINNED resolutions the W1–W6 ports code against. Every pin below was produced by an
|
|
||||||
independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
|
||||||
(line numbers cited are that file's) and A1/A3 additionally survived an adversarial
|
|
||||||
refutation pass (independent Ghidra MCP decompiles + vtable-dump slot resolution).
|
|
||||||
**No pin was refuted; none blocks on cdb.**
|
|
||||||
|
|
||||||
## Pinned
|
|
||||||
|
|
||||||
- **A1 — `motion_allows_jump` (0x005279e0) polarity: PINNED as a BLOCKLIST.
|
|
||||||
`0` = jump allowed (pass), `0x48` = jump BLOCKED** (a
|
|
||||||
"YouCantJumpFromThisPosition"-class error code). The extraction doc's original
|
|
||||||
§3a "whitelist / 0x48 = allowed" note was inverted (now corrected in-place, same
|
|
||||||
commit). Confidence: **high** (adversarially verified; refutation failed).
|
|
||||||
|
|
||||||
Definitive blocklist (port as **literal uint ranges**, NOT enum-ordinal ranges):
|
|
||||||
|
|
||||||
| Motion id (literal uint) | MotionCommand names | Returns |
|
|
||||||
|---|---|---|
|
|
||||||
| `[0x1000006f, 0x10000078]` | MagicPowerUp01..MagicPowerUp10 | `0x48` BLOCK |
|
|
||||||
| `[0x10000128, 0x10000131]` | TripleThrustLow..MagicPowerUp07Purple | `0x48` BLOCK |
|
|
||||||
| `0x40000008` (exact) | **Fallen** | `0x48` BLOCK |
|
|
||||||
| `[0x40000016, 0x40000018]` | Reload, Unload, Pickup | `0x48` BLOCK |
|
|
||||||
| `[0x4000001e, 0x40000039]` | AimLevel..MagicPray | `0x48` BLOCK |
|
|
||||||
| `[0x41000012, 0x41000014]` | Crouch, Sitting, Sleeping | `0x48` BLOCK |
|
|
||||||
| everything else — incl. **Falling `0x40000015`**, Ready `0x41000003`, Dead `0x40000011`, all turn/sidestep ids (`0x6500000d` TurnRight etc.) | — | `0` PASS |
|
|
||||||
|
|
||||||
Evidence (all boundaries re-derived from the verbatim body, raw 304908–304931):
|
|
||||||
`005279e9 if (arg2 > 0x40000018) { 00527a24 if (arg2 > 0x41000014) return 0;
|
|
||||||
00527a39 if ((arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039)))
|
|
||||||
return 0; } 005279e9 else if (arg2 < 0x40000016) { 005279f7 if (arg2 > 0x10000131)
|
|
||||||
{ 00527a18 if (arg2 != 0x40000008) 00527a1c return 0; } 005279f7 else if ((arg2 <
|
|
||||||
0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078))) 00527a10 return 0; }
|
|
||||||
00527a40 return 0x48;` — the middle band `[0x40000016..0x40000018]` satisfies
|
|
||||||
neither outer branch and falls straight to `return 0x48`. Polarity sealed by four
|
|
||||||
independent callers plus the top of the chain:
|
|
||||||
1. `jump_is_allowed` 0x005282b0 (raw 305539–305542): `eax_7 =
|
|
||||||
motion_allows_jump(this, this->interpreted_state.forward_command); if (eax_7
|
|
||||||
!= 0) return eax_7;` — nonzero returned verbatim as the failure code; all
|
|
||||||
success paths return `eax_7 == 0`.
|
|
||||||
2. `DoInterpretedMotion` 0x00528360 (raw 305597–305607): params bit `0x20000`
|
|
||||||
(disable_jump_during_link) FORCES `eax_5 = 0x48` — a jump-*disable* bit
|
|
||||||
forcing the value only makes sense if `0x48` = block; `eax_5` is stashed as
|
|
||||||
the queue node's `jump_error_code` (add_to_queue arg4 → node +0xc, raw
|
|
||||||
305044), which `jump_is_allowed`'s head peek returns as an error when nonzero.
|
|
||||||
3. `CMotionInterp::jump` 0x00528780 (raw 305801–305812): `result =
|
|
||||||
jump_is_allowed(...); if (result != 0) { standing_longjump = 0; return
|
|
||||||
result; }` — the jump only executes on the zero path.
|
|
||||||
4. Sibling convention: `jump_charge_is_allowed` (raw 304940–304948) and
|
|
||||||
`charge_jump` (raw 305453–305466) both `return 0` on pass / `0x48`/`0x49` on
|
|
||||||
block; `charge_jump`'s success path arms `standing_longjump` then `return 0`.
|
|
||||||
|
|
||||||
**Falling vs Fallen: retail blocks Fallen (0x40000008) and PASSES Falling
|
|
||||||
(0x40000015). ACE mis-transcribed** — ACE MotionInterp.cs:770-779's exact-id
|
|
||||||
term is `substate == (uint)MotionCommand.Falling` and Fallen appears nowhere in
|
|
||||||
its motion_allows_jump: the exact inverse of retail on those two ids. ACE's own
|
|
||||||
jump_charge_is_allowed (MotionInterp.cs:736) uses Fallen, matching retail's
|
|
||||||
0x40000008 in both charge gates — the Falling term is a one-off ACE transposition
|
|
||||||
slip, not a deliberate change. Falling still can't yield mid-air jumps in retail:
|
|
||||||
`jump_is_allowed`'s airborne gate (raw 305559–305570) independently returns
|
|
||||||
`0x24` unless transient_state has Contact(1)+OnWalkable(2). **Port: block Fallen,
|
|
||||||
pass Falling.**
|
|
||||||
|
|
||||||
- **A2 — `jump_is_allowed` pending-head peek: PINNED — the peek fires whenever the
|
|
||||||
queue is non-empty; there is NO `Count > 1` gate.** Raw 305524–305556
|
|
||||||
(0x005282b0): `if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
|
|
||||||
return 0x47;` THEN `class LListData* head_ = this->pending_motions.head_; ...
|
|
||||||
if (head_ != 0) eax_6 = *(int32_t*)((char*)head_ + 0xc); if ((head_ == 0 ||
|
|
||||||
eax_6 == 0)) { eax_6 = jump_charge_is_allowed(this); ... } return eax_6;` — if
|
|
||||||
the head exists AND its `jump_error_code` (+0xc) is nonzero, the whole
|
|
||||||
charge/motion/stamina chain is skipped and the peeked code returned. Ordering
|
|
||||||
note (both decompilers agree): **IsFullyConstrained (→0x47) is checked BEFORE
|
|
||||||
the peek** — port in that order. ACE L752-753's `PendingMotions.Count > 1 &&`
|
|
||||||
has no retail counterpart; do not copy. ACE L746's `WeenieObj == null &&
|
|
||||||
!WeenieObj.IsCreature()` NPE-typo also confirmed wrong — retail (raw
|
|
||||||
305517–305520) is `if (weenie_obj != 0) eax_2 = weenie_obj->vtable->IsCreature();
|
|
||||||
if ((weenie_obj != 0 && eax_2 == 0))` → non-creature-with-weenie skips the ground
|
|
||||||
check. Confidence: **high**.
|
|
||||||
|
|
||||||
- **A3 — dual-dispatch gate: PINNED to `IsThePlayer` (vtable slot +0x14), NOT
|
|
||||||
`IsCreature` (+0x2c), in all four functions.** Confidence: **high**
|
|
||||||
(adversarially verified: independent Ghidra decompile shows raw offset +0x14 at
|
|
||||||
all four sites vs +0x2c in HitGround; the ACCWeenieObject vftable dump at
|
|
||||||
0x007e3ea0, raw 1035101–1035135, binds +0x14 → `ACCWeenieObject::IsThePlayer`
|
|
||||||
0x0058C3D0 and +0x2c → `IsCreature` 0x0058C3F0).
|
|
||||||
|
|
||||||
| Function | Address @ raw line | Weenie tested | Predicate → raw path | Dispatch args | Entry gate |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `apply_current_movement` | 0x00528870 @305838 | `this->weenie_obj` | `(weenie==0 \|\| IsThePlayer()) && movement_is_autonomous()` | `(arg2, arg3)` passthrough | `physics_obj != 0 && initted != 0` |
|
|
||||||
| `ReportExhaustion` | 0x005288d0 @305861 | `this->weenie_obj` | same | `(0, 0)` | same |
|
|
||||||
| `SetWeenieObject` | 0x00528920 @305884 | **incoming `arg2`** (stored to `this->weenie_obj` first) | same | `(1, 0)` | same |
|
|
||||||
| `SetPhysicsObject` | 0x00528970 @305911 | `this->weenie_obj` | same | `(1, 0)` | `arg2 != 0 && initted != 0` (after `this->physics_obj = arg2`) |
|
|
||||||
|
|
||||||
Predicate true → `apply_raw_movement(a, b)`; false → `apply_interpreted_movement(a, b)`.
|
|
||||||
Verbatim (apply_current_movement, raw 305846–305855): `if (weenie_obj != 0)
|
|
||||||
eax_2 = weenie_obj->vtable->IsThePlayer(); if (((weenie_obj == 0 || eax_2 != 0)
|
|
||||||
&& CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0)) {
|
|
||||||
apply_raw_movement(this, arg2, arg3); return; } apply_interpreted_movement(this,
|
|
||||||
arg2, arg3);`. Semantics: `IsThePlayer` (raw 406494–406500) = `this->id ==
|
|
||||||
SmartBox::smartbox->player_id` (neg/sbb idiom); `movement_is_autonomous` (raw
|
|
||||||
276443–276447) = `return this->last_move_was_autonomous;`. Anti-artifact proof:
|
|
||||||
`HitGround` (raw 306005) and `LeaveGround` (raw 306031), ~0x250 bytes away in
|
|
||||||
the same decomp region, call `IsCreature()` — BN distinguishes the two slots
|
|
||||||
locally, ruling out a slot-shift mislabel. **ACE MotionInterp.cs:430-438's
|
|
||||||
`IsCreature` predicate is a genuine server-side divergence — do NOT copy** (a
|
|
||||||
remote player is a creature but not the player; the wrong gate sends remotes
|
|
||||||
down `apply_raw_movement` against an empty raw state).
|
|
||||||
Watchouts: (a) a FIFTH IsThePlayer site exists in `move_to_interpreted_state`
|
|
||||||
(raw 305977): per queued action node, `if ((weenie_obj->vtable->IsThePlayer()
|
|
||||||
== 0 || node->autonomous == 0))` → execute — i.e. server-echoed autonomous
|
|
||||||
actions are SKIPPED for the local player (local-echo suppression). (b) the BASE
|
|
||||||
`CWeenieObject::vftable` data dump at 0x007e3df4 (raw 1035061–1035094) is
|
|
||||||
misaligned (BN data-label overlap artifact) — only the derived ACC table at
|
|
||||||
0x007e3ea0 is trustworthy. (c) the PDB also has a distinct
|
|
||||||
`ACCWeenieObject::IsPlayer` (slot +0x10, 0x0058C890) — never conflate the three
|
|
||||||
`Is*Player*` virtuals.
|
|
||||||
|
|
||||||
- **A4 — MovementParameters bit numbering: PINNED via the retail PDB's own
|
|
||||||
bitfield struct, acclient.h:31423-31443** — bit-for-bit identical to ACE's
|
|
||||||
`MovementParamFlags`. Confidence: **high**. Absolute masks:
|
|
||||||
|
|
||||||
| Mask | Flag | Mask | Flag |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `0x1` | can_walk | `0x400` | use_spheres |
|
|
||||||
| `0x2` | can_run | `0x800` | set_hold_key |
|
|
||||||
| `0x4` | can_sidestep | `0x1000` | autonomous |
|
|
||||||
| `0x8` | can_walk_backwards | `0x2000` | modify_raw_state |
|
|
||||||
| `0x10` | can_charge | `0x4000` | modify_interpreted_state |
|
|
||||||
| `0x20` | fail_walk | `0x8000` | cancel_moveto |
|
|
||||||
| `0x40` | use_final_heading | `0x10000` | stop_completely |
|
|
||||||
| `0x80` | sticky | `0x20000` | disable_jump_during_link |
|
|
||||||
| `0x100` | move_away | | |
|
|
||||||
| `0x200` | move_towards | | |
|
|
||||||
|
|
||||||
Ctor default (raw 300510–300534, 0x00524380): `(bitfield & 0xfffdee0f) |
|
|
||||||
0x1ee0f` → **0x1EE0F** sets {can_walk, can_run, can_sidestep,
|
|
||||||
can_walk_backwards, move_towards, use_spheres, set_hold_key, modify_raw_state,
|
|
||||||
modify_interpreted_state, cancel_moveto, stop_completely}; clears {can_charge,
|
|
||||||
fail_walk, use_final_heading, sticky, move_away, autonomous,
|
|
||||||
disable_jump_during_link}. Runtime reads all consistent: DoMotion @306183
|
|
||||||
byte1-sign = `0x8000` cancel_moveto → interrupt; @306188 byte1&8 = `0x800`
|
|
||||||
set_hold_key → `SetHoldKey(hold_key_to_apply, ((__inner0 >> 0xf) & 1))` — note
|
|
||||||
**SetHoldKey's second arg is the cancel_moveto bit**, not a constant; @306213
|
|
||||||
byte1&0x20 = `0x2000` modify_raw_state → RawMotionState::ApplyMotion mirror.
|
|
||||||
DoInterpretedMotion @305597 full-dword `& 0x20000` = disable_jump_during_link →
|
|
||||||
jump_error_code forced `0x48`; @305609/305617 byte1&0x40 = `0x4000`
|
|
||||||
modify_interpreted_state. StopMotion @305684 sign → interrupt; @305705
|
|
||||||
byte1&0x20 → RawState.RemoveMotion.
|
|
||||||
**ACE-divergence traps for W1:** ACE's MovementParameters ctor sets
|
|
||||||
`CanCharge = true` (MovementParameters.cs:58) — retail's default has `0x10`
|
|
||||||
CLEAR; port `can_charge = false`. ACE also changed Default_WalkRunThreshold to
|
|
||||||
1.0 (L50) vs retail's **15.0** (@300519). Retail's ctor caches the computed
|
|
||||||
default in a static (`normal_bitfield`, @300523–300533) whose AND-mask preserves
|
|
||||||
uninitialized bits 18–31 on first call — harmless (only bits 0–17 defined); port
|
|
||||||
as a plain 0x1EE0F default.
|
|
||||||
|
|
||||||
- **A5 — `get_jump_v_z` (0x00527aa0): PINNED to ACE's shape; the BN text is
|
|
||||||
x87-flag garbled exactly as suspected** (raw 304953–304984 returns FCOM flag
|
|
||||||
synthesis on two paths — unreadable either way; the Ghidra decompile is clean
|
|
||||||
and adjudicates). Behavior: `jump_extent < 0.000199999995f` → **0.0f**; else
|
|
||||||
clamp extent to max **1.0**; `weenie_obj == NULL` → **10.0f** (`___real_41200000`);
|
|
||||||
`InqJumpVelocity(extent, &out)` false → **0.0f**; true → out. Matches ACE
|
|
||||||
L634-652. The epsilon literal is verbatim in the raw @304959: `long double
|
|
||||||
temp0 = ((long double)0.000199999995f);`. **acdream's 0.001
|
|
||||||
(MotionInterpreter.cs:278 JumpExtentEpsilon) must become 0.000199999995f.**
|
|
||||||
Confidence: **high**.
|
|
||||||
|
|
||||||
- **A6 — `get_leave_ground_velocity` (0x005280c0) zero-fallback matrix direction:
|
|
||||||
PINNED as GLOBAL→LOCAL** — decisively, by index-pattern match against Frame's
|
|
||||||
own transform pair. The fallback (raw 305434–305440) computes `out.i =
|
|
||||||
fl2gv[3i+0]*v.x + fl2gv[3i+1]*v.y + fl2gv[3i+2]*v.z` — the IDENTICAL row-linear
|
|
||||||
pattern as `Frame::globaltolocalvec` 0x00452550 (raw 91466–91471), while
|
|
||||||
`Frame::localtoglobalvec` 0x004524f0 (raw 91452–91457) uses the transpose
|
|
||||||
(`fl2gv[0]*x + fl2gv[3]*y + fl2gv[6]*z`). BN's "m_fl2gv = local→global" reading
|
|
||||||
was name-misled; the applied math is global→local, matching ACE's
|
|
||||||
`GlobalToLocalVec(Velocity)`. **acdream's `Quaternion.Inverse(Orientation)`
|
|
||||||
transform (MotionInterpreter.cs:1024) IS global→local — keep.** Mechanics:
|
|
||||||
function is void (writes the caller's Vector3); body order =
|
|
||||||
`get_state_velocity(out)` → `out.z = get_jump_v_z()` → fallback fires ONLY when
|
|
||||||
`|x| AND |y| AND |z|` are ALL `< 0.000199999995f` (epsilon appears three times,
|
|
||||||
@305412/305420/305427, one per component) and then overwrites ALL THREE
|
|
||||||
components (including z, discarding the jump v_z) with
|
|
||||||
`globaltolocal(physics_obj->m_velocityVector)`. Epsilon must become 0.0002
|
|
||||||
(currently 0.001). Confidence: **high**.
|
|
||||||
|
|
||||||
- **A7 — `MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in
|
|
||||||
`CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp
|
|
||||||
doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed
|
|
||||||
node's motion id; the interp body reads NEITHER param in this build — **pop the
|
|
||||||
HEAD unconditionally, never match by motion id**. Keep the params for R5
|
|
||||||
signature parity. (Copied verbatim from the plan §0 adjudication — no new
|
|
||||||
research needed.)
|
|
||||||
|
|
||||||
- **A8 — `enter_default_state` (0x00528c80) does NOT clear `pending_motions`: it
|
|
||||||
APPENDS the `{0, 0x41000003, 0}` sentinel onto whatever queue exists.** Raw
|
|
||||||
306128–306154: the body resets only raw_state + interpreted_state (fresh
|
|
||||||
default-constructed copies) and calls `CPhysicsObj::InitializeMotionTables`,
|
|
||||||
then `void* eax_2 = operator new(0x10); ... *(+8) = 0x41000003; *(+0xc) = 0;`
|
|
||||||
followed by the tail-append (`tail_ == 0 ? head_ = eax_2 : *tail_ = eax_2;
|
|
||||||
tail_ = eax_2`) — byte-identical in shape to `add_to_queue`'s append (raw
|
|
||||||
305047–305057), just inlined — then `initted = 1; LeaveGround(this);`. No
|
|
||||||
drain/clear anywhere. ACE's `PendingMotions = new LinkedList` reset is the
|
|
||||||
divergence; pre-existing nodes survive in retail (W2 test asserts this). The
|
|
||||||
MovementManager lazy-create double-call (§6g) is genuine retail — do not "fix";
|
|
||||||
R3's direct-bind construction calls it once explicitly. Confidence: **high**.
|
|
||||||
|
|
||||||
- **A9 — `StopCompletely` (0x00527e40) jump-snapshot quirk: PINNED verbatim.**
|
|
||||||
Raw 305216–305227 ordering: `interrupt_current_movement(physics_obj);` → `eax_2
|
|
||||||
= motion_allows_jump(this, this->interpreted_state.forward_command);` (**OLD**
|
|
||||||
forward_command, BEFORE the overwrite) → write raw_state
|
|
||||||
{forward_command=0x41000003, forward_speed=1f, sidestep_command=0,
|
|
||||||
turn_command=0} → same four writes to interpreted_state →
|
|
||||||
`StopCompletely_Internal(physics_obj)` → `add_to_queue(this, 0, 0x41000003,
|
|
||||||
eax_2)` → cell==0 → `RemoveLinkAnimations` → `return 0`. Entry: physics_obj==0
|
|
||||||
→ return 8. **Retail touches ONLY forward cmd/speed + sidestep/turn COMMANDS —
|
|
||||||
it does NOT write sidestep_speed or turn_speed** (acdream's 1.0 speed resets
|
|
||||||
are a divergence to remove per plan J9). Golden: the queued node's error code
|
|
||||||
reflects the pre-stop command. Confidence: **high**.
|
|
||||||
|
|
||||||
- **A10 — error-code semantics: PINNED with the definitive table** (exhaustive
|
|
||||||
`return <code>` sweep over raw 304908–306277 + 300150–300540; 19 return sites +
|
|
||||||
1 store site, all attributed). Codes are local-only (never on the wire) — safe
|
|
||||||
renumber in W1. Confidence: **high**.
|
|
||||||
|
|
||||||
| Code | Retail meaning | Sites (address @ raw line) |
|
|
||||||
|---|---|---|
|
|
||||||
| `8` | no physics_obj | StopCompletely @305214; DoInterpretedMotion @305579; StopInterpretedMotion @305639; StopMotion @305680; jump @305798; DoMotion @306165 |
|
|
||||||
| `0x24` | not grounded / no contact | jump_is_allowed @305570 (0x005282f0 — gravity-state creature without Contact+OnWalkable; **also covers its physics_obj==0 case**, see note); **DoInterpretedMotion @305622-305623** (action-class motion `arg2 & 0x10000000` blocked by contact_allows_move — second site, absent from the plan row) |
|
|
||||||
| `0x3f` | Crouch 0x41000012 in combat stance | DoMotion @306196 |
|
|
||||||
| `0x40` | Sitting 0x41000013 in combat stance | DoMotion @306199 |
|
|
||||||
| `0x41` | Sleeping 0x41000014 in combat stance | DoMotion @306202 |
|
|
||||||
| `0x42` | `motion & 0x2000000` outside NonCombat (0x8000003d) | DoMotion @306205 |
|
|
||||||
| `0x45` | action depth: GetNumActions ≥ 6 | DoMotion @306209 |
|
|
||||||
| `0x47` | general movement failure | jump_is_allowed @305525 (IsFullyConstrained); jump_is_allowed @305549+305556 (staged, returned when `JumpStaminaCost(arg2, arg3) == 0`); CMotionInterp::PerformMovement @306227 (`type-1 > 4`); MovementManager::PerformMovement @300201 (`type-1 > 8`) |
|
|
||||||
| `0x48` | jump BLOCKED by motion/position (**NOT a success sentinel** — the A1 polarity fix) | motion_allows_jump @304930; jump_charge_is_allowed @304948 (Fallen or Crouch..Sleeping); charge_jump @305459 (same predicate); STORED (not returned) as node jump_error_code in DoInterpretedMotion @305605 on disable_jump_during_link |
|
|
||||||
| `0x49` | weenie `CanJump(jump_extent)` refused | jump_charge_is_allowed @304941; charge_jump @305454 |
|
|
||||||
|
|
||||||
Note: `jump_is_allowed` returns **0x24** (not 8) when `physics_obj == 0` (raw
|
|
||||||
structure 305512 + 305570: the `if (this->physics_obj != 0) {...}` wrapper falls
|
|
||||||
out to `return 0x24;`) — the "8 = no physics obj" convention holds everywhere
|
|
||||||
else but NOT there; ACE (L744-745) returns NoPhysicsObject there — small ACE
|
|
||||||
divergence, port retail. W1 renames acdream's WeenieError values to these
|
|
||||||
numerics (adds 0x3f/0x40/0x41/0x42/0x45; fixes 0x24/0x47/0x48 semantics — the
|
|
||||||
current enum returns 0x48 for airborne where retail returns 0x24).
|
|
||||||
|
|
||||||
## Adjacent findings (load-bearing for W-commits, discovered while pinning)
|
|
||||||
|
|
||||||
- **`move_to_interpreted_state` call-site garble (W-commit trap):** raw
|
|
||||||
305946–305949 renders `int32_t esi_1 = -(eax_2); ...
|
|
||||||
apply_current_movement(this, 1, -((esi_1 - esi_1)));` — the second arg
|
|
||||||
algebraically collapses to constant 0, which is decompiler nonsense (sbb/neg
|
|
||||||
idiom). Intended semantics per ACE MotionInterp.cs:796-799: `allowJump =
|
|
||||||
(motion_allows_jump(OLD forward_command) == 0)` passed as
|
|
||||||
apply_current_movement's second arg. Port `allowJump = (eax_2 == 0)` — another
|
|
||||||
minor polarity trap in the A1 family.
|
|
||||||
- **`jump` (0x00528780) details for J7:** contains `interrupt_current_movement`
|
|
||||||
(@305800); on success stores `jump_extent = arg2` and calls
|
|
||||||
`set_on_walkable(obj, 0)`; clears `standing_longjump` ONLY on the failure path.
|
|
||||||
|
|
||||||
## W0 cdb capture (pending, non-blocking)
|
|
||||||
|
|
||||||
No pin above depends on it — all ten are textually pinned with adversarial
|
|
||||||
verification. One live session feeds the R3 goldens (W2/W3/W5/W6): bp
|
|
||||||
`CMotionInterp::MotionDone` / `add_to_queue` / `jump` / `jump_is_allowed` /
|
|
||||||
`jump_charge_is_allowed` / `charge_jump` / `motion_allows_jump` / `HitGround` /
|
|
||||||
`LeaveGround` / `StopCompletely` / `set_hold_run` / `SetHoldKey` /
|
|
||||||
`enter_default_state` with arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`);
|
|
||||||
user protocol per `r3-port-plan.md` §0 (idle / walk / run / shift-toggle /
|
|
||||||
tap-jump / full-charge jump / running jump / standing long-jump / jump-while-
|
|
||||||
crouched / jump-in-combat-stance (0x3f–0x42 family) / walk off a ledge (the A6
|
|
||||||
momentum fallback) / land / emote-while-running / 7 rapid emotes (0x45 cap) /
|
|
||||||
logout drain). Nice-to-have live spot-checks it will also provide: A1 polarity
|
|
||||||
(log `motion_allows_jump` arg2+eax during walk/run/mid-fall/crouch jumps) and the
|
|
||||||
A2 head-peek short-circuit.
|
|
||||||
|
|
@ -1,987 +0,0 @@
|
||||||
# R3 — ACE port map: MotionInterp + MovementManager + MotionDone chain
|
|
||||||
|
|
||||||
Sources (full-file reads, all line numbers are 1-indexed in the ACE source tree):
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` (836 lines, whole file read)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Managers/MovementManager.cs` (216 lines, whole file read)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionNode.cs` (21 lines, whole file read)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (252 lines, whole file read — AnimationDone/CheckForCompletedMotions, the actual MotionDone producer)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` (targeted reads: L80-109 IsAnimating/IsMovingOrAnimating, L290-301 CheckForCompletedMotions, L890-903 MotionDone, L4235-4247 ShowPendingMotions/motions_pending)
|
|
||||||
- `references/ACE/Source/ACE.Server/Physics/PartArray.cs` (L60-77 CheckForCompletedMotions passthrough)
|
|
||||||
|
|
||||||
No named-retail cross-reference was performed in this pass (ACE-side mapping only, per R3 scope). ACE class is `MotionInterp` (note: **not** `CMotionInterp` — ACE dropped the `C` Hungarian prefix project-wide, this is a naming-convention divergence not a functional one).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Class fields (MotionInterp.cs L14-32)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public bool Initted;
|
|
||||||
public WeenieObject WeenieObj;
|
|
||||||
public PhysicsObj PhysicsObj;
|
|
||||||
public RawMotionState RawState;
|
|
||||||
public InterpretedMotionState InterpretedState;
|
|
||||||
public float CurrentSpeedFactor;
|
|
||||||
public bool StandingLongJump;
|
|
||||||
public float JumpExtent;
|
|
||||||
public int ServerActionStamp;
|
|
||||||
public float MyRunRate;
|
|
||||||
public LinkedList<MotionNode> PendingMotions;
|
|
||||||
|
|
||||||
public const float BackwardsFactor = 6.4999998e-1f; // 0.65
|
|
||||||
public const float MaxSidestepAnimRate = 3.0f;
|
|
||||||
public const float RunAnimSpeed = 4.0f;
|
|
||||||
public const float RunTurnFactor = 1.5f;
|
|
||||||
public const float SidestepAnimSpeed = 1.25f;
|
|
||||||
public const float SidestepFactor = 0.5f;
|
|
||||||
public const float WalkAnimSpeed = 3.1199999f; // 3.12
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `PendingMotions` is `LinkedList<MotionNode>`, not an array/ring buffer — ACE reimplemented retail's pending-motion queue as a doubly-linked list. `MotionNode` (see §2) carries `ContextID`, `Motion`, `JumpErrorCode` — this is the FIFO queue of in-flight interpreted motions waiting on animation completion.
|
|
||||||
- `CurrentSpeedFactor` is declared but the only read site in this file is `get_adjusted_max_speed()` (L629) — no write site anywhere in MotionInterp.cs. Likely set externally (WeenieObj or PhysicsObj) — not visible in this file.
|
|
||||||
- `JumpExtent` doubles as both "how hard is the current jump charging" (set in `jump()`, read in `charge_jump()`/`get_jump_v_z()`) and as a guard the WeenieObj layer checks via `CanJump(JumpExtent)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. MotionNode (queue element) — MotionNode.cs, whole file
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class MotionNode
|
|
||||||
{
|
|
||||||
public int ContextID;
|
|
||||||
public uint Motion;
|
|
||||||
public WeenieError JumpErrorCode;
|
|
||||||
|
|
||||||
public MotionNode() { }
|
|
||||||
public MotionNode(int contextID, uint motion, WeenieError jumpErrorCode)
|
|
||||||
{
|
|
||||||
ContextID = contextID;
|
|
||||||
Motion = motion;
|
|
||||||
JumpErrorCode = jumpErrorCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Trivial DTO. `JumpErrorCode` is the pre-computed "would a jump be legal right now, if the player pressed jump while this pending motion is still in flight" — computed once at `add_to_queue` time (see `motion_allows_jump` call sites in `DoInterpretedMotion`/`StopCompletely`), then consumed lazily by `jump_is_allowed()` (§7) via `PendingMotions.First.Value.JumpErrorCode` when `PendingMotions.Count > 1`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. add_to_queue / MotionDone / RemoveMotion (queue lifecycle)
|
|
||||||
|
|
||||||
### add_to_queue (L388-392)
|
|
||||||
```csharp
|
|
||||||
public void add_to_queue(int contextID, uint motion, WeenieError jumpErrorCode)
|
|
||||||
{
|
|
||||||
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
|
|
||||||
PhysicsObj.IsAnimating = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Simple append + set the `IsAnimating` flag unconditionally true (even if this is the first entry). Called from:
|
|
||||||
- `DoInterpretedMotion` (L85) — normal interpreted-motion success path, `jump_error_code` precomputed at L73-83.
|
|
||||||
- `StopCompletely` (L321) — with hardcoded `MotionCommand.Ready` and a `jump` precomputed at L307.
|
|
||||||
- `StopInterpretedMotion` (L348) — with hardcoded `MotionCommand.Ready` and `WeenieError.None`.
|
|
||||||
- `apply_interpreted_movement` (L495) — the turn-stop fallback branch, hardcoded `MotionCommand.Ready` / `WeenieError.None`.
|
|
||||||
|
|
||||||
### MotionDone (L210-234) — the dequeue-on-animation-complete handler
|
|
||||||
```csharp
|
|
||||||
public void MotionDone(bool success)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return;
|
|
||||||
|
|
||||||
var motionData = PendingMotions.First;
|
|
||||||
|
|
||||||
// null or != last in list?
|
|
||||||
if (motionData != null)
|
|
||||||
{
|
|
||||||
var pendingMotion = motionData.Value;
|
|
||||||
if ((pendingMotion.Motion & (uint)CommandMask.Action) != 0)
|
|
||||||
{
|
|
||||||
PhysicsObj.unstick_from_object();
|
|
||||||
InterpretedState.RemoveAction();
|
|
||||||
RawState.RemoveAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
motionData = PendingMotions.First;
|
|
||||||
if (motionData != null)
|
|
||||||
{
|
|
||||||
PendingMotions.Remove(motionData);
|
|
||||||
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Note the ACE dev's own inline comment `// null or != last in list?` — flags this as a spot they weren't fully certain about vs. retail (a "did I get this right" breadcrumb). Two things happen if the head node exists:
|
|
||||||
1. If the head's Motion has the `Action` bit set (`CommandMask.Action`), it's treated as an "action" motion (e.g. attack/emote) that stuck the object to something — `unstick_from_object()` + pop the action off both InterpretedState and RawState action stacks.
|
|
||||||
2. **Re-fetches `PendingMotions.First` a second time** (redundant re-read — `motionData` unchanged since nothing between the two reads mutates the list) before removing it and recomputing `IsAnimating` as `Count > 0` (as opposed to `add_to_queue`'s unconditional `true`).
|
|
||||||
|
|
||||||
`success` parameter is accepted but **never read** in this method body — dead parameter as far as MotionInterp.MotionDone goes. (It IS read/propagated further down in `MotionTableManager.AnimationDone`/`CheckForCompletedMotions`, see §8 — but `MotionInterp.MotionDone` itself ignores it entirely.)
|
|
||||||
|
|
||||||
### RemoveMotion
|
|
||||||
Not defined in MotionInterp.cs — it's called on `InterpretedState`/`RawState` (`InterpretedState.RemoveMotion(motion)`, `RawState.RemoveMotion(motion)`), i.e. lives in `InterpretedMotionState`/`RawMotionState`/`MotionState` classes, NOT in MotionInterp. Call sites inside this file: L340, L351, L358, L498. Out of scope for this file-bounded pass (not in MotionInterp.cs or MovementManager.cs).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. DoMotion / StopMotion / StopCompletely (top-level motion API)
|
|
||||||
|
|
||||||
### DoMotion (L112-158) — raw-command entry point
|
|
||||||
```csharp
|
|
||||||
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
|
|
||||||
|
|
||||||
var currentParams = new MovementParameters();
|
|
||||||
currentParams.CopySome(movementParams);
|
|
||||||
|
|
||||||
var currentMotion = motion;
|
|
||||||
|
|
||||||
if (movementParams.CancelMoveTo) PhysicsObj.cancel_moveto();
|
|
||||||
if (movementParams.SetHoldKey) SetHoldKey(movementParams.HoldKeyToApply, movementParams.CancelMoveTo);
|
|
||||||
|
|
||||||
adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply);
|
|
||||||
|
|
||||||
if (InterpretedState.CurrentStyle != (uint)MotionCommand.NonCombat)
|
|
||||||
{
|
|
||||||
switch (motion)
|
|
||||||
{
|
|
||||||
case (uint)MotionCommand.Crouch: return WeenieError.CantCrouchInCombat;
|
|
||||||
case (uint)MotionCommand.Sitting: return WeenieError.CantSitInCombat;
|
|
||||||
case (uint)MotionCommand.Sleeping: return WeenieError.CantLieDownInCombat;
|
|
||||||
}
|
|
||||||
if ((motion & (uint)CommandMask.ChatEmote) != 0) return WeenieError.CantChatEmoteInCombat;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((motion & (uint)CommandMask.Action) != 0)
|
|
||||||
{
|
|
||||||
if (InterpretedState.GetNumActions() >= 6) return WeenieError.TooManyActions;
|
|
||||||
}
|
|
||||||
var result = DoInterpretedMotion(currentMotion, currentParams);
|
|
||||||
|
|
||||||
if (result == WeenieError.None && movementParams.ModifyRawState)
|
|
||||||
RawState.ApplyMotion(motion, movementParams);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Key points:
|
|
||||||
- `switch (motion)` (combat-blocked motions) tests the **original** `motion` parameter, not `currentMotion` (post-`adjust_motion` mutation) — combat-block checks happen on the raw pre-adjustment command.
|
|
||||||
- Hardcoded action cap of 6 concurrent actions (`GetNumActions() >= 6`).
|
|
||||||
- `RawState.ApplyMotion` uses the **original** `motion`, not `currentMotion` — raw state records what was actually requested, interpreted state (via `DoInterpretedMotion`) gets the post-`adjust_motion` (walk→run promoted, backward-inverted, etc.) version.
|
|
||||||
|
|
||||||
### StopMotion (L367-386)
|
|
||||||
Mirror of DoMotion but for the "stop" side: `cancel_moveto` gate, `adjust_motion`, delegates to `StopInterpretedMotion`, then conditionally calls `RawState.RemoveMotion(motion)` (original motion, not adjusted) on success.
|
|
||||||
|
|
||||||
### StopCompletely (L301-327)
|
|
||||||
```csharp
|
|
||||||
public WeenieError StopCompletely()
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
|
|
||||||
|
|
||||||
PhysicsObj.cancel_moveto();
|
|
||||||
|
|
||||||
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
|
|
||||||
|
|
||||||
RawState.ForwardCommand = (uint)MotionCommand.Ready;
|
|
||||||
RawState.ForwardSpeed = 1.0f;
|
|
||||||
RawState.SideStepCommand = 0;
|
|
||||||
RawState.TurnCommand = 0;
|
|
||||||
|
|
||||||
InterpretedState.ForwardCommand = (uint)MotionCommand.Ready;
|
|
||||||
InterpretedState.ForwardSpeed = 1.0f;
|
|
||||||
InterpretedState.SideStepCommand = 0;
|
|
||||||
InterpretedState.TurnCommand = 0;
|
|
||||||
|
|
||||||
PhysicsObj.StopCompletely_Internal();
|
|
||||||
|
|
||||||
add_to_queue(0, (uint)MotionCommand.Ready, jump);
|
|
||||||
|
|
||||||
if (PhysicsObj.CurCell == null)
|
|
||||||
PhysicsObj.RemoveLinkAnimations();
|
|
||||||
|
|
||||||
return WeenieError.None;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Hard-resets both Raw and Interpreted state (Forward/SideStep/Turn) to Ready/1.0/0/0 directly by field assignment — bypasses the normal `ApplyMotion`/`RemoveMotion` mutators entirely. `jump` (the eligibility precomputed BEFORE the reset, off the OLD `InterpretedState.ForwardCommand`) is stashed into the queued `Ready` node's `JumpErrorCode`. Delegates the physics-side reset to `PhysicsObj.StopCompletely_Internal()` (not in this file).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. DoInterpretedMotion / StopInterpretedMotion (interpreted-command layer)
|
|
||||||
|
|
||||||
### DoInterpretedMotion (L51-110)
|
|
||||||
```csharp
|
|
||||||
public WeenieError DoInterpretedMotion(uint motion, MovementParameters movementParams)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
|
|
||||||
var result = WeenieError.None;
|
|
||||||
|
|
||||||
if (contact_allows_move(motion))
|
|
||||||
{
|
|
||||||
if (StandingLongJump && (motion == WalkForward || motion == RunForward || motion == SideStepRight))
|
|
||||||
{
|
|
||||||
if (movementParams.ModifyInterpretedState)
|
|
||||||
InterpretedState.ApplyMotion(motion, movementParams);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (motion == (uint)MotionCommand.Dead)
|
|
||||||
PhysicsObj.RemoveLinkAnimations();
|
|
||||||
|
|
||||||
result = PhysicsObj.DoInterpretedMotion(motion, movementParams);
|
|
||||||
|
|
||||||
if (result == WeenieError.None)
|
|
||||||
{
|
|
||||||
var jump_error_code = WeenieError.None;
|
|
||||||
if (movementParams.DisableJumpDuringLink)
|
|
||||||
jump_error_code = WeenieError.YouCantJumpFromThisPosition;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
jump_error_code = motion_allows_jump(motion);
|
|
||||||
if (jump_error_code == WeenieError.None && (motion & (uint)CommandMask.Action) == 0)
|
|
||||||
jump_error_code = motion_allows_jump(InterpretedState.ForwardCommand);
|
|
||||||
}
|
|
||||||
add_to_queue(movementParams.ContextID, motion, jump_error_code);
|
|
||||||
|
|
||||||
if (movementParams.ModifyInterpretedState)
|
|
||||||
InterpretedState.ApplyMotion(motion, movementParams);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if ((motion & (uint)CommandMask.Action) != 0)
|
|
||||||
result = WeenieError.YouCantJumpWhileInTheAir;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (movementParams.ModifyInterpretedState)
|
|
||||||
InterpretedState.ApplyMotion(motion, movementParams);
|
|
||||||
result = WeenieError.None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PhysicsObj.CurCell == null)
|
|
||||||
PhysicsObj.RemoveLinkAnimations();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Control-flow shape:
|
|
||||||
1. Gate on `contact_allows_move(motion)` (§7) — governs whether the object is grounded enough to accept this motion at all.
|
|
||||||
2. **StandingLongJump special-case**: if mid-standing-long-jump AND the incoming motion is one of {WalkForward, RunForward, SideStepRight}, the motion is applied to InterpretedState ONLY (no `PhysicsObj.DoInterpretedMotion` call, no queue entry) — the animation itself is suppressed while charging/airborne from a standing jump, but the *intent* state still updates so movement resumes correctly on landing.
|
|
||||||
3. Otherwise: `Dead` motion clears link animations first; then delegates the actual animation dispatch to `PhysicsObj.DoInterpretedMotion` (physics/animation-table layer, out of file scope); on success, computes the jump-error-code for THIS pending motion (double motion_allows_jump check: first against the incoming `motion` itself, then — only if the incoming motion isn't itself an Action AND passed — against the current `ForwardCommand`), queues it, and conditionally applies to InterpretedState.
|
|
||||||
4. If contact does NOT allow movement: Action-class motions fail outright with `YouCantJumpWhileInTheAir`; everything else silently updates InterpretedState (if requested) and returns success — i.e., non-action motions (turning, etc.) are allowed to update intent state even while airborne, just not animate/queue.
|
|
||||||
5. Unconditional tail: if `CurCell == null` (off the cell grid — e.g. despawned/uninitialized), strip link animations.
|
|
||||||
|
|
||||||
### StopInterpretedMotion (L329-365)
|
|
||||||
Structural mirror of DoInterpretedMotion for the "stop" direction: same `contact_allows_move` gate, same StandingLongJump special-case (calls `InterpretedState.RemoveMotion` instead of `ApplyMotion`), same delegate-to-PhysicsObj pattern (`PhysicsObj.StopInterpretedMotion`), same add_to_queue-with-Ready-on-success pattern, same CurCell==null tail cleanup. The "contact disallows" else-branch here has NO action-vs-non-action split (StopInterpretedMotion always just conditionally calls `RemoveMotion` and returns `WeenieError.None`) — asymmetric with DoInterpretedMotion's stricter "action motions error out while airborne" rule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. HitGround / LeaveGround (contact transition hooks)
|
|
||||||
|
|
||||||
### HitGround (L175-185)
|
|
||||||
```csharp
|
|
||||||
public void HitGround()
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return;
|
|
||||||
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
|
|
||||||
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return;
|
|
||||||
|
|
||||||
PhysicsObj.RemoveLinkAnimations();
|
|
||||||
apply_current_movement(false, true);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Guarded to creature-only (or WeenieObj==null, i.e. non-weenie-backed physics objects) AND gravity-affected objects. Strips link animations and re-applies current movement intent with `cancelMoveTo=false, allowJump=true`.
|
|
||||||
|
|
||||||
### LeaveGround (L192-208)
|
|
||||||
```csharp
|
|
||||||
public void LeaveGround()
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return;
|
|
||||||
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
|
|
||||||
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return;
|
|
||||||
|
|
||||||
var velocity = get_leave_ground_velocity();
|
|
||||||
PhysicsObj.set_local_velocity(velocity, true);
|
|
||||||
|
|
||||||
StandingLongJump = false;
|
|
||||||
JumpExtent = 0;
|
|
||||||
|
|
||||||
PhysicsObj.RemoveLinkAnimations();
|
|
||||||
apply_current_movement(false, true);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Same guard pattern as HitGround. Computes leave-ground velocity (§9), pushes it into physics as a LOCAL velocity (`set_local_velocity(velocity, true)` — second arg likely "isLocal"/"autonomous", not resolved in this file), clears the jump-charge state (`StandingLongJump=false`, `JumpExtent=0`), strips link anims, reapplies movement. **Called from `enter_default_state()` (L615)** as the terminal step of default-state setup — i.e. every newly-initialized MotionInterp starts as if it just left the ground.
|
|
||||||
|
|
||||||
MovementManager wrappers (MovementManager.cs):
|
|
||||||
- `HitGround()` (L66-73): calls `MotionInterpreter.HitGround()` THEN `MoveToManager.HitGround()` — both interpreters get the hit-ground signal, MotionInterp first.
|
|
||||||
- `LeaveGround()` (L104-110): calls `MotionInterpreter.LeaveGround()` only; has a dead commented-out line `// NoticeHandler::RecvNotice_PrevSpellSection` (retail-symbol trace left as a comment — not ported to ACE — a network-notice hook ACE apparently didn't implement here).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Jump family
|
|
||||||
|
|
||||||
### jump (L710-727)
|
|
||||||
```csharp
|
|
||||||
public WeenieError jump(float extent, int adjustStamina)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
|
|
||||||
|
|
||||||
PhysicsObj.cancel_moveto();
|
|
||||||
|
|
||||||
var result = jump_is_allowed(extent, adjustStamina);
|
|
||||||
|
|
||||||
if (result == WeenieError.None)
|
|
||||||
{
|
|
||||||
JumpExtent = extent;
|
|
||||||
PhysicsObj.set_on_walkable(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
StandingLongJump = false;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Note: on success it sets `JumpExtent` and clears the walkable flag but does NOT itself call `LeaveGround()` or apply velocity — that must happen elsewhere (likely triggered by the physics tick detecting `on_walkable==false` + `JumpExtent>0`, out of file scope). On failure it clears `StandingLongJump` (defensive — aborts an in-progress standing-long-jump charge if the actual jump call fails).
|
|
||||||
|
|
||||||
### jump_is_allowed (L742-768)
|
|
||||||
```csharp
|
|
||||||
public WeenieError jump_is_allowed(float extent, int staminaCost)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
|
|
||||||
|
|
||||||
if (WeenieObj == null && !WeenieObj.IsCreature() || !PhysicsObj.State.HasFlag(PhysicsState.Gravity) ||
|
|
||||||
PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable))
|
|
||||||
{
|
|
||||||
if (PhysicsObj.IsFullyConstrained())
|
|
||||||
return WeenieError.GeneralMovementFailure;
|
|
||||||
|
|
||||||
if (PendingMotions.Count > 1 && PendingMotions.First.Value.JumpErrorCode != 0)
|
|
||||||
return PendingMotions.First.Value.JumpErrorCode;
|
|
||||||
|
|
||||||
var jumpError = jump_charge_is_allowed();
|
|
||||||
|
|
||||||
if (jumpError == WeenieError.None)
|
|
||||||
{
|
|
||||||
jumpError = motion_allows_jump(InterpretedState.ForwardCommand);
|
|
||||||
|
|
||||||
if (jumpError == WeenieError.None && WeenieObj != null && WeenieObj.JumpStaminaCost(extent, staminaCost) == 0)
|
|
||||||
jumpError = WeenieError.GeneralMovementFailure;
|
|
||||||
}
|
|
||||||
return jumpError;
|
|
||||||
}
|
|
||||||
return WeenieError.YouCantJumpWhileInTheAir;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**LIKELY BUG (ACE-side, flag for cross-check against named-retail):** `WeenieObj == null && !WeenieObj.IsCreature()` — if `WeenieObj` actually is null, `!WeenieObj.IsCreature()` would NPE; C#'s `&&` short-circuits left-to-right so `WeenieObj == null` being true still evaluates the right side... wait, no: `&&` short-circuits so if `WeenieObj == null` is `true`, C# does NOT evaluate `!WeenieObj.IsCreature()` — correct, no NPE. But semantically this condition is almost certainly meant to be `WeenieObj != null && !WeenieObj.IsCreature()` (matching the guard pattern used identically in HitGround/LeaveGround/apply_current_movement/adjust_motion — all of which use `WeenieObj != null && !WeenieObj.IsCreature()`). As written, `WeenieObj == null && !WeenieObj.IsCreature()` can **never be true** (if WeenieObj==null is true, the whole condition short-circuits false because `&&` needs the null-check false... actually WeenieObj==null must be TRUE for this branch, and if it's true the right operand isn't even evaluated, so `WeenieObj==null && [anything]` reduces to just needing `WeenieObj==null` — no, `A && B` requires BOTH true; if A is true B is still checked. If A is true (WeenieObj IS null) and B accesses `WeenieObj.IsCreature()` on a null WeenieObj, this WOULD NPE.** This is a genuine apparent typo vs. the established pattern elsewhere in the same file (should be `WeenieObj != null && !WeenieObj.IsCreature()`) — **flag as a divergence risk to verify against `docs/research/named-retail/` before porting this exact condition to acdream.**
|
|
||||||
- Entry condition: allowed to even check jump-legality if EITHER not-a-creature-weenie, OR not gravity-affected, OR (already in Contact+OnWalkable state).
|
|
||||||
- `IsFullyConstrained()` → GeneralMovementFailure.
|
|
||||||
- **Queue-lookahead check**: if there's more than one pending motion AND the head's precomputed `JumpErrorCode != 0`, return that cached error immediately (short-circuit — reuses the jump-eligibility computed back when that motion was queued, rather than recomputing).
|
|
||||||
- Otherwise chains `jump_charge_is_allowed()` → `motion_allows_jump(ForwardCommand)` → `WeenieObj.JumpStaminaCost(extent, staminaCost) == 0` (stamina check, external to this file) → `GeneralMovementFailure` if stamina call returns 0.
|
|
||||||
- If the outer gate fails, returns `YouCantJumpWhileInTheAir` (airborne + gravity + not a special-cased non-creature).
|
|
||||||
|
|
||||||
### jump_charge_is_allowed (L729-740)
|
|
||||||
```csharp
|
|
||||||
public WeenieError jump_charge_is_allowed()
|
|
||||||
{
|
|
||||||
if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent))
|
|
||||||
return WeenieError.CantJumpLoadedDown;
|
|
||||||
|
|
||||||
var forward = InterpretedState.ForwardCommand;
|
|
||||||
|
|
||||||
if (forward == (uint)MotionCommand.Fallen || forward >= (uint)MotionCommand.Crouch && forward <= (uint)MotionCommand.Sleeping)
|
|
||||||
return WeenieError.YouCantJumpFromThisPosition;
|
|
||||||
|
|
||||||
return WeenieError.None;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`WeenieObj.CanJump(JumpExtent)` is presumably an encumbrance/burden check (name: "loaded down"). Blocks jump-charging while `Fallen` or in the [Crouch..Sleeping] MotionCommand range (a contiguous enum-range test — same idiom used throughout this file for "in one of these seated/prone states").
|
|
||||||
|
|
||||||
### charge_jump (L564-582)
|
|
||||||
```csharp
|
|
||||||
public int charge_jump()
|
|
||||||
{
|
|
||||||
if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent))
|
|
||||||
return 0x49;
|
|
||||||
|
|
||||||
var forward = InterpretedState.ForwardCommand;
|
|
||||||
|
|
||||||
if (forward == (uint)MotionCommand.Falling || forward >= (uint)MotionCommand.Crouch && forward < (uint)MotionCommand.Sleeping)
|
|
||||||
return 0x48;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && forward == (uint)MotionCommand.Ready &&
|
|
||||||
InterpretedState.SideStepCommand == 0 && InterpretedState.TurnCommand == 0)
|
|
||||||
{
|
|
||||||
StandingLongJump = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Returns raw `int` error codes (`0x49`, `0x48`, `0`) rather than `WeenieError` enum — a leftover-looking retail-style raw-hresult-ish return convention (likely these hex values ARE `WeenieError` underlying ints but the method signature wasn't converted — worth checking `0x49`/`0x48` against the `WeenieError` enum values for `CantJumpLoadedDown`/`YouCantJumpFromThisPosition` equivalents). **Note the range-check here uses `Falling` (not `Fallen` as in `jump_charge_is_allowed`) and `< Sleeping` (exclusive) vs `jump_charge_is_allowed`'s `<= Sleeping` (inclusive)** — these two "similar" gating functions have subtly different boundary conditions; flag for retail cross-check, this looks like it could be an ACE transcription inconsistency OR an intentional retail distinction between "starting to charge a jump" vs "already mid-charge-and-issuing-the-actual-jump."
|
|
||||||
The actual `StandingLongJump = true` side-effect only fires when: grounded+walkable, current forward command is exactly `Ready` (standing still), AND no sidestep/turn in progress — i.e. this is the "player pressed-and-held jump while standing still" detector that arms the long-jump-charge special path used throughout DoInterpretedMotion/StopInterpretedMotion/apply_interpreted_movement.
|
|
||||||
|
|
||||||
### get_jump_v_z (L634-652)
|
|
||||||
```csharp
|
|
||||||
public float get_jump_v_z()
|
|
||||||
{
|
|
||||||
if (JumpExtent < PhysicsGlobals.EPSILON) return 0.0f;
|
|
||||||
|
|
||||||
var extent = JumpExtent;
|
|
||||||
if (extent > 1.0f) extent = 1.0f;
|
|
||||||
|
|
||||||
if (WeenieObj == null) return 10.0f;
|
|
||||||
|
|
||||||
float vz = extent;
|
|
||||||
if (WeenieObj.InqJumpVelocity(extent, out vz))
|
|
||||||
return vz;
|
|
||||||
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Clamps `JumpExtent` to [something-above-EPSILON, 1.0], delegates the actual extent→velocity curve to `WeenieObj.InqJumpVelocity` (out of file scope — presumably reads jump skill). Fallback of `10.0f` if there's no WeenieObj at all (non-weenie physics object jumping at max power, e.g. test/editor objects). `float vz = extent;` is a dead initializer — immediately overwritten by the `out vz` param if `InqJumpVelocity` returns true, else the method returns `0.0f` on the false path (never returns the dead-initialized `extent` value).
|
|
||||||
|
|
||||||
### get_leave_ground_velocity (L654-663)
|
|
||||||
```csharp
|
|
||||||
public Vector3 get_leave_ground_velocity()
|
|
||||||
{
|
|
||||||
var velocity = get_state_velocity();
|
|
||||||
velocity.Z = get_jump_v_z();
|
|
||||||
|
|
||||||
if (Vec.IsZero(velocity))
|
|
||||||
velocity = PhysicsObj.Position.GlobalToLocalVec(PhysicsObj.Velocity);
|
|
||||||
|
|
||||||
return velocity;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Composes horizontal velocity from current interpreted-state motion (§9 `get_state_velocity`) with vertical velocity from the jump charge, UNLESS the composed vector is exactly zero — in which case it falls back to converting the physics object's actual (global) velocity into local space. This fallback matters for e.g. falling-off-a-ledge (no jump input, no WASD, but the object still has downward/lateral velocity from having walked off an edge) vs. a genuine standing-still jump.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. HitGround/MotionDone chain — full call graph (cross-file, MotionTableManager.cs)
|
|
||||||
|
|
||||||
The actual "animation finished playing" signal originates in `MotionTableManager` (a SEPARATE class from `MotionInterp`, owned by `PartArray`, NOT by `MovementManager`):
|
|
||||||
|
|
||||||
```
|
|
||||||
MotionTableManager.AnimationDone(bool success) [L28-61]
|
|
||||||
MotionTableManager.CheckForCompletedMotions() [L63-85]
|
|
||||||
-> PhysicsObj.MotionDone(motionID, success) [PhysicsObj.cs L899-903]
|
|
||||||
-> MovementManager.MotionDone(motion, success) [MovementManager.cs L118-122]
|
|
||||||
-> MotionInterpreter.MotionDone(success) [MotionInterp.cs L210-234]
|
|
||||||
-> PhysicsObj.WeenieObj.OnMotionDone(motionID, success) [parallel notify, weenie-layer]
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `AnimationDone` and `CheckForCompletedMotions` in MotionTableManager independently walk `PendingAnimations` (a `LinkedList<AnimNode>`, the animation-table-layer's OWN pending queue — distinct from `MotionInterp.PendingMotions`) and call `PhysicsObj.MotionDone` once per completed entry, followed immediately by `WeenieObj.OnMotionDone` — i.e. **every completed motion fires two independent listeners**: the MotionInterp queue-pop (state-machine bookkeeping) and the WeenieObject notification (game-logic reaction, e.g. quest triggers, sound cues — out of scope here).
|
|
||||||
|
|
||||||
`CheckForCompletedMotions()` reachability:
|
|
||||||
```
|
|
||||||
MotionInterp.PerformMovement(mvs) [L260] -- called after EVERY movement dispatch (Do/Stop/StopCompletely)
|
|
||||||
-> PhysicsObj.CheckForCompletedMotions() [PhysicsObj.cs L296-300]
|
|
||||||
-> PartArray.CheckForCompletedMotions() [PartArray.cs L72-76]
|
|
||||||
-> MotionTableManager.CheckForCompletedMotions() [L63-85]
|
|
||||||
```
|
|
||||||
So every `MovementManager.PerformMovement` → `MotionInterp.PerformMovement` call (§10) ends with an immediate synchronous drain of any already-finished animations at the head of `PendingAnimations` — this is how a 0-frame-length animation (e.g. an instant `Ready` transition) gets its `MotionDone` fired same-tick rather than waiting for the next animation-tick callback.
|
|
||||||
|
|
||||||
`MotionTableManager.UseTime()` (L158-161) also calls `CheckForCompletedMotions()` — this is the periodic (per-tick, presumably driven by `MovementManager.UseTime()` → `MoveToManager.UseTime()`... **NOTE:** `MovementManager.UseTime()` (MovementManager.cs L176-179) only forwards to `MoveToManager.UseTime()`, NOT to `MotionInterpreter`/`MotionTableManager` — so `MotionTableManager.UseTime()`'s caller is NOT in this file pair; it must be driven directly by `PartArray`/`Sequence`'s own per-tick update, bypassing MovementManager entirely).
|
|
||||||
|
|
||||||
`AnimationDone` (L28-61) is the OTHER path into the same `PhysicsObj.MotionDone` call — driven by whatever animation-tick/callback system invokes it directly (not visible in these two files; likely `Sequence`/`AFrame` playback completion). Its loop differs from `CheckForCompletedMotions` in that it decrements a running `AnimationCounter` against each node's `NumAnims` rather than checking `NumAnims != 0` directly — i.e. `AnimationDone` is the incremental "one more anim-frame-group finished" tick, while `CheckForCompletedMotions` is the "resync/drain everything already at zero" batch pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. apply_raw / apply_interpreted / apply_current_movement (state → animation dispatch)
|
|
||||||
|
|
||||||
### apply_current_movement (L430-438) — dispatcher
|
|
||||||
```csharp
|
|
||||||
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null || !Initted) return;
|
|
||||||
|
|
||||||
if (WeenieObj != null && !WeenieObj.IsCreature() || !PhysicsObj.movement_is_autonomous())
|
|
||||||
apply_interpreted_movement(cancelMoveTo, allowJump);
|
|
||||||
else
|
|
||||||
apply_raw_movement(cancelMoveTo, allowJump);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Routes to raw-vs-interpreted based on: non-creature-weenie OR not-autonomous-movement → interpreted path; creature AND autonomous movement → raw path. (`movement_is_autonomous()` presumably distinguishes server-driven/scripted motion from player-input-driven motion — out of file scope.) Requires `Initted == true` (set at the tail of `enter_default_state`, §11) — calls before init are no-ops.
|
|
||||||
|
|
||||||
### apply_raw_movement (L506-523)
|
|
||||||
```csharp
|
|
||||||
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return;
|
|
||||||
|
|
||||||
InterpretedState.CurrentStyle = RawState.CurrentStyle;
|
|
||||||
InterpretedState.ForwardCommand = RawState.ForwardCommand;
|
|
||||||
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
|
|
||||||
InterpretedState.SideStepCommand = RawState.SideStepCommand;
|
|
||||||
InterpretedState.SideStepSpeed = RawState.SideStepSpeed;
|
|
||||||
InterpretedState.TurnCommand = RawState.TurnCommand;
|
|
||||||
InterpretedState.TurnSpeed = RawState.TurnSpeed;
|
|
||||||
|
|
||||||
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
|
|
||||||
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
|
|
||||||
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
|
|
||||||
|
|
||||||
apply_interpreted_movement(cancelMoveTo, allowJump);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Copies all 7 raw-state fields verbatim into interpreted-state, THEN runs `adjust_motion` (§12) independently over each of the three command/speed pairs (Forward, SideStep, Turn) with their respective per-axis hold-keys, THEN falls through to `apply_interpreted_movement`. This is the "translate the low-level input intent into the higher-level interpreted/animation intent" step — walk→run promotion, backward-inversion, sidestep animation-rate scaling all happen here per-axis.
|
|
||||||
|
|
||||||
### apply_interpreted_movement (L440-504)
|
|
||||||
```csharp
|
|
||||||
public void apply_interpreted_movement(bool cancelMoveTo, bool allowJump)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return;
|
|
||||||
|
|
||||||
var movementParams = new MovementParameters();
|
|
||||||
movementParams.SetHoldKey = false;
|
|
||||||
movementParams.ModifyInterpretedState = false;
|
|
||||||
movementParams.CancelMoveTo = cancelMoveTo;
|
|
||||||
movementParams.DisableJumpDuringLink = !allowJump;
|
|
||||||
|
|
||||||
if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
|
|
||||||
MyRunRate = InterpretedState.ForwardSpeed;
|
|
||||||
|
|
||||||
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams);
|
|
||||||
|
|
||||||
if (contact_allows_move(InterpretedState.ForwardCommand))
|
|
||||||
{
|
|
||||||
if (!StandingLongJump)
|
|
||||||
{
|
|
||||||
movementParams.Speed = InterpretedState.ForwardSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams);
|
|
||||||
|
|
||||||
if (InterpretedState.SideStepCommand != 0)
|
|
||||||
{
|
|
||||||
movementParams.Speed = InterpretedState.SideStepSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.SideStepCommand, movementParams);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
movementParams.Speed = 1.0f;
|
|
||||||
DoInterpretedMotion((uint)MotionCommand.Ready, movementParams);
|
|
||||||
StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
movementParams.Speed = 1.0f;
|
|
||||||
DoInterpretedMotion((uint)MotionCommand.Falling, movementParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InterpretedState.TurnCommand != 0)
|
|
||||||
{
|
|
||||||
movementParams.Speed = InterpretedState.TurnSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.TurnCommand, movementParams);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var result = PhysicsObj.StopInterpretedMotion((uint)MotionCommand.TurnRight, movementParams);
|
|
||||||
if (result == WeenieError.None)
|
|
||||||
{
|
|
||||||
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None);
|
|
||||||
if (movementParams.ModifyInterpretedState)
|
|
||||||
InterpretedState.RemoveMotion((uint)MotionCommand.TurnRight);
|
|
||||||
}
|
|
||||||
if (PhysicsObj.CurCell == null)
|
|
||||||
PhysicsObj.RemoveLinkAnimations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
This is the per-axis re-dispatch of the CURRENT interpreted state as fresh `DoInterpretedMotion`/`StopInterpretedMotion` calls (used both after raw→interpreted translation, and directly whenever contact/gravity transitions need to re-trigger animation — e.g. HitGround/LeaveGround/SetPhysicsObject/SetWeenieObject/SetHoldKey/set_hold_run all funnel here via `apply_current_movement`). Sequence:
|
|
||||||
1. `CurrentStyle` motion (stance) always re-dispatched first.
|
|
||||||
2. `MyRunRate` cache updated from `ForwardSpeed` whenever currently running (side-channel — persists the "last known run speed" independent of whatever WeenieObj.InqRunRate later reports).
|
|
||||||
3. If contact allows movement: normal case re-dispatches Forward + (SideStep OR explicit SideStepRight-stop); StandingLongJump case instead forces Ready + explicit SideStepRight-stop (suppresses forward/sidestep animation while charging a standing jump, matching the earlier note in DoInterpretedMotion).
|
|
||||||
4. If contact does NOT allow movement: unconditionally dispatches `Falling` (Speed=1.0) regardless of what ForwardCommand was.
|
|
||||||
5. Turn is handled as its own independent branch — Turn re-dispatches like Forward/SideStep, but the "stop" path when TurnCommand==0 is inlined directly here (calls `PhysicsObj.StopInterpretedMotion` — the PHYSICS layer method, not `this.StopInterpretedMotion` — and duplicates the add_to_queue/RemoveMotion/RemoveLinkAnimations bookkeeping inline rather than delegating to `this.StopInterpretedMotion`). This inline duplication vs delegating is a structural oddity worth flagging — every other "stop with bookkeeping" path in this file goes through the member `StopInterpretedMotion` method, but this one open-codes it against `PhysicsObj.StopInterpretedMotion` directly.
|
|
||||||
|
|
||||||
### get_state_velocity (L678-700) — used by get_leave_ground_velocity (§7)
|
|
||||||
```csharp
|
|
||||||
public Vector3 get_state_velocity()
|
|
||||||
{
|
|
||||||
var velocity = Vector3.Zero;
|
|
||||||
|
|
||||||
if (InterpretedState.SideStepCommand == (uint)MotionCommand.SideStepRight)
|
|
||||||
velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed;
|
|
||||||
if (InterpretedState.ForwardCommand == (uint)MotionCommand.WalkForward)
|
|
||||||
velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed;
|
|
||||||
else if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
|
|
||||||
velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed;
|
|
||||||
|
|
||||||
var rate = MyRunRate;
|
|
||||||
if (WeenieObj != null) WeenieObj.InqRunRate(ref rate);
|
|
||||||
|
|
||||||
var maxSpeed = RunAnimSpeed * rate;
|
|
||||||
if (velocity.Length() > maxSpeed)
|
|
||||||
{
|
|
||||||
velocity = Vector3.Normalize(velocity);
|
|
||||||
velocity *= maxSpeed;
|
|
||||||
}
|
|
||||||
return velocity;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
X = lateral (sidestep) component, Y = forward component (local space — X/Y here are NOT world axes). Clamped to a max speed derived from `RunAnimSpeed * runRate` — note `WeenieObj.InqRunRate(ref rate)`'s return value is discarded here (unlike `get_adjusted_max_speed`/`get_max_speed` which check the bool return and fall back to `MyRunRate` only if it returns false) — `rate` is pre-seeded with `MyRunRate` and then `InqRunRate` is allowed to overwrite it unconditionally regardless of success/failure return.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. PerformMovement (MotionInterp) vs PerformMovement (MovementManager)
|
|
||||||
|
|
||||||
### MotionInterp.PerformMovement (L236-262)
|
|
||||||
```csharp
|
|
||||||
public WeenieError PerformMovement(MovementStruct mvs)
|
|
||||||
{
|
|
||||||
var result = WeenieError.None;
|
|
||||||
switch (mvs.Type)
|
|
||||||
{
|
|
||||||
case MovementType.RawCommand: result = DoMotion(mvs.Motion, mvs.Params); break;
|
|
||||||
case MovementType.InterpretedCommand: result = DoInterpretedMotion(mvs.Motion, mvs.Params); break;
|
|
||||||
case MovementType.StopRawCommand: result = StopMotion(mvs.Motion, mvs.Params); break;
|
|
||||||
case MovementType.StopInterpretedCommand: result = StopInterpretedMotion(mvs.Motion, mvs.Params); break;
|
|
||||||
case MovementType.StopCompletely: result = StopCompletely(); break;
|
|
||||||
default: return WeenieError.GeneralMovementFailure;
|
|
||||||
}
|
|
||||||
PhysicsObj.CheckForCompletedMotions();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Dispatch table over `MovementType`; unconditionally drains completed motions (§8) after every dispatched call (except the `default`/unknown-type early-return, which skips the drain).
|
|
||||||
|
|
||||||
### MovementManager.PerformMovement (L124-157)
|
|
||||||
```csharp
|
|
||||||
public WeenieError PerformMovement(MovementStruct mvs)
|
|
||||||
{
|
|
||||||
PhysicsObj.set_active(true);
|
|
||||||
|
|
||||||
switch (mvs.Type)
|
|
||||||
{
|
|
||||||
case MovementType.RawCommand:
|
|
||||||
case MovementType.InterpretedCommand:
|
|
||||||
case MovementType.StopRawCommand:
|
|
||||||
case MovementType.StopInterpretedCommand:
|
|
||||||
case MovementType.StopCompletely:
|
|
||||||
if (MotionInterpreter == null)
|
|
||||||
{
|
|
||||||
MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj);
|
|
||||||
if (PhysicsObj != null) MotionInterpreter.enter_default_state();
|
|
||||||
}
|
|
||||||
return MotionInterpreter.PerformMovement(mvs);
|
|
||||||
|
|
||||||
case MovementType.MoveToObject:
|
|
||||||
case MovementType.MoveToPosition:
|
|
||||||
case MovementType.TurnToObject:
|
|
||||||
case MovementType.TurnToHeading:
|
|
||||||
if (MoveToManager == null)
|
|
||||||
MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj);
|
|
||||||
return MoveToManager.PerformMovement(mvs);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return WeenieError.GeneralMovementFailure;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Outer layer: unconditionally activates the physics object (`set_active(true)`) BEFORE dispatch, lazy-constructs `MotionInterpreter` (with `enter_default_state()`) on first use for the motion-command group, lazy-constructs `MoveToManager` on first use for the moveto/turnto group. This is the true entry point most game-logic code calls — `MotionInterp.PerformMovement` is only reachable through this wrapper (or directly if something already holds a `MotionInterp` reference, e.g. via `get_minterp()`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Lifecycle: enter_default_state / HandleExitWorld / HandleEnterWorld
|
|
||||||
|
|
||||||
### MotionInterp.enter_default_state (L604-616)
|
|
||||||
```csharp
|
|
||||||
public void enter_default_state()
|
|
||||||
{
|
|
||||||
RawState = new RawMotionState();
|
|
||||||
InterpretedState = new InterpretedMotionState();
|
|
||||||
|
|
||||||
PhysicsObj.InitializeMotionTables();
|
|
||||||
PendingMotions = new LinkedList<MotionNode>(); // ??
|
|
||||||
|
|
||||||
add_to_queue(0, (uint)MotionCommand.Ready, 0);
|
|
||||||
|
|
||||||
Initted = true;
|
|
||||||
LeaveGround();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Note the ACE dev's own `// ??` comment on the `PendingMotions` reset — another self-flagged uncertainty spot (worth checking retail decomp for whether PendingMotions is genuinely reset here or whether retail preserves/asserts-empty). Order: fresh Raw/Interpreted state objects → physics-layer motion table init → fresh pending-motion queue → seed the queue with one `Ready` motion (contextID=0, jumpErrorCode=`WeenieError.None`=0) → flip `Initted=true` → call `LeaveGround()` (§6) which itself is now unlocked since `Initted` gates nothing in LeaveGround directly, but LeaveGround calls `apply_current_movement` which DOES gate on `Initted`.
|
|
||||||
|
|
||||||
### MovementManager.EnterDefaultState (L38-46)
|
|
||||||
```csharp
|
|
||||||
public void EnterDefaultState()
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return;
|
|
||||||
if (MotionInterpreter == null)
|
|
||||||
MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj);
|
|
||||||
MotionInterpreter.enter_default_state();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Thin lazy-construct + delegate wrapper.
|
|
||||||
|
|
||||||
### HandleExitWorld — two versions
|
|
||||||
**MotionInterp.HandleExitWorld (L160-173):**
|
|
||||||
```csharp
|
|
||||||
public void HandleExitWorld()
|
|
||||||
{
|
|
||||||
foreach (var pendingMotion in PendingMotions)
|
|
||||||
{
|
|
||||||
if (PhysicsObj != null && (pendingMotion.Motion & (uint)CommandMask.Action) != 0)
|
|
||||||
{
|
|
||||||
PhysicsObj.unstick_from_object();
|
|
||||||
InterpretedState.RemoveAction();
|
|
||||||
RawState.RemoveAction();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PendingMotions.Clear();
|
|
||||||
if (PhysicsObj != null) PhysicsObj.IsAnimating = false;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Iterates ALL pending motions (not just the head, unlike `MotionDone`) — for EVERY pending motion that has the Action bit set, unsticks + pops an action off both state stacks (this will over-pop if multiple pending Action motions exist simultaneously and InterpretedState/RawState only track a bounded action stack — worth checking `GetNumActions()`'s cap of 6 against how many action-removals this loop could trigger). Then clears the whole queue and force-sets `IsAnimating = false` directly (bypassing the `Count > 0` recompute that `MotionDone` uses).
|
|
||||||
|
|
||||||
**MovementManager.HandleExitWorld (L54-58):** thin delegate, `if (MotionInterpreter != null) MotionInterpreter.HandleExitWorld();` — no MoveToManager involvement (contrast with `HitGround` which drives both).
|
|
||||||
|
|
||||||
### MovementManager.HandleEnterWorld (L48-52)
|
|
||||||
```csharp
|
|
||||||
public void HandleEnterWorld()
|
|
||||||
{
|
|
||||||
//if (MotionInterpreter != null)
|
|
||||||
//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Entirely commented out — dead/no-op in ACE. The commented reference to `NoticeHandler.RecvNotice_PrevSpellSelection` is a retail-symbol breadcrumb (spell-selection restore notice on world-enter) that ACE chose not to implement. **Flag for acdream: if named-retail decomp confirms this notice is meaningful (e.g. restoring a previously-selected spell/combat-style UI state on relog), this is a genuine ACE gap, not just dead weight — acdream may need to port it from decomp directly since ACE has nothing to copy.**
|
|
||||||
|
|
||||||
Also note: `MotionTableManager.HandleEnterWorld(Sequence sequence)` (MotionTableManager.cs L103-108) is a DIFFERENT, unrelated `HandleEnterWorld` on a different class — it drains `PendingAnimations` via repeated `AnimationDone(false)` calls and clears link animations on the sequence. Not reachable from `MovementManager.HandleEnterWorld` (which is a no-op) — must be invoked from elsewhere (PartArray or the weenie enter-world path, out of scope).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. adjust_motion / apply_run_to_command (raw→interpreted transform helpers)
|
|
||||||
|
|
||||||
### adjust_motion (L394-428)
|
|
||||||
```csharp
|
|
||||||
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
|
|
||||||
{
|
|
||||||
if (WeenieObj != null && !WeenieObj.IsCreature())
|
|
||||||
return;
|
|
||||||
|
|
||||||
switch (motion)
|
|
||||||
{
|
|
||||||
case (uint)MotionCommand.RunForward:
|
|
||||||
return;
|
|
||||||
|
|
||||||
case (uint)MotionCommand.WalkBackwards:
|
|
||||||
motion = (uint)MotionCommand.WalkForward;
|
|
||||||
speed *= -BackwardsFactor; // -0.65
|
|
||||||
break;
|
|
||||||
|
|
||||||
case (uint)MotionCommand.TurnLeft:
|
|
||||||
motion = (uint)MotionCommand.TurnRight;
|
|
||||||
speed *= -1.0f;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case (uint)MotionCommand.SideStepLeft:
|
|
||||||
motion = (uint)MotionCommand.SideStepRight;
|
|
||||||
speed *= -1.0f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (motion == (uint)MotionCommand.SideStepRight)
|
|
||||||
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25) = 1.248
|
|
||||||
|
|
||||||
if (holdKey == HoldKey.Invalid)
|
|
||||||
holdKey = RawState.CurrentHoldKey;
|
|
||||||
|
|
||||||
if (holdKey == HoldKey.Run)
|
|
||||||
apply_run_to_command(ref motion, ref speed);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Canonicalizes "left/backward" variants into their "right/forward" counterparts with negated speed (single-animation-per-axis-direction retail convention: there's no separate WalkBackwards/TurnLeft/SideStepLeft animation state, just the canonical one played at negative speed). `WalkBackwards` gets an EXTRA `-BackwardsFactor` (0.65) scalar on top of the sign flip — backward walking is intentionally slower than forward. `SideStepRight` always gets rescaled by `SidestepFactor * (WalkAnimSpeed/SidestepAnimSpeed)` ≈ 1.248 regardless of hold-key, to convert a walk-speed-denominated input into the sidestep animation's own speed scale. `HoldKey.Invalid` falls back to whatever `RawState.CurrentHoldKey` currently is; if the resolved hold key is `Run`, defers to `apply_run_to_command` for the walk→run promotion.
|
|
||||||
|
|
||||||
### apply_run_to_command (L525-562)
|
|
||||||
```csharp
|
|
||||||
public void apply_run_to_command(ref uint motion, ref float speed)
|
|
||||||
{
|
|
||||||
var speedMod = 1.0f;
|
|
||||||
|
|
||||||
if (WeenieObj != null)
|
|
||||||
{
|
|
||||||
var runFactor = 0.0f;
|
|
||||||
if (WeenieObj.InqRunRate(ref runFactor))
|
|
||||||
speedMod = runFactor;
|
|
||||||
else
|
|
||||||
speedMod = MyRunRate;
|
|
||||||
}
|
|
||||||
switch (motion)
|
|
||||||
{
|
|
||||||
case (uint)MotionCommand.WalkForward:
|
|
||||||
if (speed > 0.0f)
|
|
||||||
motion = (uint)MotionCommand.RunForward;
|
|
||||||
speed *= speedMod;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case (uint)MotionCommand.TurnRight:
|
|
||||||
speed *= RunTurnFactor; // 1.5
|
|
||||||
break;
|
|
||||||
|
|
||||||
case (uint)MotionCommand.SideStepRight:
|
|
||||||
speed *= speedMod;
|
|
||||||
if (MaxSidestepAnimRate < Math.Abs(speed))
|
|
||||||
speed = speed > 0.0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`WalkForward` promotes to `RunForward` ONLY if `speed > 0.0f` — i.e. a negative-speed "walk forward" (which per `adjust_motion` is actually a canonicalized WalkBackwards) does NOT get promoted to running even while the Run hold-key is active; backward movement stays at walk-animation regardless of hold-key. `TurnRight` while running gets a flat 1.5x speed multiplier (turns faster while running). `SideStepRight` gets the run-rate multiplier applied AND is then hard-clamped to ±`MaxSidestepAnimRate` (3.0) — this clamp exists ONLY in the run-path, not in the base `adjust_motion` sidestep scaling, meaning sidestep-while-walking is unclamped but sidestep-while-running is capped.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. contact_allows_move / motion_allows_jump / is_standing_still (gating predicates)
|
|
||||||
|
|
||||||
### contact_allows_move (L584-602)
|
|
||||||
```csharp
|
|
||||||
public bool contact_allows_move(uint motion)
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null) return false;
|
|
||||||
|
|
||||||
if (motion == (uint)MotionCommand.Dead || motion == (uint)MotionCommand.Falling ||
|
|
||||||
motion >= (uint)MotionCommand.TurnRight && motion <= (uint)MotionCommand.TurnLeft)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (WeenieObj != null && !WeenieObj.IsCreature())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity))
|
|
||||||
return true;
|
|
||||||
if (!PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
|
|
||||||
return false;
|
|
||||||
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Always-allowed motion classes: `Dead`, `Falling`, and the [TurnRight..TurnLeft] enum range (turning is always allowed regardless of contact — matches `apply_interpreted_movement`'s independent Turn-branch handling). Non-creature weenies bypass all contact gating. Non-gravity objects bypass gating. Otherwise: requires BOTH `Contact` AND `OnWalkable` transient flags to allow movement; `Contact` without `OnWalkable` (e.g. touching a wall/ceiling, not a floor) explicitly disallows.
|
|
||||||
|
|
||||||
### motion_allows_jump (L770-779)
|
|
||||||
```csharp
|
|
||||||
public WeenieError motion_allows_jump(uint substate)
|
|
||||||
{
|
|
||||||
if (substate >= (uint)MotionCommand.Reload && substate <= (uint)MotionCommand.Pickup ||
|
|
||||||
substate >= (uint)MotionCommand.TripleThrustLow && substate <= (uint)MotionCommand.MagicPowerUp07Purple ||
|
|
||||||
substate >= (uint)MotionCommand.MagicPowerUp01 && substate <= (uint)MotionCommand.MagicPowerUp10 ||
|
|
||||||
substate >= (uint)MotionCommand.Crouch && substate <= (uint)MotionCommand.Sleeping ||
|
|
||||||
substate >= (uint)MotionCommand.AimLevel && substate <= (uint)MotionCommand.MagicPray ||
|
|
||||||
substate == (uint)MotionCommand.Falling)
|
|
||||||
{
|
|
||||||
return WeenieError.YouCantJumpFromThisPosition;
|
|
||||||
}
|
|
||||||
return WeenieError.None;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Five contiguous MotionCommand enum ranges + one exact match, ALL block jumping: [Reload..Pickup], [TripleThrustLow..MagicPowerUp07Purple], [MagicPowerUp01..MagicPowerUp10], [Crouch..Sleeping], [AimLevel..MagicPray], and exactly `Falling`. This depends entirely on the ORDER of values in the `MotionCommand` enum matching retail's numeric ordering — **any acdream MotionCommand enum that doesn't preserve retail's exact ordinal layout for these ranges will silently break this range-check port.** Called from: `DoInterpretedMotion` (jump-error precompute, twice — against incoming motion AND against ForwardCommand), `StopCompletely` (against ForwardCommand before reset), `jump_is_allowed` (against ForwardCommand), `move_to_interpreted_state` (against ForwardCommand, to gate `allowJump`).
|
|
||||||
|
|
||||||
### is_standing_still (L702-708)
|
|
||||||
```csharp
|
|
||||||
public bool is_standing_still()
|
|
||||||
{
|
|
||||||
return PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) &&
|
|
||||||
InterpretedState.ForwardCommand == (uint)MotionCommand.Ready &&
|
|
||||||
InterpretedState.SideStepCommand == 0 &&
|
|
||||||
InterpretedState.TurnCommand == 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Same predicate shape as the `StandingLongJump` arm-condition inlined in `charge_jump()` (§7) — grounded+walkable, Ready forward, zero sidestep, zero turn. Not called anywhere within these two files (public API surface for external callers, e.g. WeenieObj/game-logic layer, out of scope).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Misc small methods
|
|
||||||
|
|
||||||
- **InqStyle (L187-190):** `return InterpretedState.CurrentStyle;` — trivial accessor, returned as `long` despite `CurrentStyle` presumably being `uint` (implicit widening).
|
|
||||||
- **SetHoldKey (L274-287):** only handles `HoldKey.None` explicitly — if the NEW key is `None` AND the current key was `Run`, clears to `None` and re-applies movement; the `HoldKey.Run` case (or others) falls through the switch with NO explicit case, meaning setting the hold key TO `Run` via `SetHoldKey` is a silent no-op unless `key == RawState.CurrentHoldKey` returns early first — the only way `HoldKey.Run` actually gets set appears to be `set_hold_run` (below), not `SetHoldKey`. Early-return guard: `if (key == RawState.CurrentHoldKey) return;` — no-op if unchanged.
|
|
||||||
- **set_hold_run (L826-833):**
|
|
||||||
```csharp
|
|
||||||
public void set_hold_run(int val, bool cancelMoveTo)
|
|
||||||
{
|
|
||||||
if ((val == 0) != (RawState.CurrentHoldKey != HoldKey.Run))
|
|
||||||
{
|
|
||||||
RawState.CurrentHoldKey = val != 0 ? HoldKey.Run : HoldKey.None;
|
|
||||||
apply_current_movement(cancelMoveTo, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
XOR-style guard: `(val==0) != (CurrentHoldKey != Run)` is true exactly when `val` and the "is currently Run" state disagree, i.e. this is a real toggle. Sets `HoldKey.Run` or `HoldKey.None` (never `Invalid` or others) and re-applies movement with `allowJump=true` unconditionally.
|
|
||||||
- **SetPhysicsObject (L289-293) / SetWeenieObject (L295-299):** both simply assign the field then call `apply_current_movement(true, true)` (force cancelMoveTo=true, allowJump=true) — any (re)binding of either owning object triggers a full movement re-application.
|
|
||||||
- **get_adjusted_max_speed (L618-632):**
|
|
||||||
```csharp
|
|
||||||
public float get_adjusted_max_speed()
|
|
||||||
{
|
|
||||||
var rate = 1.0f;
|
|
||||||
if (WeenieObj != null)
|
|
||||||
{
|
|
||||||
if (!WeenieObj.InqRunRate(ref rate))
|
|
||||||
rate = MyRunRate;
|
|
||||||
}
|
|
||||||
if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
|
|
||||||
rate = InterpretedState.ForwardSpeed / CurrentSpeedFactor;
|
|
||||||
return rate * 4.0f;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Note: if currently running, `rate` is OVERWRITTEN entirely by `ForwardSpeed / CurrentSpeedFactor` — the `WeenieObj.InqRunRate`/`MyRunRate` lookup above becomes dead work in that branch. `4.0f` is presumably `RunAnimSpeed` inlined rather than referencing the constant (magic-number duplication — flag for acdream: use the named constant, don't inline `4.0f`).
|
|
||||||
- **get_max_speed (L665-676):** simpler sibling — `RunAnimSpeed * rate` where `rate` comes from `InqRunRate`/`MyRunRate` fallback, no ForwardCommand override. Two "max speed" methods with different semantics (adjusted = "current effective speed accounting for what's actually playing," max = "theoretical max run speed") — both present, worth checking retail naming/usage split before porting just one.
|
|
||||||
- **motions_pending (L784-787) — MotionInterp:** `return PendingMotions.Count > 0;` with an XML doc comment noting `PhysicsObj.IsAnimating` is the faster equivalent. **PhysicsObj.motions_pending() (PhysicsObj.cs L4244-4247)** is a DIFFERENT, simpler method: `return IsAnimating;` directly — i.e. PhysicsObj's own `motions_pending()` already takes the fast path the MotionInterp doc-comment recommends; **MovementManager.motions_pending() (L195-200)** delegates to `MotionInterpreter.motions_pending()` (the SLOW `Count>0` path, not the fast `IsAnimating` path) when `MotionInterpreter != null`, else returns `false` — so the three `motions_pending()` overloads across PhysicsObj/MovementManager/MotionInterp are NOT all equivalent-cost, and MovementManager's public-facing one takes the slower route.
|
|
||||||
- **move_to_interpreted_state (L789-824):** syncs an externally-supplied `InterpretedMotionState` (e.g. from a network update / DR snapshot) into this instance. Computes `allowJump` from the OLD `ForwardCommand` (before `copy_movement_from` overwrites it) via `motion_allows_jump(...) == WeenieError.None`. Replays the incoming state's `Actions` list through a **sequence-stamp wraparound comparator**: `currentStamp = action.Stamp & 0x7FFF`, `serverStamp = ServerActionStamp & 0x7FFFF` (**note: differing mask widths, `0x7FFF` (15 bits) vs `0x7FFFF` (19 bits) — likely a typo, should probably both be the same mask; flag for retail cross-check**), `deltaStamp = abs(currentStamp - serverStamp)`, and picks a "is this newer" test based on whether the delta exceeds `0x3FFF` (half of 15-bit range) — a classic sequence-number wraparound comparison. Actions only replay if `WeenieObj.IsCreature() || action.Autonomous`.
|
|
||||||
- **ReportExhaustion (L264-272):**
|
|
||||||
```csharp
|
|
||||||
public void ReportExhaustion()
|
|
||||||
{
|
|
||||||
if (PhysicsObj == null || !Initted) return;
|
|
||||||
if (WeenieObj == null || WeenieObj.IsCreature() && PhysicsObj.movement_is_autonomous())
|
|
||||||
apply_raw_movement(false, false);
|
|
||||||
else
|
|
||||||
apply_interpreted_movement(false, false);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Same raw-vs-interpreted split condition shape as `apply_current_movement` but with the WeenieObj null-check ADDED to the raw-path condition (here: `WeenieObj==null || (IsCreature && autonomous)` routes to raw; there: `WeenieObj!=null && !IsCreature || !autonomous` routes to interpreted) — these two conditions are NOT exact logical complements of each other across the two methods (worth a careful truth-table comparison before assuming they're meant to be identical dispatch logic). Both calls pass `cancelMoveTo=false, allowJump=false` — exhaustion reporting never cancels an active moveto and never allows a fresh jump.
|
|
||||||
MovementManager.ReportExhaustion (L159-165) is a thin delegate with a dead commented-out `// NoticeHandler::RecvNotice_PrevSpellSelection` line, same as HandleEnterWorld.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. MovementManager remaining surface (not covered above)
|
|
||||||
|
|
||||||
- **CancelMoveTo / HandleUpdateTarget / IsMovingTo / MakeMoveToManager / UseTime:** all thin null-guarded delegates to `MoveToManager` — no MotionInterp involvement. `MakeMoveToManager()` (L112-116) lazy-constructs `MoveToManager` WITHOUT calling any init-state method (contrast with the `MotionInterpreter` lazy-construct sites which always follow with `enter_default_state()`).
|
|
||||||
- **InqInterpretedMotionState / InqRawMotionState / get_minterp:** identical three-line lazy-construct-with-enter_default_state pattern, just returning a different field (`.InterpretedState`, `.RawState`, or the interpreter itself).
|
|
||||||
- **unpack_movement(object addr, uint size) (L213):** empty body — `{ }`. Stub/no-op, presumably a retail network-deserialization entry point ACE doesn't need (server already has structured data, doesn't need to unpack a wire buffer here) or hasn't ported. Flag: if named-retail decomp shows this doing real work, it's client-side-only logic ACE correctly skips (server doesn't need to interpret its own outbound format), OR it's a genuine gap — check the decomp before assuming either way.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary table — ACE method inventory vs retail-decomp cross-check TODO
|
|
||||||
|
|
||||||
| ACE method | File:Line | Retail decomp checked this pass? |
|
|
||||||
|---|---|---|
|
|
||||||
| `add_to_queue` | MotionInterp.cs:388 | No — ACE-only pass |
|
|
||||||
| `MotionDone` | MotionInterp.cs:210 | No |
|
|
||||||
| `DoMotion` | MotionInterp.cs:112 | No |
|
|
||||||
| `DoInterpretedMotion` | MotionInterp.cs:51 | No |
|
|
||||||
| `StopMotion` | MotionInterp.cs:367 | No |
|
|
||||||
| `StopInterpretedMotion` | MotionInterp.cs:329 | No |
|
|
||||||
| `StopCompletely` | MotionInterp.cs:301 | No |
|
|
||||||
| `HitGround` | MotionInterp.cs:175 | No |
|
|
||||||
| `LeaveGround` | MotionInterp.cs:192 | No |
|
|
||||||
| `jump` | MotionInterp.cs:710 | No |
|
|
||||||
| `jump_is_allowed` | MotionInterp.cs:742 | No — **flag: possible null-guard typo L747** |
|
|
||||||
| `jump_charge_is_allowed` | MotionInterp.cs:729 | No |
|
|
||||||
| `charge_jump` | MotionInterp.cs:564 | No — **flag: Falling/Fallen + range-inclusivity mismatch vs jump_charge_is_allowed** |
|
|
||||||
| `get_jump_v_z` | MotionInterp.cs:634 | No |
|
|
||||||
| `get_leave_ground_velocity` | MotionInterp.cs:654 | No |
|
|
||||||
| `enter_default_state` | MotionInterp.cs:604 | No — **flag: `// ??` on PendingMotions reset** |
|
|
||||||
| `apply_raw_movement` | MotionInterp.cs:506 | No |
|
|
||||||
| `apply_interpreted_movement` | MotionInterp.cs:440 | No — **flag: inline PhysicsObj.StopInterpretedMotion duplication in Turn-stop branch** |
|
|
||||||
| `apply_current_movement` | MotionInterp.cs:430 | No |
|
|
||||||
| `adjust_motion` | MotionInterp.cs:394 | No |
|
|
||||||
| `apply_run_to_command` | MotionInterp.cs:525 | No |
|
|
||||||
| `contact_allows_move` | MotionInterp.cs:584 | No |
|
|
||||||
| `motion_allows_jump` | MotionInterp.cs:770 | No — **depends on MotionCommand enum ordinal layout matching retail exactly** |
|
|
||||||
| `move_to_interpreted_state` | MotionInterp.cs:789 | No — **flag: 0x7FFF vs 0x7FFFF mask width mismatch** |
|
|
||||||
| `MovementManager.PerformMovement` | MovementManager.cs:124 | No |
|
|
||||||
| `MovementManager.HandleEnterWorld` | MovementManager.cs:48 | No — **dead/commented; retail NoticeHandler::RecvNotice_PrevSpellSelection not ported** |
|
|
||||||
| `MotionTableManager.AnimationDone` | MotionTableManager.cs:28 | No |
|
|
||||||
| `MotionTableManager.CheckForCompletedMotions` | MotionTableManager.cs:63 | No |
|
|
||||||
|
|
||||||
All rows above are candidates for Step 0 (`grep named-retail/acclient_2013_pseudo_c.txt` by `CMotionInterp::<method>` — note retail almost certainly uses the `C`-prefixed class name ACE dropped) before any acdream port work proceeds, per CLAUDE.md's mandatory workflow.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,377 +0,0 @@
|
||||||
# R3 port work-list — CMotionInterp completion + local-player unification
|
|
||||||
|
|
||||||
Inputs: `r3-motioninterp-decomp.md` (verbatim retail extraction, same dir),
|
|
||||||
`r3-ace-motioninterp.md` (ACE cross-ref, same dir), plan of record
|
|
||||||
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R3), R2 boundary
|
|
||||||
contract `docs/research/2026-07-02-r2-motiontable/r2-port-plan.md` §4, current code
|
|
||||||
`src/AcDream.Core/Physics/MotionInterpreter.cs` (1426 lines, post-D6/S2a),
|
|
||||||
`src/AcDream.App/Input/PlayerMovementController.cs` (1521 lines),
|
|
||||||
`src/AcDream.Core/Physics/InterpretedMotionFunnel.cs`, GameWindow K-fix18 sites.
|
|
||||||
|
|
||||||
**Precondition / state at R3 start (updated at vaulting, 2026-07-02):** R1 complete
|
|
||||||
(`a987cad1` P6). R2 is IN FLIGHT: Q0 (`dc54a3e4`) + Q1 MotionState (`2345da30`) +
|
|
||||||
Q2 `Motion/CMotionTable.cs` (`98f58db9`, 44 tests) committed;
|
|
||||||
Q3 (MotionTableManager + `IMotionDoneSink`) in flight, Q4 (adapter cutover +
|
|
||||||
queue-drain wiring) and Q5 (RemoteMotionSink deletion) NOT started.
|
|
||||||
`RemoteMotionSink.cs` still exists (217 lines). **R3 commits W2+ hard-depend on
|
|
||||||
R2 Q3/Q4/Q5 being shipped** (the `IMotionDoneSink` seam, the
|
|
||||||
sentinel→`AnimationDone` drain, and funnel-direct `PerformMovement` dispatch are
|
|
||||||
R2 deliverables R3 consumes). W0/W1 are independent and can start while R2
|
|
||||||
finishes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. DECOMP AMBIGUITIES TO PIN before porting (the W0 pseudocode commit)
|
|
||||||
|
|
||||||
| # | Ambiguity | Evidence each way | Pin method |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A1 | **`motion_allows_jump` 0x48 polarity is INVERTED in the BN extraction's "cleaned" note.** r3-motioninterp-decomp §3a annotates `0x48` = "jump allowed" and the range set as a whitelist. But the CALLER `jump_is_allowed` (0x005282b0, §3h) does `eax_7 = motion_allows_jump(fwd); if (eax_7 != 0) return eax_7;` — nonzero is returned AS THE ERROR, so **0x48 = "YouCantJumpFromThisPosition"-class BLOCK and the ranges are the blocklist** (matches ACE MotionInterp.cs:770-779: five blocked ranges + one exact id, return None otherwise). Under the corrected polarity the retail blocklist is: `[0x1000006f,0x10000078]`, `[0x10000128,0x10000131]`, `0x40000008 (Fallen)`, `[0x40000016,0x40000018]`, `[0x4000001e,0x40000039]`, `[0x41000012,0x41000014] (Crouch..Sleeping)`; everything else (incl. **0x40000015 Falling → 0 = pass**) falls through. ACE instead blocks exactly `Falling` and never `Fallen` — one of the two mis-transcribed a constant. The same wrong "0x48 = allowed" annotation propagates into the decomp doc's §10 constants inventory. | decomp §3a/§3h vs ACE L770-779; acdream MotionCommand.cs: Fallen=0x40000008, Falling=0x40000015 | Re-read raw pseudo-C @305 line 304908 for the exact comparison chain; map every boundary constant through the dat MotionCommand table (does 0x40000015 really pass?); decisive cdb: bp `acclient!CMotionInterp::motion_allows_jump` log arg2+eax while user jumps during walk/run/mid-fall/crouch. **Port the pinned blocklist as literal uint ranges, NOT enum-ordinal ranges** (ACE's enum-order dependence is the flagged fragility). |
|
|
||||||
| A2 | **`jump_is_allowed` pending-head peek gate.** Retail (§3h full body): peek `pending_motions.head_` whenever non-empty; if `head.jump_error_code != 0` return it. ACE (L756): `PendingMotions.Count > 1 && First.JumpErrorCode != 0` — the `Count > 1` has no retail counterpart in this decomp. | decomp §3h (no count check) vs ACE L756 | Retail decomp is full-body and unambiguous → port retail (head peek whenever queue non-empty). Note in pseudocode why ACE differs (their head = currently-playing heuristic). ACE's L747 `WeenieObj == null && !WeenieObj.IsCreature()` NPE-typo is CONFIRMED wrong vs retail's `weenie != 0 && IsCreature()==0` gate — do not copy. |
|
|
||||||
| A3 | **`apply_current_movement` / `SetWeenieObject` / `SetPhysicsObject` dual-dispatch gate: `IsThePlayer` or `IsCreature`?** The decomp extraction (§4c) states ReportExhaustion gates on `IsThePlayer() && movement_is_autonomous()` and claims the identical pattern in apply_current_movement/SetWeenieObject/SetPhysicsObject. ACE apply_current_movement (L430-438) gates on `IsCreature`. These are NOT the same predicate: a remote player is a creature but not the player. Client-side, `IsThePlayer` is the coherent reading (raw_state is only meaningful for the locally-controlled object); ACE is a server where its own physics objects are all "the player". Getting this wrong sends remotes down `apply_raw_movement` and corrupts their interpreted state from an empty raw state. | decomp §4c text vs ACE L430/L497 | Re-read raw pseudo-C at 0x00528870 (line 305838, apply_current_movement), 0x00528920 (SetWeenieObject), 0x00528970 (SetPhysicsObject) — the vtable slot called distinguishes `IsThePlayer` from `IsCreature`. If still ambiguous, cdb bp with `this` dump on a remote-visible session. Default: **IsThePlayer-based for apply_current_movement/ReportExhaustion** (decomp's explicit ReportExhaustion body shows IsThePlayer; §4c says the others are identical). |
|
|
||||||
| A4 | **MovementParameters bit numbering.** DoMotion reads byte-1 bits: `&8` → SetHoldKey (=0x800 overall), `&0x20` → raw mirror (=0x2000); acclient.h's union comment says "bit0x8=SetHoldKey, bit0x20=Raw mirror, bit0x40=Interpreted mirror" (byte-relative). Sign bit = interrupt. Ctor default = `(bitfield & 0xfffdee0f) | 0x1ee0f`. | decomp §0/§2 vs acclient.h:31453 comment | Derive the absolute bit positions from the ctor default 0x1ee0f + ACE MovementParameters property names (CancelMoveTo/SetHoldKey/ModifyRawState/ModifyInterpretedState/DisableJumpDuringLink/…) which ACE mapped from the same bitfield. Document the full flag table in the pseudocode doc; the C# port uses named bool properties + a `ToBitfield()` only if the wire ever needs it. |
|
|
||||||
| A5 | **`get_jump_v_z` no-weenie fallback + near-zero path.** BN garbles the `extent < 0.0002` path (x87 flag noise) and reads the no-weenie return as "clamped extent"; ACE: `< EPSILON → 0.0f`, no-weenie → `10.0f`, InqJumpVelocity false → `0.0f`. Current acdream uses ACE's shape but with epsilon **0.001** (wrong — retail literal is `0.000199999995f`). | decomp §3c vs ACE L634-652 vs MotionInterpreter.cs:278 | Textual pin at 0x00527aa0 raw. Low stakes (creatures always have a weenie); fix the epsilon regardless. |
|
|
||||||
| A6 | **`get_leave_ground_velocity` zero-fallback matrix direction.** BN reads `m_fl2gv` (local→global) applied to the velocity; ACE does `GlobalToLocalVec(Velocity)`. Since the result feeds `set_local_velocity` (a LOCAL vector) and `m_velocityVector` is global, global→local is the only coherent reading — BN row/column confusion. Current `Quaternion.Inverse(Orientation)` transform (MotionInterpreter.cs:1024) IS global→local: keep. Epsilon must become 0.0002 (currently 0.001 via JumpExtentEpsilon). | decomp §3d vs ACE L654-663 vs current :1011-1029 | Adjudicate textually in pseudocode; conformance test: walk-off-ledge with nonzero world velocity preserves momentum direction. |
|
|
||||||
| A7 | **`MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in `CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed node's motion id; the interp body reads NEITHER param in this build — **pop the HEAD unconditionally, never match by motion id**. Keep the params for R5 signature parity. | decomp §1c/§6d | Documented adjudication only. |
|
|
||||||
| A8 | **`enter_default_state` does NOT clear `pending_motions`.** Retail (§4d): resets raw+interp state, InitializeMotionTables, then APPENDS the `{0, 0x41000003, 0}` sentinel to whatever queue exists — no drain. ACE resets `PendingMotions = new LinkedList` with the author's own `// ??`. Port retail (append, no clear); note the MovementManager lazy-create path calls enter_default_state TWICE on first touch (retail double-call, §6g) — genuine retail, do not "fix", but R3's direct-bind construction calls it once explicitly. | decomp §4d/§6g vs ACE L604-616 | Documented adjudication; test asserts pre-existing nodes survive an enter_default_state. |
|
|
||||||
| A9 | **`StopCompletely` jump-snapshot quirk**: `motion_allows_jump` is evaluated on the OLD forward_command, then the state is overwritten and the OLD result is stashed in the queued Ready node. Port verbatim including the quirk (ACE L301-327 agrees). | decomp §5a + ACE | Textual pin only; golden asserts the queued node's error code reflects the pre-stop command. |
|
|
||||||
| A10 | **Error-code renumber vs current acdream `WeenieError`.** Retail numeric usage: `8`=no physics obj, `0x24`=not grounded (jump_is_allowed airborne), `0x3f/0x40/0x41`=combat-stance crouch/sit/sleep rejects, `0x42`=combat-stance chat-emote-bit reject, `0x45`=action depth ≥6, `0x47`=constrained OR stamina OR bad MovementStruct.type, `0x48`=motion/position blocks jump, `0x49`=CanJump refused. acdream's current enum (MotionInterpreter.cs:113-127) has the NAMES shuffled (0x24 named GeneralMovementFailure but used where retail uses 0x48-class; jump_is_allowed returns 0x48 for airborne where retail returns 0x24). | decomp §10 vs MotionInterpreter.cs:113-127, :1064 | W1 fixes the enum to retail's numeric semantics (add 0x3f/0x40/0x41/0x42/0x45); W3/W5 use them. Codes are local-only (never on the wire) — safe rename. |
|
|
||||||
|
|
||||||
**W0 cdb capture (one session serves all of R3):** bp `CMotionInterp::MotionDone` /
|
|
||||||
`add_to_queue` / `jump` / `jump_is_allowed` / `jump_charge_is_allowed` / `charge_jump`
|
|
||||||
/ `motion_allows_jump` / `HitGround` / `LeaveGround` / `StopCompletely` / `set_hold_run`
|
|
||||||
/ `SetHoldKey` / `enter_default_state` with arg+ret logging (pattern:
|
|
||||||
tools/cdb/l2g-observer.cdb). User protocol: idle / walk / run / shift-toggle mid-walk /
|
|
||||||
tap-jump / full-charge jump / running jump / standing long-jump (charge while idle,
|
|
||||||
then W mid-charge — the StandingLongJump suppression) / jump attempt while crouched
|
|
||||||
(blocked codes) / jump attempt in combat stance (0x3f-0x42 family) / walk off a ledge
|
|
||||||
(LeaveGround without jump — the momentum fallback) / land / emote-while-running
|
|
||||||
(action-class node through MotionDone) / 7 rapid emotes (0x45 depth cap) / logout
|
|
||||||
(HandleExitWorld drain).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ITEMIZED GAPS — current vs retail (R3 scope)
|
|
||||||
|
|
||||||
Severity: **BLOCKER** = R3's conformance harness meaningless without it; **HIGH** =
|
|
||||||
visible behavior wrongness / blocks R4-R6; **MED** = edge-visible; **LOW** = textual.
|
|
||||||
|
|
||||||
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| J1 | **No `pending_motions` queue** — no MotionNode, no `add_to_queue` (append `{context_id, motion, jump_error_code}`), no `MotionDone` (head action-class 0x10000000 → unstick + Interpreted+Raw `RemoveAction`, then unconditional head pop), no `motions_pending`, no `HandleExitWorld` drain loop. The R2 `IMotionDoneSink` signal dead-ends in a diagnostic recorder (per r2-port-plan Q4). | add_to_queue 0x00527b80 @305032; MotionDone 0x00527ec0 @305238; motions_pending 0x00527fe0; HandleExitWorld 0x00527f30; ACE MotionInterp.cs:210-234/388-392/160-173 | `MotionInterpreter.cs:1394` ("Idle add_to_queue(Ready) bookkeeping lands with S3") + `:1417` ("add_to_queue … S3 bookkeeping") — both TODO comments; no queue field exists | **BLOCKER** |
|
|
||||||
| J2 | **No action FIFO on the states** — retail `RawMotionState`/`InterpretedMotionState` each carry an actions list (`ApplyMotion`/`RemoveMotion`/`AddAction`/`RemoveAction`/`GetNumActions`); MotionDone's action-class pop and DoMotion's ≥6 depth cap operate on it. acdream's `InterpretedMotionState` (MotionInterpreter.cs:185-210) + `LegacyRawMotionState` (:148-179) are flat 6/7-field structs; the outbound `RawMotionState` packs an always-empty action list (register TS-24). | acclient.h RawMotionState/InterpretedMotionState; DoMotion @306159 GetNumActions≥6; MotionDone RemoveAction pair | `MotionInterpreter.cs:148-210`; `Core/Physics/RawMotionState.cs` (packer type, no actions) | **BLOCKER** |
|
|
||||||
| J3 | **DoMotion is a 12-line approximation.** Retail 0x00528d20: interrupt on sign bit; `SetHoldKey` on bit-SetHoldKey BEFORE `adjust_motion`; fresh local `MovementParameters` re-default (caller's distance/heading fields discarded for the interpreted call); combat-stance gate (`current_style != 0x8000003d` rejects 0x41000012/13/14 → 0x3f/0x40/0x41 and `motion & 0x2000000` → 0x42 — tested on the ORIGINAL id); action-depth cap → 0x45; delegate `DoInterpretedMotion(adjusted, localParams)`; mirror `RawState.ApplyMotion(ORIGINAL id)` on the mirror bit. Current: writes RawState.Forward\* then calls the legacy DoInterpretedMotion — no params, no gates, no mirror discipline. | 0x00528d20 @306159; ACE L112-158 | `MotionInterpreter.cs:448-462` | **BLOCKER** |
|
|
||||||
| J4 | **TWO parallel DoInterpretedMotion implementations, both partial.** The legacy overload (:473-491) hand-applies state + apply_current_movement; the S2a funnel `DispatchInterpretedMotion` (:1408-1425) has the verbatim contact gate + sink dispatch but no StandingLongJump special-case (Walk/Run/SideStepRight while charging → state-only, NO dispatch, NO queue), no `Dead → RemoveLinkAnimations`, no jump_error_code compute (double `motion_allows_jump` check: incoming motion, then ForwardCommand if non-action) + `add_to_queue`, no `ModifyInterpretedState` param, no `CurCell==null → RemoveLinkAnimations` tail. Same story for StopInterpretedMotion (:527-553 vs the funnel's bare `sink.StopMotion`): missing post-stop `add_to_queue(ctx, Ready, None)` + raw `RemoveMotion` mirror. They must MERGE into one verbatim pair. | DoInterpretedMotion 0x00528360 (S2a base) + queue callers @305607/305657/305775; ACE L51-110/329-365 | `MotionInterpreter.cs:473-491`, `:498-553`, `:1408-1425`, `:1358-1396` | **BLOCKER** |
|
|
||||||
| J5 | **Jump gate family absent**: no `motion_allows_jump` (0x005279e0 literal-range blocklist, A1), no `jump_charge_is_allowed` (0x00527a50: CanJump → 0x49; Fallen/Crouch..Sleeping → 0x48), no `charge_jump` (0x005281c0: arms `standing_longjump` only when on-ground + fwd==Ready + no sidestep/turn). `jump_is_allowed` (:1053-1071) is a 15-line approximation missing the creature/state-0x400 entry shape, IsFullyConstrained → 0x47, the pending-head jump_error_code peek (A2), the charge→motion→stamina chain, and `JumpStaminaCost` (not on IWeenieObject). Wrong airborne code (0x48, retail 0x24 — A10). | §3a/3b/3e/3h; ACE L729-779 | `MotionInterpreter.cs:1053-1071`; IWeenieObject :239-256 (no JumpStaminaCost/IsThePlayer) | **BLOCKER** |
|
|
||||||
| J6 | **StandingLongJump armed in the WRONG function** — the S2a-flagged misattribution: acdream arms it as a side effect of `contact_allows_move` (:1139-1148, explicitly marked "PRE-EXISTING acdream side effect (not part of 0x00528240)"); retail arms it ONLY in `charge_jump`. Consequence today: every grounded idle contact check flips the flag, so the funnel's StandingLongJump branch (:1371-1375) can fire without any jump charge. | charge_jump 0x005281c0 @305448 | `MotionInterpreter.cs:1139-1148` | **HIGH** |
|
|
||||||
| J7 | **jump() missing `interrupt_current_movement`** (retail always cancels the in-flight transition first; ACE `cancel_moveto`). And the CONTROLLER manually calls `LeaveGround()` + pre-captures `get_jump_v_z` at key-release (PlayerMovementController.cs:996-1028) — retail's LeaveGround fires from the physics layer detecting ground departure (`set_on_walkable(false)` → transition → MovementManager::LeaveGround). Walk-off-a-ledge never calls LeaveGround at all today (the get_leave_ground_velocity momentum fallback is dead code). | jump 0x00528780 @305792; LeaveGround 0x00528b00 (NOT the doc-comment's 0x00529710) | `MotionInterpreter.cs:943-958`; PlayerMovementController.cs:996-1028, :1226-1257 (transition block calls HitGround but never LeaveGround) | **HIGH** |
|
|
||||||
| J8 | **HitGround/LeaveGround bodies incomplete**: both missing the creature gate (`weenie==null OR IsCreature`) and `RemoveLinkAnimations`; LeaveGround missing the state-0x400 gate, `apply_current_movement(0,0)` re-sync, and the autonomous flag on `set_local_velocity(v, 1)`. **This is the retail mechanism K-fix18 fakes**: leaving ground strips pending link animations, so Falling engages instantly — no `skipTransitionLink` needed. | HitGround 0x00528ac0 @305996; LeaveGround 0x00528b00 @306022 | `MotionInterpreter.cs:1166-1198`; K-fix18 sites: AnimationSequencer.cs:305/313/390/401/433, GameWindow.cs:4817-4831, :10194-10224 | **HIGH** |
|
|
||||||
| J9 | **StopCompletely missing** interrupt, the A9 jump snapshot, `add_to_queue(0, Ready, jumpErr)`, and cell-null `RemoveLinkAnimations`; extra divergence: resets sidestep/turn SPEEDS to 1.0 (retail writes only fwd cmd/speed + zeroes sidestep/turn COMMANDS). `set_velocity(Zero)` stands in for `StopCompletely_Internal` (acceptable, keep + note). | 0x00527e40 @305208 | `MotionInterpreter.cs:577-602` | **HIGH** |
|
|
||||||
| J10 | **No `enter_default_state` / `initted`**: no state reset + InitializeMotionTables + Ready-sentinel enqueue (A8) + `initted=1` + unconditional LeaveGround; nothing gates `apply_current_movement`/`ReportExhaustion` on initted. | 0x00528c80 @306124 | constructors at `MotionInterpreter.cs:386-398` only | **BLOCKER** |
|
|
||||||
| J11 | **`apply_current_movement` is a direct-velocity approximation**, not the retail dual dispatch (`initted` gate → IsThePlayer/autonomous (A3) → `apply_raw_movement(cancelMoveTo, allowJump)` else `apply_interpreted_movement(...)`). The grounded `set_local_velocity(get_state_velocity())` write is an acdream adaptation of root-motion-driven velocity — survives R3 (register row), retires in R6. Also: no `(cancelMoveTo, allowJump)` param plumbing anywhere — `DisableJumpDuringLink = !allowJump` feeds the jump_error_code path (ACE L538/L232). | 0x00528870 @305838 (A3 pin); ACE L430-438 | `MotionInterpreter.cs:905-925`; funnel `ApplyInterpretedMovement` :1358 (no params) | **HIGH** |
|
|
||||||
| J12 | **No `ReportExhaustion`** (initted gate + IsThePlayer/autonomous dual dispatch, both `(0,0)` args); no `movement_is_autonomous` flag on PhysicsBody; no `IsThePlayer` on IWeenieObject. Retail relay chain: MovementManager::ReportExhaustion → CMotionInterp (+ MoveToManager, R4). The dead ACE `NoticeHandler.RecvNotice_PrevSpellSelection` breadcrumb is NOT R3 scope (spell-UI notice) — file as a note, don't port. | 0x005288d0 @305861 | nothing | MED-HIGH (needed for stamina-exhaustion demotes; wire caller lands with server stamina events) |
|
|
||||||
| J13 | **No `SetHoldKey`/`set_hold_run`** — retail's Shift edge handling (XOR toggle guard, None-only-meaningful-from-Run, both re-apply movement). acdream fakes it by rebuilding the whole RawMotionState every frame (J15). `HoldKey_None=1 / HoldKey_Run=2` encoding already matches acdream's HoldKey enum usage. | set_hold_run 0x00528b70 @306053; SetHoldKey 0x00528bb0 @306072 | `MotionInterpreter.cs:340` (bare CurrentHoldKey field, written by apply_raw_movement only) | **HIGH** |
|
|
||||||
| J14 | **No `CMotionInterp::PerformMovement` 5-way dispatch** with `CheckForCompletedMotions` after EVERY op (the synchronous zero-tick drain — retail calls it at 306234-306262 after each of DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletely). Current PerformMovement (:416-430) dispatches to the approximations and has a comment explicitly skipping the flush. Needs a seam to the entity's MotionTableManager (R2-Q3). | 0x00528e80 @306221; CPhysicsObj::CheckForCompletedMotions 0x0050fe30 | `MotionInterpreter.cs:400-430` | **BLOCKER** |
|
|
||||||
| J15 | **Local player motion state machine lives in PlayerMovementController, level-triggered.** Per-frame RawMotionState rebuild from held keys (:887-910) instead of retail's edge-driven DoMotion/StopMotion + set_hold_run through PerformMovement; jump charge/fire block calls `_motion.jump` + manual LeaveGround (:986-1028); outbound wire commands re-derived from `input` (:1264-1328) instead of read from the raw state; `LocalAnimationCommand`/`LocalAnimationSpeed` synthesized (:1284-1296, :1418-1456) instead of the interpreted state driving the sequencer through the same funnel path remotes use. This is the plan-of-record's "motion half of PlayerMovementController" — R3's unification target. | DoMotion/StopMotion callers = CommandInterpreter edges; set_hold_run call sites §5c (0x0058b303 / 0x006b33ca) | PlayerMovementController.cs:887-910, :986-1028, :1264-1359, :1418-1456; GameWindow.cs:10062-10234 (UpdatePlayerAnimation fallback chain + SetCycle drive) | **BLOCKER** for the stage's named deliverable |
|
|
||||||
| J16 | **Epsilons + codes**: `JumpExtentEpsilon = 0.001` used in get_jump_v_z AND get_leave_ground_velocity — retail is `0.000199999995f` in both (§10); WeenieError names shuffled vs retail numerics (A10). | §3c/§3d/§10 | `MotionInterpreter.cs:278`, :978, :1020; :113-127 | MED |
|
|
||||||
| J17 | **`is_standing_still` absent** (on-ground + Ready + no sidestep/turn) — trivial; needed by callers (input layer, R7 cadence) and by the decomp's documented substitute set for the not-in-retail `IsAnimating`/`IsMovingOrAnimating`. **Do NOT invent a combined IsAnimating helper** — retail has none (decomp §7f negative result); ACE's `PhysicsObj.IsAnimating` flag is an ACE-ism (retail queries `motions_pending`). | 0x00527fa0 @305309 | nothing | LOW |
|
|
||||||
| J18 | **`adjust_motion` creature guard unwired** (register TS-34): `IWeenieObject.IsCreature()` exists (default true) but adjust_motion ignores it (comment ":758 Creature guard unwired"). One-line fix; retires TS-34. | adjust_motion @305343 gate | `MotionInterpreter.cs:756-758` | LOW |
|
|
||||||
| J19 | **VectorUpdate remote-jump path bypasses the interp** (GameWindow.cs:4782-4840): sets body velocity/airborne + forces the Falling cycle via `SetCycle(..., skipTransitionLink:true)` (K-fix18 site 1). Retail: the remote's physics leaves ground → LeaveGround → RemoveLinkAnimations + apply_current_movement → contact-blocked funnel dispatches Falling. After J8, this becomes `rm.Motion.LeaveGround()` (+ the funnel's existing Falling dispatch). | SmartBox::DoVectorUpdate 0x004521C0 → set_velocity/set_omega; LeaveGround chain | GameWindow.cs:4802-4833 | **HIGH** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. KEEP LIST — already matching retail (do not re-port)
|
|
||||||
|
|
||||||
| Behavior | Retail anchor | acdream anchor |
|
|
||||||
|---|---|---|
|
|
||||||
| Inbound funnel dispatch order: `MoveToInterpretedState` (flat copy + 15-bit stamp gate + local-echo skip) / `ApplyInterpretedMovement` axis order (style → fwd-or-Falling → sidestep(-stop) → turn(-stop, early return)) + 183-case live-trace suite | 0x005289c0 / 0x00528600 (S2a, `7b0cbbda`) | `MotionInterpreter.cs:1312-1396` — W5 merges the Dispatch backend into verbatim DoInterpretedMotion WITHOUT touching dispatch order; suite is the parity bar |
|
|
||||||
| `contact_allows_move` verbatim body (turns/Falling/Dead always-allowed, non-creature bypass, gravity bypass, Contact+OnWalkable) | 0x00528240 (S2a rewrite) | `MotionInterpreter.cs:1096-1151` — MINUS the :1139-1148 StandingLongJump side effect (J6 deletes it) |
|
|
||||||
| `adjust_motion` / `apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` incl. the RunForward-early-return, sign-gated promotion, ±3.0 sidestep clamp, max-speed clamp | D6/D6.2a (`0f099bb6`) | `MotionInterpreter.cs:654-888` — untouched except J18's one-line guard |
|
|
||||||
| `GetMaxSpeed` ×4.0 (UN-2 byte-verified vs the BN x87 dropout) | 0x00527cb0 | `MotionInterpreter.cs:1235-1245` |
|
|
||||||
| `GetCycleVelocity` Option-B forward-velocity source (MotionData.Velocity over the constant) | adaptation, r03 §1.3 | `MotionInterpreter.cs:382`, :668-683 — keep; verify register row exists, retire when R6 root motion drives the body |
|
|
||||||
| `StopCompletely_Internal` stand-in = `PhysicsObj.set_velocity(Zero)` | 0x0050f5a0 is CPhysicsObj-side | `MotionInterpreter.cs:599` — keep + register note (full port is physics-layer scope) |
|
|
||||||
| Jump charge accumulation UI timing (2.0 extent/s, AP-24) + extent→height formula | GetPowerBarLevel 0x0056ADE0 illegible | PlayerMovementController.cs:167-178 — charge STAYS controller-side (retail's charge lives at the SmartBox/input boundary too, §3e caller 0x0056afac); AP-24 row survives |
|
|
||||||
| Physics tick gate (30 Hz MinQuantum), Resolve/bounce/landing detection, AP diff cadence, PortalSpace, SetPosition/SnapToCell, NotePositionSent | L.5/L.2c/B.6 anchors in-file | PlayerMovementController.cs sections 4-5, 8 — R3 does not touch; R6 owns tick order |
|
|
||||||
| Auto-walk (B.6) block incl. `DoMotion(Ready)`/`DoMotion(WalkForward)` drive | MovementManager case 6 stand-in | PlayerMovementController.cs:254-766 — survives R3 verbatim (calls route through the new DoMotion transparently); REPLACED in R4 by MoveToManager |
|
|
||||||
| Outbound wire packers (RawMotionState::Pack, MoveToStatePack, JumpPack) + dual command catalogs | L.2b/D6.2b/L.1b | untouched; W6 changes only WHERE the packed values are read from (raw state instead of re-derivation) |
|
|
||||||
| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), R1 CSequence core, R2 CMotionTable/MotionState/MotionTableManager | plan "absorbed" + R2 | untouched — R3 sits between MovementManager(-to-be) and the R2 queue |
|
|
||||||
| `0x41000003` Ready sentinel / `0x10000000` action-class bit / `0x8000003d` NonCombat conventions | §10 | already the file-wide constants |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
|
|
||||||
|
|
||||||
New code target: extend `src/AcDream.Core/Physics/MotionInterpreter.cs` in place (it IS
|
|
||||||
the CMotionInterp port; plan rule 4's `Motion/` dir holds the R2 table/queue classes it
|
|
||||||
talks to). Tests: `tests/AcDream.Core.Tests/Physics/` (existing MotionInterpreter
|
|
||||||
suites + `Motion/` for new fixtures). Every commit: build+test green, register rows
|
|
||||||
added/retired in-commit.
|
|
||||||
|
|
||||||
**W0 — pseudocode + ambiguity pinning (docs only).**
|
|
||||||
`docs/research/2026-07-0x-motioninterp-pseudocode.md` from r3-motioninterp-decomp.md,
|
|
||||||
resolving A1-A10 (§0) — the A1 polarity fix and A3 dispatch-gate pin are load-bearing;
|
|
||||||
correct the decomp doc's §3a/§10 annotations in the same commit. Run the ONE cdb
|
|
||||||
session (§0 protocol) — feeds W2/W3/W5/W6 goldens.
|
|
||||||
Fixture source: **cdb** (live retail). Deps: none (parallel with R2 completion).
|
|
||||||
|
|
||||||
**W1 — retail state completion: action FIFO + MovementParameters + WeenieError renumber.** (closes J2, J16-codes/A10)
|
|
||||||
`InterpretedMotionState`/`RawMotionState` gain the actions list
|
|
||||||
(`AddAction`/`RemoveAction`/`GetNumActions`/`ApplyMotion`/`RemoveMotion` per retail
|
|
||||||
member set; the outbound packer's empty-list TS-24 comment updates to "populated by
|
|
||||||
CMotionInterp"); fold `LegacyRawMotionState` into the full-field
|
|
||||||
`AcDream.Core.Physics.RawMotionState` (one raw-state type; delete the legacy struct);
|
|
||||||
`MovementParameters` verbatim class (ctor defaults: distance_to_object=0.6,
|
|
||||||
fail_distance=FLT_MAX, speed=1, walk_run_threshhold=15, hold_key=Invalid, bitfield
|
|
||||||
0x1ee0f per A4 pin; named bool properties); `MotionNode {ContextId, Motion,
|
|
||||||
JumpErrorCode}`; WeenieError renumbered to retail (add 0x3f/40/41/42/45, fix
|
|
||||||
0x24/0x47/0x48 semantics).
|
|
||||||
Tests first: FIFO discipline; params defaults vs ctor decomp; existing suites green
|
|
||||||
under the raw-state fold.
|
|
||||||
Fixture source: **synthetic**. Deps: W0.
|
|
||||||
|
|
||||||
**W2 — pending_motions lifecycle + the MotionDone consumer (R2 seam lands).** (closes J1, J17; §4 below is this commit's wiring spec)
|
|
||||||
`MotionInterpreter` gains `PendingMotions` (LinkedList<MotionNode> — register note
|
|
||||||
reuses AD-34 wording), `add_to_queue` 0x00527b80, `motions_pending` 0x00527fe0,
|
|
||||||
`MotionDone(uint motion, bool success)` 0x00527ec0 (head-peek action-class → unstick
|
|
||||||
seam (no-op Action? callback until R5 StickyManager — register row) +
|
|
||||||
`InterpretedState.RemoveAction()` + `RawState.RemoveAction()`; unconditional head pop;
|
|
||||||
NEVER match by motion id per A7), `HandleExitWorld` 0x00527f30 (loop),
|
|
||||||
`is_standing_still` 0x00527fa0, and `motion_allows_jump` 0x005279e0 (pure fn, pinned
|
|
||||||
per A1 — needed here because every add_to_queue caller computes jump_error_code).
|
|
||||||
Wire the queue's PRODUCERS into the funnel: `DispatchInterpretedMotion` success path
|
|
||||||
computes jump_error_code (double-check per ACE L231-239 shape pinned at W0) +
|
|
||||||
`add_to_queue(ctx, motion, err)`; `ApplyInterpretedMovement`'s turn-stop adds
|
|
||||||
`add_to_queue(ctx, Ready, None)` (@305775); funnel StopMotion path adds the post-stop
|
|
||||||
Ready node (@305657). GameWindow: replace R2-Q4's diagnostic `IMotionDoneSink` binding
|
|
||||||
with the real per-entity bind (§4); teleport/despawn/exit paths call BOTH
|
|
||||||
`manager.HandleExitWorld()` (R2) and `interp.HandleExitWorld()` (retail has both
|
|
||||||
layers' drains).
|
|
||||||
Tests first: queue tables (enqueue per successful dispatch incl. Ready stops; pop
|
|
||||||
order; action-class pops from BOTH states + fires unstick; exit drain); S2a 183-case
|
|
||||||
suite green (dispatch unchanged, queue side effects asserted additively); end-to-end
|
|
||||||
chain test: MotionTableManager.AnimationDone → sink → interp pops (§4 diagram).
|
|
||||||
Fixture source: **synthetic + W0 cdb goldens** (add_to_queue arg conformance).
|
|
||||||
Deps: W1 + **R2 Q3/Q4 shipped**.
|
|
||||||
|
|
||||||
**W3 — jump family verbatim.** (closes J5, J6, J7-interp-side, J16-epsilons)
|
|
||||||
`jump_charge_is_allowed` 0x00527a50; `charge_jump` 0x005281c0 (arms StandingLongJump;
|
|
||||||
**DELETE the contact_allows_move side effect :1139-1148** — J6); `get_jump_v_z`
|
|
||||||
(epsilon → 0.000199999995f; A5 fallback pinned); `get_leave_ground_velocity` (same
|
|
||||||
epsilon; A6 direction confirmed); `jump_is_allowed` 0x005282b0 full chain (creature/
|
|
||||||
state-0x400 entry shape → 0x24; `IsFullyConstrained` seam on PhysicsBody (stub false +
|
|
||||||
register row); pending-head peek per A2; charge → motion_allows_jump(fwd) →
|
|
||||||
`JumpStaminaCost` (new IWeenieObject member; PlayerWeenie implements — real gating
|
|
||||||
stays TS-5-deferred, row survives)); `jump` 0x00528780 (+`interrupt_current_movement`
|
|
||||||
seam — no-op Action? until R4 cancel_moveto exists; register row). IWeenieObject gains
|
|
||||||
`IsThePlayer()` (default false; PlayerWeenie true) for W4's A3 dispatch.
|
|
||||||
Tests first: per-error-code gate tables from W0 goldens; StandingLongJump arming
|
|
||||||
matrix (grounded+idle only, cleared on failed jump); head-peek short-circuit;
|
|
||||||
blocked-motion jump attempts (crouch range per pinned A1 table).
|
|
||||||
Fixture source: **W0 cdb goldens + synthetic**. Deps: W2.
|
|
||||||
|
|
||||||
**W4 — ground transitions + lifecycle: HitGround/LeaveGround/enter_default_state/ReportExhaustion/hold keys; K-fix18 DELETED.** (closes J8, J10, J11-shape, J12, J13, J19; J18 one-liner rides along)
|
|
||||||
`HitGround` 0x00528ac0 verbatim (creature gate + Gravity + `RemoveLinkAnimations` seam
|
|
||||||
+ `apply_current_movement(false, true)`); `LeaveGround` 0x00528b00 verbatim (velocity
|
|
||||||
+ autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations +
|
|
||||||
apply_current_movement); `RemoveLinkAnimations` seam = `Action?` on MotionInterpreter,
|
|
||||||
App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per
|
|
||||||
CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence);
|
|
||||||
`enter_default_state` 0x00528c80 (A8: append sentinel, no clear; `Initted` field;
|
|
||||||
LeaveGround tail); `Initted` gates apply_current_movement + ReportExhaustion;
|
|
||||||
`apply_current_movement` becomes the verbatim dual dispatch per A3 (the direct
|
|
||||||
grounded-velocity write MOVES to the controller-side call site unchanged — register
|
|
||||||
row: velocity-from-state adaptation until R6 root motion); `ReportExhaustion`
|
|
||||||
0x005288d0 (A3 gate; `movement_is_autonomous` flag added to PhysicsBody, set true at
|
|
||||||
the local-player chokepoint, false on DR-applied updates); `SetHoldKey` 0x00528bb0 +
|
|
||||||
`set_hold_run` 0x00528b70; `SetPhysicsObject`/`SetWeenieObject` re-apply pattern.
|
|
||||||
**PlayerMovementController transition wiring:** grounded→airborne edge (section 5,
|
|
||||||
:1245-1257) now calls `_motion.LeaveGround()`; the jump-fire block stops calling
|
|
||||||
LeaveGround manually — `jump()` sets on_walkable(false) and the SAME frame's
|
|
||||||
transition detection fires LeaveGround (ordering test); JumpAction wire velocity read
|
|
||||||
from the LeaveGround-computed vector (get_leave_ground_velocity at fire time —
|
|
||||||
byte-identical values, source unified).
|
|
||||||
**K-fix18 DELETION (both sites + the parameter):** GameWindow :4817-4833 remote
|
|
||||||
VectorUpdate → `rm.Motion.LeaveGround()` replaces the forced SetCycle (J19); GameWindow
|
|
||||||
:10194-10224 skipLink computation + arg deleted; `AnimationSequencer.SetCycle`'s
|
|
||||||
`skipTransitionLink` parameter + its post-dispatch link-clear (whatever shape R2-Q4
|
|
||||||
left it in) deleted; K-fix18 register row (r2-port-plan keep-list says "register row
|
|
||||||
kept → R3") retired — if no row exists, the sweep note records it was comment-only.
|
|
||||||
Tests first: HitGround/LeaveGround call RemoveLinkAnimations + re-apply (seam mock);
|
|
||||||
ledge walk-off momentum fallback (A6); jump-fire → LeaveGround same-tick ordering;
|
|
||||||
enter_default_state seeds queue + initted + LeaveGround; ReportExhaustion dispatch
|
|
||||||
truth table (A3 pin); hold-key toggle tables (set_hold_run XOR guard; SetHoldKey
|
|
||||||
None-only-from-Run). Visual acceptance carried to W6's pass: jump engages Falling
|
|
||||||
instantly WITHOUT K-fix18.
|
|
||||||
Fixture source: **synthetic + W0 cdb goldens (LeaveGround/HitGround arg traces)**.
|
|
||||||
Deps: W3 (+ R2-Q4 committed for the sequencer seam shape).
|
|
||||||
|
|
||||||
**W5 — DoMotion/StopMotion/StopCompletely verbatim + the ONE DoInterpretedMotion + PerformMovement flush.** (closes J3, J4, J9, J14)
|
|
||||||
`DoMotion` 0x00528d20 (interrupt bit; SetHoldKey bit BEFORE adjust_motion; params
|
|
||||||
re-default; combat-stance gate on ORIGINAL id → 0x3f/0x40/0x41/0x42; depth cap 0x45;
|
|
||||||
raw mirror ORIGINAL id); `StopMotion` 0x00528530 (mirror shape, no gates, raw
|
|
||||||
RemoveMotion ORIGINAL id); `StopCompletely` 0x00527e40 (A9 verbatim incl. quirk +
|
|
||||||
exact-fields-only reset + add_to_queue + cell-null RemoveLinkAnimations); **merge**
|
|
||||||
legacy `DoInterpretedMotion`/`StopInterpretedMotion` overloads with the funnel
|
|
||||||
Dispatch backend into ONE verbatim `DoInterpretedMotion(uint, MovementParameters)` /
|
|
||||||
`StopInterpretedMotion(uint, MovementParameters)` (StandingLongJump state-only branch;
|
|
||||||
Dead → RemoveLinkAnimations; jump_error_code + add_to_queue (moves here from W2's
|
|
||||||
interim funnel site); ModifyInterpretedState; CurCell-null tail — CurCell proxy =
|
|
||||||
`PhysicsObj.CellId == 0`); **DELETE** `ApplyMotionToInterpretedState` (:1253-1282) +
|
|
||||||
the legacy overloads; `PerformMovement` gains the per-op
|
|
||||||
`CheckForCompletedMotions` seam call (bound to the entity's MotionTableManager
|
|
||||||
UseTime/CheckForCompletedMotions — R2-Q3 object) after every dispatch.
|
|
||||||
Tests first: DoMotion gate tables (each combat-stance code, depth cap, mirror
|
|
||||||
discipline: raw gets ORIGINAL id, interpreted gets adjusted) from W0 goldens; S2a
|
|
||||||
183-case suite green under the merged backend (assertions unchanged — dispatch order
|
|
||||||
is funnel-owned); zero-tick flush test (stop-with-no-link completes same call).
|
|
||||||
Fixture source: **W0 cdb goldens + suite**. Deps: W2+W4 (+ R2-Q5 so the funnel's sink
|
|
||||||
IS PerformMovement dispatch — otherwise the merge has two consumers).
|
|
||||||
|
|
||||||
**W6 — LOCAL PLAYER UNIFICATION (the GameWindow + controller commit; do NOT fan out — feedback_dont_parallelize_coupled_plan_slices).** (closes J15)
|
|
||||||
PlayerMovementController sheds the motion state machine; edge-driven retail input:
|
|
||||||
- **DIES in the controller:** per-frame RawMotionState rebuild + `apply_raw_movement`
|
|
||||||
call (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4);
|
|
||||||
`localAnimCmd`/`LocalAnimationSpeed` synthesis (:1283-1296, :1340-1359 change
|
|
||||||
detection on localAnimCmd, :1412-1456 K-fix5 block + auto-walk anim overrides —
|
|
||||||
the sequencer is now driven by the interp's own dispatch, not by
|
|
||||||
UpdatePlayerAnimation); `MovementResult.LocalAnimationCommand/LocalAnimationSpeed`
|
|
||||||
fields (and GameWindow consumers).
|
|
||||||
- **MOVES to MotionInterpreter calls (edge-driven):** input edges → `PerformMovement`:
|
|
||||||
W/S press → `DoMotion(WalkForward/WalkBackward, params)`; release →
|
|
||||||
`StopMotion(same)`; strafe/turn keys likewise; Shift edge → `set_hold_run(run,
|
|
||||||
interrupt:true)` (retail 0x006b33ca shape); jump release → `jump(extent)` (charge
|
|
||||||
accumulation STAYS controller-side, AP-24). The controller keeps a tiny
|
|
||||||
prev-key-state edge detector — that IS retail's CommandInterpreter altitude.
|
|
||||||
- **STAYS in the controller (input/camera/physics/wire):** Yaw + mouse turn
|
|
||||||
(:938-946); keyboard-turn Yaw integration reading
|
|
||||||
`_motion.InterpretedState.TurnCommand/TurnSpeed` (:932-937, unchanged); grounded
|
|
||||||
velocity write from `get_state_velocity` (:966-976 — the R6-deferred adaptation);
|
|
||||||
physics integrate/Resolve/bounce/landing (sections 4-5); heartbeat/AP (section 8);
|
|
||||||
auto-walk (B.6, until R4); SetPosition/PortalSpace (the :825 `DoMotion(Ready)`
|
|
||||||
arrival-idle becomes `StopCompletely()` — retail's teleport idle is a full stop;
|
|
||||||
note in commit); outbound section 6 now READS `_motion.RawState` (commands + per-axis
|
|
||||||
hold keys) instead of re-deriving from `input` — wire bytes identical (golden-byte
|
|
||||||
packer tests prove it).
|
|
||||||
- **GameWindow call sites that change:** `UpdatePlayerAnimation(result)` (:10062-10234)
|
|
||||||
DELETED — the player's AnimatedEntity sequencer/manager is bound to the player's
|
|
||||||
MotionInterpreter via the same funnel/PerformMovement path remotes use (R2-Q5
|
|
||||||
sink shape); the airborne-Falling override falls out of contact_allows_move +
|
|
||||||
apply_current_movement (retail mechanism, already live in the funnel); the #45
|
|
||||||
sidestep animSpeed factor + ACDREAM_ANIM_SPEED_SCALE knob move into (or are
|
|
||||||
re-verified against) the CMotionTable-driven speedMod path — expected-diff
|
|
||||||
annotations required; :4344 `ApplyServerRunRate` retargets to `my_run_rate` +
|
|
||||||
`apply_current_movement` (same effect, no InterpretedState poke); :7953-7971
|
|
||||||
JumpAction build reads the W4-unified launch vector; :7975 call deleted with
|
|
||||||
UpdatePlayerAnimation.
|
|
||||||
Tests first: FULL suite green; pre-cutover recorded local-player SetCycle/dispatch
|
|
||||||
traces (captured BEFORE this commit under the W0 protocol) replayed → same cycle
|
|
||||||
identities + speeds, expected-diffs documented (Falling engage now via
|
|
||||||
link-strip; sidestep factor source); outbound golden-byte parity (MoveToState/AP/Jump
|
|
||||||
byte-identical for the same input script); live smoke ACDREAM_DUMP_MOTION +
|
|
||||||
observer view; **ONE user visual pass** (walk/run/toggle/strafe/turn/jump instant
|
|
||||||
Falling/landing/emote-mid-run — the stage acceptance).
|
|
||||||
Fixture source: **pre-cutover recorded traces + golden-byte + suite**. Deps: W5.
|
|
||||||
|
|
||||||
**W7 — register sweep + roadmap + digest (docs/cleanup only).**
|
|
||||||
Retired rows: MotionDone-unconsumed (R2's), K-fix18, TS-34 (adjust_motion guard),
|
|
||||||
plus the S2a StandingLongJump-misattribution note. Surviving rows (verify/add):
|
|
||||||
TS-5 (CanJump always-true → now ALSO JumpStaminaCost stub), AP-24 (charge rate),
|
|
||||||
TS-24 updated (actions now populated locally — packing next), TS-21/TS-23/AP-30
|
|
||||||
(re-anchor line numbers after controller edits), NEW rows: unstick_from_object no-op
|
|
||||||
seam (→R5 StickyManager), interrupt_current_movement/cancel_moveto no-op (→R4),
|
|
||||||
grounded velocity-from-state write (→R6 root motion), managed LinkedList<MotionNode>
|
|
||||||
vs LList (AD-34 wording), A5/A6 pinned-reading notes if either resolves against ACE.
|
|
||||||
Roadmap stage table (R3 shipped); milestones "currently working toward" check; memory
|
|
||||||
digest note (animation-sequencer deep-dive: pending_motions gap CLOSED end-to-end).
|
|
||||||
Deps: W6.
|
|
||||||
|
|
||||||
Parallelization: W0∥W1 only. W2→W3→W4→W5→W6 are sequential-coupled (queue → gates
|
|
||||||
using queue → transitions using gates → dispatchers using all → cutover). W4 and W6
|
|
||||||
both touch GameWindow — single-agent each.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. IMotionDoneSink → MotionInterpreter.MotionDone — exact wiring vs R2's seam
|
|
||||||
|
|
||||||
R2's contract (r2-port-plan §4) delivers: `CSequence` G5 gate → `AnimationDoneSentinel`
|
|
||||||
→ GameWindow anim tick drain (:9876-9890 area) → `manager.AnimationDone(true)` per
|
|
||||||
sentinel + `manager.UseTime()` once per tick → `MotionTableManager` countdown pop →
|
|
||||||
action-class head pops the MANAGER's own `MotionState.RemoveActionHead()` (R2-OWNED)
|
|
||||||
→ `IMotionDoneSink.MotionDone(uint motion, bool success)` — bound in R2-Q4 to a
|
|
||||||
diagnostic recorder.
|
|
||||||
|
|
||||||
R3-W2 replaces the recorder:
|
|
||||||
|
|
||||||
```
|
|
||||||
MotionTableManager (per entity) [R2-Q3, exists]
|
|
||||||
└─ IMotionDoneSink.MotionDone(motion, success) [R2 seam — signature UNCHANGED]
|
|
||||||
└─ R3: the entity's MotionInterpreter.MotionDone(motion, success)
|
|
||||||
[stands in for retail CPhysicsObj::MotionDone 0x0050fdb0 →
|
|
||||||
MovementManager::MotionDone 0x005242d0 → CMotionInterp::MotionDone
|
|
||||||
0x00527ec0 — both intermediates are null-guarded relays with ZERO
|
|
||||||
logic; R5 inserts MovementManager between manager and interp
|
|
||||||
WITHOUT changing this behavior]
|
|
||||||
body: head-peek pending_motions;
|
|
||||||
head.Motion & 0x10000000 → unstick seam (no-op → R5)
|
|
||||||
+ InterpretedState.RemoveAction()
|
|
||||||
+ RawState.RemoveAction();
|
|
||||||
pop head UNCONDITIONALLY (A7: motion id + success are IGNORED —
|
|
||||||
positional protocol, never match-by-id, never branch on success)
|
|
||||||
```
|
|
||||||
|
|
||||||
Binding points (App, per entity, at creation):
|
|
||||||
- **Remote:** where `RemoteMotion` is constructed (each remote already owns a
|
|
||||||
`MotionInterpreter` — `remoteMot.Motion`, GameWindow.cs:4646): the same construction
|
|
||||||
that gives the entity its MotionTableManager (R2-Q4/Q5) passes
|
|
||||||
`sink: remoteMot.Motion` (MotionInterpreter implements IMotionDoneSink directly).
|
|
||||||
- **Local player:** `PlayerMovementController` exposes its `MotionInterpreter`
|
|
||||||
(new internal property `Motion`); GameWindow binds the player entity's manager to it
|
|
||||||
when the player's AnimatedEntity/sequencer is created (same site as
|
|
||||||
`AttachCycleVelocityAccessor`, :384).
|
|
||||||
|
|
||||||
Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b):
|
|
||||||
1. **Two queues, never merged**: `MotionTableManager.pending_animations` (CPartArray
|
|
||||||
side, duration-in-ticks) vs `CMotionInterp.pending_motions` (movement side,
|
|
||||||
waiting-for-callback). THREE action trackers stay in lockstep via the 0x10000000
|
|
||||||
bit: manager's MotionState action FIFO (popped in R2's AnimationDone),
|
|
||||||
InterpretedState.actions + RawState.actions (popped in R3's MotionDone).
|
|
||||||
2. **Tick placement provisional until R6**: the sentinel drain + UseTime stay at the
|
|
||||||
R2-documented GameWindow drain point (G6 seam). R3 adds only the SYNCHRONOUS flush:
|
|
||||||
`CMotionInterp.PerformMovement` calls the manager's CheckForCompletedMotions after
|
|
||||||
every op (W5), which fires MotionDone same-call for zero-tick nodes.
|
|
||||||
|
|
||||||
Success-flag adjudication (closes r2-plan §4's warning): retail
|
|
||||||
`CMotionInterp::MotionDone` never reads the flag in this build — R3's jump/HitGround
|
|
||||||
logic does NOT key off it after all. The flag's only real consumers are
|
|
||||||
server/weenie-side (`OnMotionDone`) and the R2 manager's own countdown semantics.
|
|
||||||
Preserve pass-through (true from Hook_AnimDone, false from world drains, hardcoded
|
|
||||||
true from CheckForCompletedMotions) for R5 signature parity; document as inert at the
|
|
||||||
interp.
|
|
||||||
|
|
||||||
Enter/exit world: exit paths call `manager.HandleExitWorld()` (drains
|
|
||||||
pending_animations, each firing sink.MotionDone(motion, false) → interp pops in step)
|
|
||||||
THEN `interp.HandleExitWorld()` (drains any remainder — retail runs both layers'
|
|
||||||
drains via CPartArray::HandleExitWorld and MovementManager::HandleExitWorld
|
|
||||||
independently). Enter-world: manager-side only (`remove_all_link_animations` + drain);
|
|
||||||
`MovementManager::HandleEnterWorld`'s interp relay is the mis-symbolicated tailcall
|
|
||||||
(decomp §6e) — CMotionInterp has NO HandleEnterWorld; do not invent one (negative
|
|
||||||
result, decomp §6e/§1d).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
|
|
||||||
|
|
||||||
- `CPhysicsObj::IsAnimating` / `IsMovingOrAnimating` — **not in this PDB** (decomp
|
|
||||||
§7f). ACE's `PhysicsObj.IsAnimating` bool is an ACE-ism. acdream queries
|
|
||||||
`motions_pending()` / `is_standing_still()` / `CPartArray.HasAnims` individually.
|
|
||||||
- `MovementManager::HandleUpdateTarget` — not in this PDB (§6j); likely
|
|
||||||
MoveToManager-internal (R4). Do not stub.
|
|
||||||
- `CMotionInterp::HandleEnterWorld` — does not exist (§6e); enter-world work is
|
|
||||||
manager/CPartArray-side only.
|
|
||||||
- `MakeMoveToManager` / MoveToManager internals — R4. R3 leaves the B.6 auto-walk +
|
|
||||||
`interrupt_current_movement`/`cancel_moveto` as registered no-op seams.
|
|
||||||
- `NoticeHandler::RecvNotice_PrevSpellSelection` (HandleEnterWorld/ReportExhaustion
|
|
||||||
relays) — retail spell-UI notice ACE never ported; out of R3 scope; file a research
|
|
||||||
note only (no ACE reference exists — decomp-direct if ever needed).
|
|
||||||
- `LeaveGround` address is **0x00528b00** — the 0x00529710 in MotionInterpreter.cs's
|
|
||||||
doc comment (:1153, :1159) is a stale Ghidra-chunk guess; W4 fixes the citations
|
|
||||||
(several header comments cite old FUN_ addresses — sweep them to named symbols).
|
|
||||||
|
|
@ -1,726 +0,0 @@
|
||||||
# R3-W6 cutover-execution-map — local player motion unification
|
|
||||||
|
|
||||||
Research-only doc. Re-anchors `r3-port-plan.md` §3 W6 (which cites stale
|
|
||||||
line numbers) against the CURRENT code, post-W1-W5. Every anchor below was
|
|
||||||
read directly from the working tree at HEAD (`e214acdf`) during this
|
|
||||||
session — no line number here is inherited from the plan.
|
|
||||||
|
|
||||||
Inputs read: `r3-port-plan.md` §3 W6 + §1 row J15 + §4 (sink wiring
|
|
||||||
contract); `r3-motioninterp-decomp.md` §5c/§8 (`set_hold_run` callers);
|
|
||||||
`src/AcDream.App/Input/PlayerMovementController.cs` (full file, 1547
|
|
||||||
lines); `src/AcDream.App/Rendering/GameWindow.cs` (targeted sections via
|
|
||||||
subagent + direct reads); `src/AcDream.Core/Physics/MotionInterpreter.cs`
|
|
||||||
(full file, 3083 lines); `src/AcDream.Core/Physics/RawMotionState.cs`;
|
|
||||||
`src/AcDream.Core/Physics/Motion/MovementParameters.cs`; the test tree.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §1 Current-state inventory — every member/block that DIES
|
|
||||||
|
|
||||||
### 1a. Per-frame `RawMotionState` rebuild + `apply_raw_movement(RawMotionState)` call
|
|
||||||
|
|
||||||
**`PlayerMovementController.cs:892-924`** (comment block + body, inside
|
|
||||||
`Update`). Every frame, regardless of whether input changed, builds a
|
|
||||||
fresh `RawMotionState` from `MovementInput` and pushes it through the
|
|
||||||
snapshot-consuming overload:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var axisHold = input.Run ? HoldKey.Run : HoldKey.None;
|
|
||||||
var raw = new RawMotionState { CurrentHoldKey = axisHold, ForwardCommand = ..., ... };
|
|
||||||
_motion.apply_raw_movement(raw);
|
|
||||||
```
|
|
||||||
|
|
||||||
This is retail's `apply_raw_movement(RawMotionState raw)` overload
|
|
||||||
(`MotionInterpreter.cs:1346-1371`) — it copies 6 fields into
|
|
||||||
`InterpretedState` and normalizes each axis via `adjust_motion`, but does
|
|
||||||
**not** drive velocity or dispatch through a sink. It's a **level-triggered**
|
|
||||||
substitute for retail's **edge-triggered** `DoMotion`/`StopMotion`/
|
|
||||||
`set_hold_run` calls that mutate `RawState` incrementally via
|
|
||||||
`RawMotionState.ApplyMotion`/`RemoveMotion`. DIES in W6 — replaced by §2's
|
|
||||||
edge table.
|
|
||||||
|
|
||||||
### 1b. Section 2 grounded-velocity write (STAYS, not a W6 kill — flagged for clarity)
|
|
||||||
|
|
||||||
**`PlayerMovementController.cs:962-991`**. Reads
|
|
||||||
`_motion.get_state_velocity()` and writes `_body.set_local_velocity(...)`
|
|
||||||
when grounded. The plan (§3 W6 "STAYS") keeps this — it's the R6-deferred
|
|
||||||
"grounded velocity from state" adaptation (register row). Listed here only
|
|
||||||
so the doc distinguishes it from 1a, which it's textually adjacent to.
|
|
||||||
|
|
||||||
### 1c. Jump charge/fire block
|
|
||||||
|
|
||||||
**`PlayerMovementController.cs:993-1043`**. Charge accumulates via
|
|
||||||
`_jumpCharging`/`_jumpExtent` (unchanged — AP-24, stays controller-side).
|
|
||||||
On release, calls:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var jumpResult = _motion.jump(_jumpExtent);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Confirmed gap (not in the plan's text, found this session):**
|
|
||||||
`MotionInterpreter.ChargeJump()` (`MotionInterpreter.cs:1765-1789`, the
|
|
||||||
verbatim `charge_jump` port, the ONLY place `StandingLongJump` arms) is
|
|
||||||
**never called** by the controller today — only by
|
|
||||||
`MotionInterpreterJumpFamilyTests.cs`. The doc comment at
|
|
||||||
`MotionInterpreter.cs:1756-1762` says outright: *"no regression today
|
|
||||||
since nothing calls ChargeJump yet, so remotes/local both continue
|
|
||||||
unaffected."* W6 must add a `ChargeJump()` call at charge-START (the
|
|
||||||
retail caller is the SmartBox/input boundary at `0x0056afac`, out of
|
|
||||||
CMotionInterp's own scope but exactly where the controller's charge
|
|
||||||
block lives). Without this, `StandingLongJump` never arms for the local
|
|
||||||
player post-cutover either — same bug, just moved. See §6 risk R1.
|
|
||||||
|
|
||||||
The manual `LeaveGround()` pre-capture this block used to need was
|
|
||||||
**already deleted in W4** (comment at `:1016-1021` confirms: "the manual
|
|
||||||
LeaveGround call is DELETED — jump() clears OnWalkable, and the SAME
|
|
||||||
frame's grounded→airborne edge... fires `_motion.LeaveGround()`"). This
|
|
||||||
part of the plan's W6 description is stale — it's W4-done, not a W6 task.
|
|
||||||
|
|
||||||
### 1d. `LocalAnimationCommand` / `LocalAnimationSpeed` synthesis
|
|
||||||
|
|
||||||
**`MovementResult` record fields**: `PlayerMovementController.cs:55`
|
|
||||||
(`LocalAnimationCommand`) and `:62` (`LocalAnimationSpeed`), part of the
|
|
||||||
record at `:42-65`.
|
|
||||||
|
|
||||||
**Synthesis sites**:
|
|
||||||
- `:1308-1322` — `localAnimCmd` set from `input.Forward`/`input.Backward`
|
|
||||||
+ `_weenie.InqRunRate` (Run cycle selection independent of wire command).
|
|
||||||
- `:1443-1481` — K-fix5 `localAnimSpeed` computation (run-rate pacing for
|
|
||||||
Backward+Run/Strafe+Run) + auto-walk override block
|
|
||||||
(`_autoWalkMovingForwardThisFrame`/`_autoWalkTurnDirectionThisFrame`
|
|
||||||
branches overwrite `localAnimCmd`/`localAnimSpeed`).
|
|
||||||
- `:1355-1377` — change-detection block includes `localAnimCmd !=
|
|
||||||
_prevLocalAnimCmd` as a `changed` trigger (7th line of the OR-chain,
|
|
||||||
`:1371`) and tracks `_prevLocalAnimCmd` (`:191`, `:1377`).
|
|
||||||
- `:1503-1504` — final `MovementResult` construction passes both fields
|
|
||||||
through.
|
|
||||||
|
|
||||||
DIES: the two fields, all three synthesis sites, and the
|
|
||||||
`_prevLocalAnimCmd` change-detection leg (the OTHER six `changed`
|
|
||||||
triggers survive unchanged — see §5).
|
|
||||||
|
|
||||||
### 1e. `SetPosition`'s arrival-idle `DoMotion(Ready)`
|
|
||||||
|
|
||||||
**`PlayerMovementController.cs:839`**, inside `SetPosition(Vector3, uint,
|
|
||||||
Vector3)`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
_motion.DoMotion(MotionCommand.Ready, 1.0f);
|
|
||||||
```
|
|
||||||
|
|
||||||
Per the plan: "the `:825` `DoMotion(Ready)` arrival-idle becomes
|
|
||||||
`StopCompletely()` — retail's teleport idle is a full stop." Confirmed
|
|
||||||
`StopCompletely()` (`MotionInterpreter.cs:1054-1084`) is the correct
|
|
||||||
retail-faithful substitute: it resets forward/sidestep/turn COMMANDS
|
|
||||||
(not speeds, per J9), zeros `PhysicsObj.set_velocity`, enqueues the A9
|
|
||||||
jump-snapshot node, and fires `RemoveLinkAnimations` if uncelled — a
|
|
||||||
strictly more complete reset than the current one-field `DoMotion(Ready)`
|
|
||||||
call. Same substitution applies to the identical call inside
|
|
||||||
`DriveServerAutoWalk`'s turn-in-place branch (`:732`) — **the plan does
|
|
||||||
NOT call this one out**, but it is textually the same pattern
|
|
||||||
(`_motion.DoMotion(MotionCommand.Ready, 1.0f)`) serving the same "go
|
|
||||||
idle" role mid-auto-walk. Flagged as a judgment call in §6 (R4) — recommend
|
|
||||||
leaving `:732` as `DoMotion(Ready)` since auto-walk survives R3 verbatim
|
|
||||||
per the KEEP LIST and StopCompletely's jump-snapshot/queue side effects
|
|
||||||
are not obviously wanted mid-auto-walk-turn.
|
|
||||||
|
|
||||||
### 1f. Shift/Run detection
|
|
||||||
|
|
||||||
**Today**: `input.Run` is read directly into `axisHold` (1a) every frame
|
|
||||||
— no edge detection exists; the level-triggered rebuild makes edge
|
|
||||||
detection moot today. W6 replaces this with an explicit prev-Shift-state
|
|
||||||
edge feeding `set_hold_run(bool holdingRun, bool interrupt)`
|
|
||||||
(`MotionInterpreter.cs:2453-2463`), which itself has an internal XOR
|
|
||||||
no-op guard (so calling it every frame with the same value is safe, but
|
|
||||||
the controller should still gate on an edge per the plan's "tiny
|
|
||||||
prev-key-state edge detector").
|
|
||||||
|
|
||||||
### 1g. Outbound section 6 — re-derivation from `input`
|
|
||||||
|
|
||||||
**`PlayerMovementController.cs:1288-1354`** (`outForwardCmd`/
|
|
||||||
`outForwardSpeed`/`outSidestepCmd`/etc.) re-derives the wire commands
|
|
||||||
from `MovementInput` fields (`input.Forward`, `input.StrafeRight`, etc.)
|
|
||||||
completely independently of `_motion.RawState`, which by this point in
|
|
||||||
the frame (post 1a) already holds equivalent values. This is redundant
|
|
||||||
computation that DIES — the outbound-wire-build in GameWindow (§4/§5)
|
|
||||||
should read `_motion.RawState` (now the SAME instance the controller has
|
|
||||||
been mutating via `DoMotion`/`StopMotion`/`set_hold_run`) directly instead
|
|
||||||
of `MovementResult.ForwardCommand`/etc. **Note**: this doc treats "which
|
|
||||||
fields of `MovementResult` survive" as a §5 design decision, not a
|
|
||||||
foregone conclusion — see §5.
|
|
||||||
|
|
||||||
### 1h. GameWindow: `UpdatePlayerAnimation` (DELETE) + its caller
|
|
||||||
|
|
||||||
**Definition**: `GameWindow.cs:10139-10307` (169 lines).
|
|
||||||
**Single caller**: `GameWindow.cs:8006`, inside `OnUpdate`'s
|
|
||||||
`if (_playerController is not null)` block, immediately after the
|
|
||||||
JumpAction wire-send block (`:7984-8002`).
|
|
||||||
|
|
||||||
Reads `result.IsOnGround` (airborne→Falling override, `:10164-10165`),
|
|
||||||
`result.LocalAnimationCommand` (`:10166`, preferred source),
|
|
||||||
`result.ForwardCommand`/`SidestepCommand`/`TurnCommand` (`:10168-10172`,
|
|
||||||
fallback chain when `LocalAnimationCommand` is null),
|
|
||||||
`result.LocalAnimationSpeed` (`:10186`, `:10255-10257`, feeds `SetCycle`).
|
|
||||||
Calls `ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed *
|
|
||||||
animScale)` directly (`:10297`) — this is a **parallel path** to
|
|
||||||
`MotionTableDispatchSink.ApplyMotion` that remotes use; it does NOT go
|
|
||||||
through `_playerController.Motion` at all. DIES in full — the player's
|
|
||||||
`AnimatedEntity.Sequencer` gets driven through the SAME
|
|
||||||
`MotionTableDispatchSink` → `DefaultSink` funnel remotes use (§3).
|
|
||||||
|
|
||||||
### 1i. `input.` reads for MOTION (not camera) — full inventory
|
|
||||||
|
|
||||||
All in `PlayerMovementController.Update`, all replaced by edge calls
|
|
||||||
except where noted:
|
|
||||||
- `input.Forward`/`input.Backward` (`:907-909`, `:1309-1322`) → W press/S
|
|
||||||
press/release edges.
|
|
||||||
- `input.StrafeLeft`/`input.StrafeRight` (`:912-914`, `:1328-1337`) →
|
|
||||||
strafe press/release edges.
|
|
||||||
- `input.TurnLeft`/`input.TurnRight` (`:917-919`, `:1344-1353`) → turn
|
|
||||||
press/release edges. **Note**: `input.TurnRight`/`TurnLeft` ALSO drive
|
|
||||||
Yaw integration directly at `:946-951` — that consumption STAYS (reads
|
|
||||||
`_motion.InterpretedState.TurnCommand`, unchanged per the plan).
|
|
||||||
- `input.Run` (`:903`, `:910`, `:915`, `:920`, `:1313`, `:1365`,
|
|
||||||
`:1445-1446`, `:1502`) → collapses to the Shift edge → `set_hold_run`.
|
|
||||||
- `input.Jump` (`:1000`) → STAYS as the charge/fire trigger (unchanged
|
|
||||||
shape, gains the `ChargeJump()` call per 1c).
|
|
||||||
- `input.MouseDeltaX` (`:952`) → STAYS (camera/Yaw only, never a motion
|
|
||||||
command — deliberately, per the existing comment on why mouse-turn
|
|
||||||
generates no wire command).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §2 The edge-detector design
|
|
||||||
|
|
||||||
### 2a. Minimal prev-state
|
|
||||||
|
|
||||||
The controller needs exactly 6 booleans (one per discrete axis-direction)
|
|
||||||
plus the existing Shift/Run bool, replacing the current
|
|
||||||
`_prevForwardCmd`/`_prevSidestepCmd`/`_prevTurnCmd`/`_prevForwardSpeed`/
|
|
||||||
`_prevRunHold`/`_prevLocalAnimCmd` fields (`:186-191`) which tracked
|
|
||||||
OUTPUT state for change-detection. The new fields track INPUT state for
|
|
||||||
edge-detection:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private bool _prevForwardHeld; // input.Forward
|
|
||||||
private bool _prevBackwardHeld; // input.Backward
|
|
||||||
private bool _prevStrafeLeftHeld;
|
|
||||||
private bool _prevStrafeRightHeld;
|
|
||||||
private bool _prevTurnLeftHeld;
|
|
||||||
private bool _prevTurnRightHeld;
|
|
||||||
private bool _prevRunHeld; // input.Run (Shift)
|
|
||||||
```
|
|
||||||
|
|
||||||
Six of the seven map 1:1 onto retail's per-axis `RawMotionState` channels
|
|
||||||
(forward, sidestep, turn — each a single "current command" slot, so
|
|
||||||
Forward-press-while-Backward-held needs the SAME "switch channel" handling
|
|
||||||
`ApplyMotion` already does verbatim — no new logic needed, just route
|
|
||||||
both to the forward channel).
|
|
||||||
|
|
||||||
### 2b. Edge → call table
|
|
||||||
|
|
||||||
| Edge | Call | MovementParameters |
|
|
||||||
|---|---|---|
|
|
||||||
| Forward pressed (was false, now true) | `DoMotion(MotionCommand.WalkForward, p)` | ctor defaults (`new MovementParameters()`) — `Speed=1f` is the retail raw speed (ACE recomputes broadcast speed; D6.2b finding already established this). |
|
|
||||||
| Forward released (was true, now false), AND Backward not held | `StopMotion(MotionCommand.WalkForward, p)` | ctor defaults. |
|
|
||||||
| Backward pressed | `DoMotion(MotionCommand.WalkBackward, p)` | ctor defaults (`adjust_motion` applies `BackwardsFactor` — unchanged, already verbatim). |
|
|
||||||
| Backward released, Forward not held | `StopMotion(MotionCommand.WalkBackward, p)` | ctor defaults. |
|
|
||||||
| StrafeRight pressed | `DoMotion(MotionCommand.SideStepRight, p)` | ctor defaults. |
|
|
||||||
| StrafeLeft pressed | `DoMotion(MotionCommand.SideStepLeft, p)` | ctor defaults. |
|
|
||||||
| Strafe released (both false) | `StopMotion(MotionCommand.SideStepRight, p)` (retail always stops via the RIGHT id per `adjust_motion`'s SideStepLeft→SideStepRight canonicalization — confirmed in `RawMotionState.ApplyMotion`'s switch, which treats `0x6500000f`/`0x65000010` as the same channel) | ctor defaults. |
|
|
||||||
| TurnRight pressed | `DoMotion(MotionCommand.TurnRight, p)` | ctor defaults. |
|
|
||||||
| TurnLeft pressed | `DoMotion(MotionCommand.TurnLeft, p)` | ctor defaults. |
|
|
||||||
| Turn released (both false) | `StopMotion(MotionCommand.TurnRight, p)` | ctor defaults. |
|
|
||||||
| Shift edge (any transition) | `set_hold_run(input.Run, interrupt: true)` | N/A — direct call, no `MovementParameters`. `interrupt:true` matches the decomp's SECOND caller (`006b33ca`, `arg3=1`, explicitly the "player input/command layer" call site per `r3-motioninterp-decomp.md:1001`) — the FIRST caller (`0058b303`) omits arg3 in the decomp text (ambiguous, likely also non-zero given calling convention defaults per the decomp's own hedge) and is not the player-input site anyway. |
|
|
||||||
| Jump release (existing charge-fire block) | `_motion.jump(_jumpExtent)` — UNCHANGED call, only gains the `ChargeJump()` call at charge-START (see §1c/§6 R1) | N/A — `jump(float, int)` takes no `MovementParameters`. |
|
|
||||||
| Auto-walk `DoMotion(Ready)`/`DoMotion(WalkForward)` (`:732`, `:767`) | UNCHANGED — plan explicitly keeps this. | 2-arg compat overload, unchanged. |
|
|
||||||
|
|
||||||
**TODO flag (per the task's own instruction)**: the decomp does not show
|
|
||||||
a `DoMotion`/`StopMotion` call site at the CommandInterpreter boundary —
|
|
||||||
only `set_hold_run`'s two callers are visible in
|
|
||||||
`r3-motioninterp-decomp.md` §5c/§8 (`0058b303`/`006b33ca`). The
|
|
||||||
DoMotion/StopMotion edge calls above are this doc's inference from the
|
|
||||||
verbatim `DoMotion`/`StopMotion` bodies' own defaulting behavior (both
|
|
||||||
build a **fresh** `MovementParameters` internally for the
|
|
||||||
`DoInterpretedMotion`/`StopInterpretedMotion` tail-call regardless of what
|
|
||||||
the caller passes — see `MotionInterpreter.cs:915`, `:992` — so the
|
|
||||||
caller-supplied `p`'s only LIVE effects are `CancelMoveTo` (interrupt),
|
|
||||||
`SetHoldKey`+`HoldKeyToApply` (hold-key push), `Speed`, and
|
|
||||||
`ModifyRawState`/`ModifyInterpretedState` gates). Ctor defaults
|
|
||||||
(`CancelMoveTo=true, SetHoldKey=true, ModifyRawState=true,
|
|
||||||
ModifyInterpretedState=true, Speed=1f, HoldKeyToApply=Invalid`) are the
|
|
||||||
right choice UNLESS retail's real CommandInterpreter caller passes
|
|
||||||
`HoldKeyToApply` per-axis (plausible for Run) — **TODO(W6 impl): verify
|
|
||||||
against a fresh cdb capture of the CommandInterpreter boundary before
|
|
||||||
implementing**, per the task's own note. This doc marks it a TODO rather
|
|
||||||
than guessing further, consistent with CLAUDE.md's "do not guess" rule.
|
|
||||||
|
|
||||||
### 2c. Why `DoMotion`'s own re-defaulting makes this safe
|
|
||||||
|
|
||||||
`DoMotion(motion, p)` (`MotionInterpreter.cs:908-949`) builds `var local =
|
|
||||||
new MovementParameters()` (fresh re-default) and only carries
|
|
||||||
`local.Speed = speed` and `local.HoldKeyToApply = p.HoldKeyToApply`
|
|
||||||
through to `DoInterpretedMotion`. The CALLER's `p` only matters for:
|
|
||||||
`p.CancelMoveTo` (interrupt-before-dispatch), `p.SetHoldKey` (whether to
|
|
||||||
call `SetHoldKey` first), `p.ModifyRawState` (whether the raw-state mirror
|
|
||||||
runs at the end). This means the controller passing ctor-default
|
|
||||||
`MovementParameters` per edge is not a simplification that loses
|
|
||||||
information — it is EXACTLY what retail's own `DoMotion` does internally
|
|
||||||
regardless of caller intent, for every field except the four above. Low
|
|
||||||
risk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §3 The player sequencer drive — tracing DefaultSink to the sequencer
|
|
||||||
|
|
||||||
### 3a. Call path confirmed present, ONE binding missing
|
|
||||||
|
|
||||||
Full trace, verified directly in `MotionInterpreter.cs`:
|
|
||||||
|
|
||||||
1. Controller calls `DoMotion(motion, p)` (edge) →
|
|
||||||
2. `DoMotion` → `DoInterpretedMotion(motion, local)` (`:943`) →
|
|
||||||
3. `DoInterpretedMotion(uint, MovementParameters)` (`:2837-2838`) →
|
|
||||||
`DoInterpretedMotion(motion, p, DefaultSink)` (the sink-parameterized
|
|
||||||
core) →
|
|
||||||
4. Inside (`:2850-2947`): `sink?.ApplyMotion(motion, p.Speed)` (`:2884`)
|
|
||||||
— **this is the dispatch to the animation backend**.
|
|
||||||
|
|
||||||
Separately, for the CONTINUOUS axes (forward/sidestep/turn re-applied
|
|
||||||
every physics event, e.g. on `LeaveGround`/`HitGround`/`set_hold_run`):
|
|
||||||
|
|
||||||
1. `set_hold_run`/`SetHoldKey`/`LeaveGround`/`HitGround` all call
|
|
||||||
`apply_current_movement(cancelMoveTo, allowJump)` (`:1404-1417`) →
|
|
||||||
2. Dual-dispatch gate (A3): `isThePlayer = WeenieObj is null ||
|
|
||||||
WeenieObj.IsThePlayer()`. Local player: `WeenieObj` is
|
|
||||||
`PlayerWeenie` (constructed at `PlayerMovementController.cs:354`,
|
|
||||||
presumably `IsThePlayer() == true` — **needs one-line confirmation in
|
|
||||||
IWeenieObject/PlayerWeenie, not re-derived here**) AND
|
|
||||||
`PhysicsObj.LastMoveWasAutonomous` is set `true` at construction
|
|
||||||
(`PlayerMovementController.cs:360`, with the comment "R3-W6 refines
|
|
||||||
this per-motion") → routes to `apply_raw_movement(cancelMoveTo,
|
|
||||||
allowJump)` (the bool-arg overload, `:1443-1450`) →
|
|
||||||
3. That overload calls `apply_raw_movement(RawState)` (mutates
|
|
||||||
`InterpretedState` from the interpreter's OWN `RawState` — no longer
|
|
||||||
an externally-built snapshot, this is the key W6 shift) then
|
|
||||||
`ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump)` (`:1449`) →
|
|
||||||
4. `ApplyCurrentMovementInterpreted` (`:1491-1536`): `if (DefaultSink is
|
|
||||||
not null) { ApplyInterpretedMovement(InterpretedState.CurrentStyle,
|
|
||||||
DefaultSink, cancelMoveTo, allowJump); return; }` (`:1502-1506`) —
|
|
||||||
**this IS the full funnel dispatch** (style → forward-or-Falling →
|
|
||||||
sidestep(-stop) → turn(-stop)), the SAME `ApplyInterpretedMovement`
|
|
||||||
the inbound remote funnel uses and the SAME dispatch-order the S2a
|
|
||||||
183-case suite pins.
|
|
||||||
|
|
||||||
**CONFIRMED: the call path from an edge-driven `DoMotion`/`set_hold_run`
|
|
||||||
all the way to `AnimationSequencer.SetCycle`/`PerformMovement` already
|
|
||||||
exists in Core, fully wired, contingent on ONE thing: `DefaultSink` must
|
|
||||||
be non-null for the player's `MotionInterpreter`.**
|
|
||||||
|
|
||||||
### 3b. The missing binding — confirmed via direct read + subagent cross-check
|
|
||||||
|
|
||||||
`MotionInterpreter.cs:1479-1489` doc comment states explicitly: *"Null
|
|
||||||
(the local player until R3-W6, interp-less entities) → the AP-77
|
|
||||||
physics-only tail."* Confirmed live: at
|
|
||||||
`GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), the player's
|
|
||||||
`_playerController.Motion` gets THREE of the four seams bound
|
|
||||||
(`RemoveLinkAnimations`, `InitializeMotionTables`,
|
|
||||||
`CheckForCompletedMotions`) but explicitly NOT `DefaultSink` — inline
|
|
||||||
comment: *"DefaultSink stays null until R3-W6 (UpdatePlayerAnimation
|
|
||||||
still drives the player's cycles)."*
|
|
||||||
|
|
||||||
Compare against the remote pattern, `EnsureRemoteMotionBindings`
|
|
||||||
(`GameWindow.cs:4195-4224`), called lazily from `OnLiveMotionUpdated`
|
|
||||||
(`:4679`) and `OnLiveVectorUpdated`(`:4859`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
rm.Sink = new MotionTableDispatchSink(ae.Sequencer)
|
|
||||||
{
|
|
||||||
TurnApplied = (turnMotion, turnSpeed) => { /* ObservedOmega seed */ },
|
|
||||||
TurnStopped = () => rmForSink.ObservedOmega = Vector3.Zero,
|
|
||||||
};
|
|
||||||
rm.Motion.DefaultSink = rm.Sink; // <-- MISSING for player
|
|
||||||
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
|
||||||
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
|
||||||
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
|
|
||||||
```
|
|
||||||
|
|
||||||
**W6 action**: at `GameWindow.cs:12987-13000` (`EnterPlayerModeNow`),
|
|
||||||
construct a `MotionTableDispatchSink(playerSeq)` and bind
|
|
||||||
`_playerController.Motion.DefaultSink = <that sink>` alongside the three
|
|
||||||
existing binds. The `TurnApplied`/`TurnStopped` callbacks exist for the
|
|
||||||
`ObservedOmega` remote dead-reckoning seed — the LOCAL player has no
|
|
||||||
`ObservedOmega` concept (it's a remote-only field on `RemoteMotion`), so
|
|
||||||
these two callbacks can be no-ops for the player's sink, OR omitted
|
|
||||||
entirely if `MotionTableDispatchSink`'s constructor doesn't require them
|
|
||||||
(confirm the class's actual required-vs-optional members before
|
|
||||||
implementing — not independently re-verified in this pass beyond the
|
|
||||||
subagent's citation of `MotionTableDispatchSink.cs:34-72`).
|
|
||||||
|
|
||||||
**Where to store the sink instance**: unlike `RemoteMotion` (a wrapper
|
|
||||||
struct with `.Sink`/`.Motion`/`.ObservedOmega` fields), the player has no
|
|
||||||
equivalent wrapper — `_playerController` plays that role directly. The
|
|
||||||
sink instance needs a home: either (a) a new field on
|
|
||||||
`PlayerMovementController` (parallels `Motion` being exposed there
|
|
||||||
already), or (b) a `GameWindow`-local field alongside the existing
|
|
||||||
`_playerController` field. Recommend (a) for symmetry with `Motion`'s
|
|
||||||
existing exposure pattern (`PlayerMovementController.cs:375`) — a new
|
|
||||||
`internal MotionTableDispatchSink? Sink { get; set; }` property, set from
|
|
||||||
`EnterPlayerModeNow`, mirrors `RemoteMotion.Sink` exactly.
|
|
||||||
|
|
||||||
### 3c. What this replaces
|
|
||||||
|
|
||||||
Once `DefaultSink` is bound, `UpdatePlayerAnimation`'s direct
|
|
||||||
`ae.Sequencer.SetCycle(...)` call becomes fully redundant — the funnel
|
|
||||||
dispatch (`ApplyInterpretedMovement` → `DefaultSink.ApplyMotion` →
|
|
||||||
`MotionTableDispatchSink.ApplyMotion` → `sequencer.PerformMovement`) now
|
|
||||||
fires on every edge-driven `DoMotion`/`StopMotion`/`set_hold_run`/
|
|
||||||
`LeaveGround`/`HitGround` call, exactly mirroring how remotes already
|
|
||||||
work. The airborne→Falling swap "falls out of `contact_allows_move` +
|
|
||||||
`apply_current_movement`" per the plan — confirmed: `ApplyInterpretedMovement`
|
|
||||||
(`:2731-2733`) already has `if (!contact_allows_move(...))
|
|
||||||
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);` as its
|
|
||||||
SECOND dispatch (unconditional check every call), so once the player's
|
|
||||||
`apply_current_movement` reaches this funnel on the LeaveGround edge, the
|
|
||||||
Falling swap happens automatically with no App-side airborne branch
|
|
||||||
needed at all — `UpdatePlayerAnimation`'s `:10164-10165` airborne
|
|
||||||
override becomes dead code, confirming full deletion is correct, not just
|
|
||||||
plumbing-safe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §4 GameWindow edit list
|
|
||||||
|
|
||||||
| # | Current anchor | What replaces it |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `UpdatePlayerAnimation` definition, `:10139-10307` | DELETE the method entirely. |
|
|
||||||
| 2 | `UpdatePlayerAnimation(result)` call, `:8006` | DELETE the call (the tail of the `if (_playerController is not null)` block in `OnUpdate`). |
|
|
||||||
| 3 | `EnterPlayerModeNow`, binding block `:12987-13000` | ADD the `DefaultSink` bind (§3b) as a 4th line alongside the existing 3 seam binds. |
|
|
||||||
| 4 | Outbound wire-build block, `:7893-7913` (fresh `RawMotionState` built from `MovementResult` fields) | Read `_playerController.Motion.RawState` directly instead of re-deriving. See §5 for exactly which fields still need `MovementResult` vs which now come from `RawState`. |
|
|
||||||
| 5 | `ApplyServerRunRate` call site, `OnLiveMotionUpdated` `:4373` (per subagent citation) | UNCHANGED — `ApplyServerRunRate` (`PlayerMovementController.cs:410-414`) already calls `_motion.apply_current_movement(false, false)`, which after 3b's bind now ALSO drives the sequencer via `DefaultSink` (a currently-inert call becomes live) — no GameWindow-side edit needed, but flag as a **behavior change**, not just plumbing (see §6 R2). |
|
|
||||||
| 6 | JumpAction wire-build block, `:7984-8002` | UNCHANGED — still reads `result.JumpExtent`/`result.JumpVelocity` from `MovementResult` (§5 keeps these fields; the jump path is not touched by the DoMotion/StopMotion edge redesign). |
|
|
||||||
| 7 | `OnLiveVectorUpdated`'s remote-jump `LeaveGround()`+`DefaultSink` pattern, `:4845-4864` | UNCHANGED — this is the ALREADY-MIGRATED reference pattern (W4/J19 closed) that item 3 mirrors for the player. Cited here only as the proof-of-pattern, not an edit site. |
|
|
||||||
|
|
||||||
Two GameWindow items the plan names that turned out to be **already
|
|
||||||
resolved, not pending W6 work** (found this session, not anticipated by
|
|
||||||
the plan text):
|
|
||||||
|
|
||||||
- **`skipTransitionLink`**: confirmed via repo-wide grep — zero
|
|
||||||
occurrences in any production `.cs` file. Fully deleted in W4. The
|
|
||||||
plan's W6 bullet listing this as something to delete is stale; no
|
|
||||||
action needed.
|
|
||||||
- **`ACDREAM_ANIM_SPEED_SCALE`/#45 sidestep factor**: both live entirely
|
|
||||||
inside the doomed `UpdatePlayerAnimation` (`:10258-10295`) — they die
|
|
||||||
WITH the method, they don't need a separate "move into the
|
|
||||||
CMotionTable-driven speedMod path" step, because there is no equivalent
|
|
||||||
concept upstream in `MotionTableDispatchSink`/`AnimationSequencer.
|
|
||||||
PerformMovement` today. See §6 R3 — this is a real behavior-loss risk,
|
|
||||||
not a mechanical relocation, and needs a decision before deletion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §5 Wire-parity plan
|
|
||||||
|
|
||||||
### 5a. Current outbound flow (pre-W6)
|
|
||||||
|
|
||||||
`GameWindow.cs:7893-7913` builds a FRESH `RawMotionState` from
|
|
||||||
`MovementResult.ForwardCommand`/`SidestepCommand`/`TurnCommand`/
|
|
||||||
`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`, then passes it to
|
|
||||||
`MoveToState.Build(...)` (`:7916`), which internally calls
|
|
||||||
`RawMotionStatePacker.Pack(w, rawMotionState)`
|
|
||||||
(`MoveToState.cs:80`).
|
|
||||||
|
|
||||||
### 5b. Post-W6 target flow
|
|
||||||
|
|
||||||
`_playerController.Motion.RawState` is, by construction, the SAME
|
|
||||||
`RawMotionState` instance the edge-driven `DoMotion`/`StopMotion` calls
|
|
||||||
have been mutating via `ApplyMotion`/`RemoveMotion` all frame (per J2's
|
|
||||||
W1 fold: `RawState` IS the packer-compatible type, action FIFO included).
|
|
||||||
GameWindow's outbound block should read directly from
|
|
||||||
`_playerController.Motion.RawState` instead of reconstructing one.
|
|
||||||
|
|
||||||
**Field-by-field disposition**:
|
|
||||||
|
|
||||||
| `RawMotionState` field | Source post-W6 | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `CurrentHoldKey` | `RawState.CurrentHoldKey`, set by `set_hold_run` edge | Was `result.IsRunning ? Run : None` — now the interpreter's own tracked value. |
|
|
||||||
| `ForwardCommand`/`ForwardHoldKey`/`ForwardSpeed` | `RawState.*`, set by `ApplyMotion`/`RemoveMotion` on the Forward/Backward edges | Was `result.ForwardCommand ?? Default`. |
|
|
||||||
| `SidestepCommand`/`SidestepHoldKey`/`SidestepSpeed` | `RawState.*` | Was `result.SidestepCommand ?? Default`. |
|
|
||||||
| `TurnCommand`/`TurnHoldKey`/`TurnSpeed` | `RawState.*` | Was `result.TurnCommand ?? Default`. |
|
|
||||||
| `Actions` | `RawState.Actions` | Was ALWAYS EMPTY pre-W1 (TS-24); post-W1 populated by `ApplyMotion`'s action-class branch — this is a genuine NEW behavior surfacing on the wire once W6 reads live `RawState` instead of a `MovementResult`-rebuilt empty-actions snapshot. Flag in §6 R5. |
|
|
||||||
|
|
||||||
**`MovementResult` fields that SURVIVE** (still needed by GameWindow for
|
|
||||||
non-wire purposes): `Position`, `RenderPosition`, `CellId`, `IsOnGround`
|
|
||||||
(camera/HUD), `JustLanded` (landing SFX/anim triggers if any exist
|
|
||||||
outside `UpdatePlayerAnimation` — verify no other consumer before
|
|
||||||
deleting used-only-by-UpdatePlayerAnimation fields), `JumpExtent`/
|
|
||||||
`JumpVelocity` (JumpAction wire build, item 6 in §4, untouched by this
|
|
||||||
redesign since jump keeps its existing dedicated path).
|
|
||||||
|
|
||||||
**`MovementResult` fields that DIE**: `LocalAnimationCommand`,
|
|
||||||
`LocalAnimationSpeed` (§1d). **Candidates for deletion pending §5c
|
|
||||||
decision**: `ForwardCommand`/`SidestepCommand`/`TurnCommand`/
|
|
||||||
`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`/
|
|
||||||
`MotionStateChanged` — these become redundant with `RawState` reads, but
|
|
||||||
deleting them is a larger surface change to `MovementResult`'s consumers
|
|
||||||
(the golden-byte tests test `RawMotionStatePacker`/`MoveToState`
|
|
||||||
directly, NOT `MovementResult`, so they're unaffected either way — see
|
|
||||||
5d). Recommend KEEPING these fields in `MovementResult` for this W6 slice
|
|
||||||
(mark `[Obsolete]` or comment "now diagnostic-only, GameWindow reads
|
|
||||||
RawState") rather than deleting, to keep the W6 diff scoped to the
|
|
||||||
motion-drive mechanism, not a `MovementResult` API redesign — a follow-up
|
|
||||||
cleanup commit can trim the struct once nothing reads it.
|
|
||||||
|
|
||||||
### 5c. `MotionStateChanged` gate — needs a replacement signal
|
|
||||||
|
|
||||||
`GameWindow.cs:7874` gates the whole outbound MoveToState build on
|
|
||||||
`result.MotionStateChanged && !_playerController.IsServerAutoWalking`.
|
|
||||||
Post-W6, `MovementResult.MotionStateChanged` (computed from the DYING
|
|
||||||
`_prevForwardCmd`/etc. output-comparison block, §1d) needs a replacement.
|
|
||||||
**Recommend**: derive change-detection from the edge table itself — ANY
|
|
||||||
edge fired this frame (forward/backward/strafe/turn/run edges, §2a) is
|
|
||||||
definitionally a state change; expose a `bool MotionChangedThisFrame`
|
|
||||||
computed from `_prevXHeld != inputXHeld` ORed across all axes, computed
|
|
||||||
in the SAME `Update` call, replacing the output-comparison approach with
|
|
||||||
an input-edge approach. This is a strictly simpler equivalent (retail's
|
|
||||||
CommandInterpreter only calls `DoMotion`/`StopMotion` ON edges in the
|
|
||||||
first place, so "an edge fired" and "state changed" are the same
|
|
||||||
predicate by construction — no separate comparison needed). Confirm this
|
|
||||||
doesn't regress the `runHold`-without-directional-edge case (Shift toggle
|
|
||||||
while already moving) — the edge table's Shift-edge row already covers
|
|
||||||
this (`set_hold_run` fires independently of the directional edges), so
|
|
||||||
OR-ing in the Shift edge too closes that case.
|
|
||||||
|
|
||||||
### 5d. Golden-byte tests — confirmed unaffected
|
|
||||||
|
|
||||||
`RawMotionStatePackTests.cs`, `MoveToStateGoldenTests.cs`,
|
|
||||||
`MoveToStateTests.cs`, `AutonomousPositionTests.cs`, `JumpActionTests.cs`,
|
|
||||||
`PositionPackTests.cs` (all under
|
|
||||||
`tests/AcDream.Core.Net.Tests/Messages/`) construct `RawMotionState`/call
|
|
||||||
`MoveToState.Build` with EXPLICIT field values directly — none reference
|
|
||||||
`PlayerMovementController` or `MovementResult`. They pin
|
|
||||||
`RawMotionStatePacker`'s bit-packing contract, which W6 does not touch
|
|
||||||
(only WHERE the packed values are sourced from changes). These stay green
|
|
||||||
as a structural guarantee, not something W6 needs to specifically verify
|
|
||||||
beyond "still compiles, still passes" — but a NEW test is needed for the
|
|
||||||
end-to-end path: build a `RawMotionState` via live `DoMotion`/`StopMotion`
|
|
||||||
edges, feed it to `RawMotionStatePacker.Pack`, and diff against the SAME
|
|
||||||
byte sequence the pre-cutover `MovementResult`-rebuilt path produced for
|
|
||||||
an identical input script (see §7).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §6 Risk list
|
|
||||||
|
|
||||||
**R1 — `ChargeJump()` never called (BLOCKER-adjacent discovery, not in
|
|
||||||
the plan text).** Confirmed via grep: `MotionInterpreter.ChargeJump()`
|
|
||||||
has zero production call sites — only `MotionInterpreterJumpFamilyTests.cs`
|
|
||||||
calls it. `StandingLongJump` therefore never arms for ANY entity today
|
|
||||||
(local or remote), despite W3 having fully ported the mechanism. W6's
|
|
||||||
jump-charge block (§1c) MUST add a `ChargeJump()` call when charge begins
|
|
||||||
(mirroring `input.Jump && !_jumpCharging` → also call `_motion.ChargeJump()`,
|
|
||||||
checking its `WeenieError` return the same way the fire-path already
|
|
||||||
checks `jump()`'s). Without this, the "standing long-jump" feature stays
|
|
||||||
silently dead post-cutover — same bug, just no longer excusable as "W3
|
|
||||||
hasn't wired the call site yet." **Recommendation: treat as in-scope for
|
|
||||||
W6, not a separate follow-up** — it's the SAME jump-charge block W6 is
|
|
||||||
already editing to add `ChargeJump`'s sibling gate calls.
|
|
||||||
|
|
||||||
**R2 — `ApplyServerRunRate`'s `apply_current_movement` call goes from
|
|
||||||
inert to live.** Pre-W6, `ApplyServerRunRate` (`PlayerMovementController.cs:
|
|
||||||
410-414`) calls `_motion.apply_current_movement(false, false)`, but
|
|
||||||
`DefaultSink` is null so `ApplyCurrentMovementInterpreted` falls through
|
|
||||||
to the AP-77 physics-only tail (a `get_state_velocity` → `set_local_velocity`
|
|
||||||
write, no sequencer touch). Post-W6 (once `DefaultSink` is bound, §3b),
|
|
||||||
the SAME call now ALSO dispatches through the funnel
|
|
||||||
(`ApplyInterpretedMovement` → `SetCycle`) on every server RunRate echo —
|
|
||||||
this could fire a redundant/premature `SetCycle` call on every
|
|
||||||
`UpdateMotion (0xF61C)` echo for the player's own guid, independent of
|
|
||||||
any local edge. **Recommendation**: verify this doesn't cause visible
|
|
||||||
cycle-restart flicker (retail's real `apply_current_movement` call here IS
|
|
||||||
supposed to re-dispatch — this may be CORRECT retail behavior surfacing
|
|
||||||
for the first time — but it's a genuine visual-risk item for the "ONE
|
|
||||||
user visual pass" acceptance test, not a mechanical no-op).
|
|
||||||
|
|
||||||
**R3 — `#45` sidestep factor + `ACDREAM_ANIM_SPEED_SCALE` have no
|
|
||||||
upstream equivalent.** Per §4, both die with `UpdatePlayerAnimation`
|
|
||||||
because `MotionTableDispatchSink.ApplyMotion`/`AnimationSequencer.
|
|
||||||
PerformMovement` (the remote path, now shared) has no known
|
|
||||||
sidestep-specific speed-scale multiplier or global visual-pacing knob.
|
|
||||||
Two sub-risks: (a) sidestep animation pacing may visibly change (the
|
|
||||||
1.248× factor matched ACE's `BroadcastMovement` wire formula — losing it
|
|
||||||
means the sidestep CYCLE plays at `SidestepAnimSpeed`-derived pace instead
|
|
||||||
of the wire-matched pace); (b) `ACDREAM_ANIM_SPEED_SCALE` (a debug/tuning
|
|
||||||
knob) simply stops having any effect for the local player post-cutover
|
|
||||||
(remotes never had it either, so this makes local and remote consistent —
|
|
||||||
arguably correct, but a functionality LOSS for whoever uses that env var).
|
|
||||||
**Recommendation**: confirm with the user whether (a) is an
|
|
||||||
expected-diff (remotes today do NOT get the 1.248× factor either — so
|
|
||||||
post-cutover the LOCAL player's sidestep pacing becomes consistent with
|
|
||||||
how remotes have always looked, which may in fact be the retail-faithful
|
|
||||||
answer the #45 comment's "matching ACE's wire formula" was compensating
|
|
||||||
for a DIFFERENT bug in) before deleting; document as an expected-diff in
|
|
||||||
the W6 commit either way, per the plan's own instruction ("expected-diff
|
|
||||||
annotations required").
|
|
||||||
|
|
||||||
**R4 — `DriveServerAutoWalk`'s `DoMotion(Ready)` at `:732` is not named
|
|
||||||
in the plan's W6 SetPosition-only StopCompletely note.** §1e flags this
|
|
||||||
textual sibling. Auto-walk survives R3 verbatim per the KEEP LIST, so
|
|
||||||
changing `:732` is OUT of scope — but a future reader diffing "every
|
|
||||||
`DoMotion(Ready)` call site" against the plan's single cited line (`:825`
|
|
||||||
old numbering) could reasonably assume both change. **Recommendation**:
|
|
||||||
leave `:732` untouched, but add a one-line comment there cross-referencing
|
|
||||||
this doc so the distinction is explicit at the code site, not just here.
|
|
||||||
|
|
||||||
**R5 — `RawState.Actions` starts reaching the wire for the first time.**
|
|
||||||
Per §5b, reading live `RawState` instead of a `MovementResult`-rebuilt
|
|
||||||
snapshot means the `Actions` FIFO (populated since W1/W2 by
|
|
||||||
`ApplyMotion`'s action-class branch, e.g. `motion & 0x10000000` bit —
|
|
||||||
action-class motions like emotes/attacks IF the local player ever calls
|
|
||||||
`DoMotion` with an action-class id) starts appearing in the packed
|
|
||||||
`MoveToState` bytes where it was previously always empty (TS-24). If
|
|
||||||
nothing in the current player-input path ever issues an action-class
|
|
||||||
`DoMotion` call, this is a no-op in practice for W6's scope (movement axes
|
|
||||||
only) — but flag it so a future action/emote wiring phase doesn't get
|
|
||||||
blindsided by "wait, did this already work?"
|
|
||||||
|
|
||||||
**R6 — `IWeenieObject.IsThePlayer()` for `PlayerWeenie` not
|
|
||||||
independently re-verified this session.** §3a's dispatch-path trace
|
|
||||||
ASSUMES `PlayerWeenie.IsThePlayer() == true` (required for
|
|
||||||
`apply_current_movement`'s A3 gate to route to `apply_raw_movement`,
|
|
||||||
which is the path that reaches `DefaultSink`). This is almost certainly
|
|
||||||
already correct (W3 added `IsThePlayer()` to `IWeenieObject` specifically
|
|
||||||
for this dispatch, per J5's gap list), but a W6 implementer should grep
|
|
||||||
`PlayerWeenie.cs` for the override before writing code, not assume from
|
|
||||||
this doc alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §7 Test plan
|
|
||||||
|
|
||||||
### 7a. New unit tests — edge table
|
|
||||||
|
|
||||||
Add to `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`
|
|
||||||
(or a new `PlayerMovementControllerEdgeTests.cs` alongside it, matching
|
|
||||||
the existing per-concern file split seen in
|
|
||||||
`DispatcherToMovementIntegrationTests.cs`):
|
|
||||||
|
|
||||||
- Press-W → `_motion.RawState.ForwardCommand == WalkForward` after one
|
|
||||||
`Update` call (was: never directly observable pre-W6 since the raw
|
|
||||||
state was an ephemeral per-frame local; post-W6 it's the persistent
|
|
||||||
field — this test is only possible/meaningful after the cutover).
|
|
||||||
- Press-W, hold 3 frames, release → `RawState.ForwardCommand ==
|
|
||||||
MotionCommand.Ready` after release frame (StopMotion fired).
|
|
||||||
- Press-W then press-S same frame → forward channel ends on S (or
|
|
||||||
whichever wins per `ApplyMotion`'s single-channel-per-axis semantics —
|
|
||||||
pin the actual retail-faithful tie-break, likely "last processed wins"
|
|
||||||
given the controller processes Forward before Backward in its own
|
|
||||||
`MovementInput` field order — assert whichever the implementation
|
|
||||||
produces, don't guess a preference here).
|
|
||||||
- Shift press while W held → `RawState.CurrentHoldKey == Run` +
|
|
||||||
`set_hold_run`'s internal `apply_current_movement` fired (observable
|
|
||||||
via a velocity change or a `DefaultSink` mock's call count once 3b
|
|
||||||
lands).
|
|
||||||
Shift press while NOT moving → `CurrentHoldKey == Run` still updates
|
|
||||||
(retail's `set_hold_run` doesn't gate on movement) but no velocity
|
|
||||||
visible change.
|
|
||||||
- Redundant Shift-held-while-already-Run (two consecutive `Update` calls
|
|
||||||
with `input.Run=true`) → `set_hold_run`'s XOR guard no-ops on the
|
|
||||||
second call (assert via a call-counting mock on
|
|
||||||
`apply_current_movement`'s side effect, e.g. `DefaultSink.ApplyMotion`
|
|
||||||
invocation count stays flat).
|
|
||||||
- Jump charge-then-release still produces `WeenieError.None` from
|
|
||||||
`jump()` AND (new) `ChargeJump()` was called at charge-start with a
|
|
||||||
`StandingLongJump` arm when idle+grounded (R1's fix, needs its own
|
|
||||||
assertion — grounded+idle+charge+release → `StandingLongJump == true`
|
|
||||||
observable via `_motion.StandingLongJump` before the release clears it,
|
|
||||||
or via the funnel's state-only dispatch branch firing).
|
|
||||||
- SetPosition (teleport) → `RawState.ForwardCommand == Ready` AND
|
|
||||||
`PendingMotions` gained the A9 snapshot node (StopCompletely's
|
|
||||||
`AddToQueue` call) — this is a NEW observable side effect vs the old
|
|
||||||
`DoMotion(Ready)` call, worth a dedicated assertion.
|
|
||||||
|
|
||||||
### 7b. Existing tests needing re-pin
|
|
||||||
|
|
||||||
Per the tests-agent's findings:
|
|
||||||
|
|
||||||
- **`PlayerMovementControllerTests.cs`** (15 facts, 29 `Update` calls) —
|
|
||||||
the two jump tests (`Update_JumpOnFlatTerrain_BecomesAirborne`,
|
|
||||||
`Update_AirborneFrames_ZRiseThenFalls`) are HIGH risk for needing
|
|
||||||
re-pin: they exercise the exact charge→release block §1c/R1 touches.
|
|
||||||
Re-run after the `ChargeJump()` addition — if `StandingLongJump` arms
|
|
||||||
during a stationary charge-then-release-while-still-idle scenario these
|
|
||||||
tests use, a NEW dispatch path (`DispatchInterpretedMotion(Ready)` +
|
|
||||||
`DispatchStopInterpretedMotion(SideStepRight)` per
|
|
||||||
`ApplyInterpretedMovement`'s StandingLongJump branch) fires that didn't
|
|
||||||
before — verify it doesn't change `IsAirborne`/`VerticalVelocity`/
|
|
||||||
`Position.Z` assertions (it shouldn't, since that branch is state-only
|
|
||||||
no-dispatch-to-velocity per J6, but MUST be checked, not assumed).
|
|
||||||
- **`DispatcherToMovementIntegrationTests.cs`** (6 facts) — asserts
|
|
||||||
dispatcher-built `MovementInput` produces IDENTICAL results to
|
|
||||||
hand-built input. Should stay green unchanged (it exercises the
|
|
||||||
`MovementInput`→`Update` boundary, not the internals W6 rewrites) —
|
|
||||||
but re-run explicitly since it's the integration seam most likely to
|
|
||||||
surface an edge-detection regression (e.g. if a test builds two
|
|
||||||
DIFFERENT `MovementInput` values across two `Update` calls without the
|
|
||||||
edge-detector's `_prevXHeld` fields being exercised the same way a
|
|
||||||
real per-frame loop would).
|
|
||||||
- **`CellarUpTrajectoryReplayTests.cs`**'s
|
|
||||||
`IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` — a
|
|
||||||
600-frame bit-stability regression guard with NO input held. Should be
|
|
||||||
UNAFFECTED (no edges ever fire), but it's cited in memory
|
|
||||||
(`feedback_render_perf_measurement.md`-adjacent class of test) as a
|
|
||||||
historically fragile guard — run it explicitly, don't assume "no input
|
|
||||||
means no risk" given this test exists specifically because a prior
|
|
||||||
change broke exactly this "nothing should happen when nothing is
|
|
||||||
pressed" invariant.
|
|
||||||
|
|
||||||
### 7c. Pre-cutover trace capture (plan-mandated, not yet done)
|
|
||||||
|
|
||||||
Per the plan's Fixture source line: "pre-cutover recorded traces +
|
|
||||||
golden-byte + suite." This doc does not capture the trace (out of
|
|
||||||
research-only scope) but flags that the W6 IMPLEMENTATION session must
|
|
||||||
run `ACDREAM_DUMP_MOTION=1` (or the equivalent `SetCycle`/dispatch trace
|
|
||||||
capture) against the CURRENT (pre-W6) code first, save the log, THEN
|
|
||||||
implement the cutover, THEN replay the same input script and diff — this
|
|
||||||
is a sequencing dependency on the implementation session, not something
|
|
||||||
this research pass can pre-stage without running the live client.
|
|
||||||
|
|
||||||
### 7d. Suites that must stay green throughout, unmodified
|
|
||||||
|
|
||||||
`RetailObserverTraceConformanceTests.cs` (184-case funnel dispatch-order
|
|
||||||
suite) + `MotionInterpreterFunnelTests.cs` (13 facts, synthetic
|
|
||||||
action-class supplement) — both exercise the INBOUND remote funnel path
|
|
||||||
(`DispatchInterpretedMotion`/`MoveToInterpretedState`), a sibling path to
|
|
||||||
what W6 touches. Should require zero changes; green throughout is the
|
|
||||||
signal that W6's edits to the SHARED `MotionInterpreter` machinery
|
|
||||||
(`DoMotion`/`PerformMovement`/`apply_current_movement`) didn't perturb
|
|
||||||
the remote dispatch order. All six golden-byte packer test files (§5d)
|
|
||||||
likewise stay green unmodified.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Anchor summary (file:line, this session, HEAD `e214acdf`)
|
|
||||||
|
|
||||||
- `src/AcDream.App/Input/PlayerMovementController.cs` — full file read,
|
|
||||||
1547 lines.
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs` — `UpdatePlayerAnimation`
|
|
||||||
`:10139-10307`; caller `:8006`; `ApplyServerRunRate` call `:4373`;
|
|
||||||
JumpAction block `:7984-8002`; outbound RawMotionState build
|
|
||||||
`:7893-7913`; `EnterPlayerModeNow` binding block `:12987-13000`;
|
|
||||||
`EnsureRemoteMotionBindings` `:4195-4224`; `OnLiveVectorUpdated`
|
|
||||||
LeaveGround pattern `:4845-4864`.
|
|
||||||
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — full file read, 3083
|
|
||||||
lines; key anchors: `DoMotion` `:856-949`; `StopMotion` `:958-1008`;
|
|
||||||
`StopCompletely` `:1054-1084`; `apply_raw_movement` (2 overloads)
|
|
||||||
`:1346-1371`, `:1443-1450`; `apply_current_movement` `:1404-1417`;
|
|
||||||
`DefaultSink` property `:1489`; `ApplyCurrentMovementInterpreted`
|
|
||||||
`:1491-1536`; `ChargeJump` `:1765-1789`; `jump` `:1822-1839`;
|
|
||||||
`LeaveGround` `:2310-2330`; `HitGround` `:2361-2375`;
|
|
||||||
`EnterDefaultState` `:2415-2427`; `set_hold_run` `:2453-2463`;
|
|
||||||
`SetHoldKey` `:2506-2525`; `MotionDone` `:2132-2154`;
|
|
||||||
`ApplyInterpretedMovement` `:2715-2762`; `DoInterpretedMotion`
|
|
||||||
`:2837-2948`.
|
|
||||||
- `src/AcDream.Core/Physics/RawMotionState.cs` — full file, `ApplyMotion`
|
|
||||||
`:208-273`, `RemoveMotion` `:293-321`.
|
|
||||||
- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — full file,
|
|
||||||
fields `:84-186`.
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
# L.2g S2 — inbound CMotionInterp funnel: verbatim pseudocode
|
|
||||||
|
|
||||||
Date: 2026-07-02. Oracle: named decomp (line refs into
|
|
||||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`) + the LIVE cdb trace
|
|
||||||
of a retail observer (`l2g-observer-trace.log`, breakpoint script
|
|
||||||
`tools/cdb/l2g-observer.cdb`) which confirmed the exact runtime chain and
|
|
||||||
per-UM `DoInterpretedMotion` order: **style → forward → sidestep(-stop) →
|
|
||||||
turn(-stop)**, applied wholesale the tick the message arrives.
|
|
||||||
|
|
||||||
Empty-UM semantics (settled): `InterpretedMotionState::UnPack` (0x0051f400,
|
|
||||||
294360) decodes absent fields to ctor defaults — style `0x8000003D`, forward
|
|
||||||
`0x41000003 Ready`, speeds `1.0`, sidestep/turn `0` — so a flags=0 UM is a
|
|
||||||
retail-verbatim FULL STOP. No special-casing. (The wire varies with the
|
|
||||||
DRIVER's client state; both explicit-walk and empty variants are handled by
|
|
||||||
the same wholesale apply. `RawMotionState::Pack` 0x0051ed10 confirmed pure
|
|
||||||
static-default-diff — outbound L.2b port stays as-is.)
|
|
||||||
|
|
||||||
## 1. MovementManager::unpack_movement — case 0 (0x00524440, 300563)
|
|
||||||
|
|
||||||
```
|
|
||||||
unpack_movement(blob):
|
|
||||||
if minterp == null or physics_obj == null: return 0
|
|
||||||
physics_obj.interrupt_current_movement()
|
|
||||||
physics_obj.unstick_from_object()
|
|
||||||
|
|
||||||
u16 header = read_u16() # low byte: movement type; high byte: motionFlags
|
|
||||||
u16 styleIdx = read_u16() # outer style, command_ids[] index
|
|
||||||
style = command_ids[styleIdx]
|
|
||||||
if minterp.get_current_style() != style: # "GetPinVersion" in BN
|
|
||||||
minterp.DoMotion(style, default_params) # style applied ONLY on change
|
|
||||||
|
|
||||||
switch header.low_byte:
|
|
||||||
case 0: # InterpretedMotionState
|
|
||||||
ims = InterpretedMotionState() # ctor defaults (see above)
|
|
||||||
ims.UnPack(blob) # absent fields keep defaults
|
|
||||||
stickyGuid = (header & 0x100) ? read_u32() : 0
|
|
||||||
MovementManager.move_to_interpreted_state(ims)
|
|
||||||
if stickyGuid: physics_obj.stick_to_object(stickyGuid)
|
|
||||||
minterp.standing_longjump = (header & 0x200)
|
|
||||||
return 1
|
|
||||||
case 6/7: MoveTo… (existing acdream path, keep)
|
|
||||||
case 8/9: TurnTo… (S6)
|
|
||||||
default: return 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. CMotionInterp::move_to_interpreted_state (0x005289c0, 305936)
|
|
||||||
|
|
||||||
```
|
|
||||||
move_to_interpreted_state(ims):
|
|
||||||
if physics_obj == null: return 0
|
|
||||||
raw_state.current_style = ims.current_style
|
|
||||||
physics_obj.interrupt_current_movement()
|
|
||||||
jumpAllowed = motion_allows_jump(interpreted_state.forward_command) # OLD state!
|
|
||||||
interpreted_state.copy_movement_from(ims) # FLAT overwrite (0x0051e750)
|
|
||||||
apply_current_movement(force=1, jumpAllowed)
|
|
||||||
|
|
||||||
for action in ims.actions: # MotionItem list
|
|
||||||
# 15-bit wraparound stamp gate vs server_action_stamp (305953-305971)
|
|
||||||
if newer_15bit(action.stamp, server_action_stamp):
|
|
||||||
if weenie is player and action.autonomous: skip # local echo guard
|
|
||||||
server_action_stamp = action.stamp
|
|
||||||
DoInterpretedMotion(action.command, params(speed=action.speed))
|
|
||||||
return 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. CMotionInterp::apply_current_movement (0x00528870, 305838)
|
|
||||||
|
|
||||||
```
|
|
||||||
apply_current_movement(force, jumpAllowed):
|
|
||||||
if physics_obj == null or !initted: return
|
|
||||||
if (weenie==null or weenie.IsThePlayer()) and physics_obj.movement_is_autonomous():
|
|
||||||
apply_raw_movement(force, jumpAllowed) # LOCAL player (already ported, D6)
|
|
||||||
else:
|
|
||||||
apply_interpreted_movement(force, jumpAllowed) # REMOTES — this port
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. CMotionInterp::apply_interpreted_movement (0x00528600, 305713)
|
|
||||||
|
|
||||||
```
|
|
||||||
apply_interpreted_movement(force, jumpAllowed):
|
|
||||||
if physics_obj == null: return
|
|
||||||
if interpreted_state.forward_command == RunForward (0x44000007):
|
|
||||||
my_run_rate = interpreted_state.forward_speed # cache server run rate
|
|
||||||
|
|
||||||
DoInterpretedMotion(interpreted_state.current_style, {}) # stance
|
|
||||||
|
|
||||||
if !contact_allows_move(interpreted_state.forward_command):
|
|
||||||
DoInterpretedMotion(0x40000015 Falling, {})
|
|
||||||
elif standing_longjump:
|
|
||||||
DoInterpretedMotion(0x41000003 Ready, {})
|
|
||||||
StopInterpretedMotion(0x6500000F SideStep…, {})
|
|
||||||
else:
|
|
||||||
DoInterpretedMotion(interpreted_state.forward_command,
|
|
||||||
{speed: interpreted_state.forward_speed})
|
|
||||||
if interpreted_state.sidestep_command == 0:
|
|
||||||
StopInterpretedMotion(0x6500000F, {})
|
|
||||||
else:
|
|
||||||
DoInterpretedMotion(interpreted_state.sidestep_command,
|
|
||||||
{speed: interpreted_state.sidestep_speed})
|
|
||||||
|
|
||||||
if interpreted_state.turn_command != 0:
|
|
||||||
DoInterpretedMotion(interpreted_state.turn_command,
|
|
||||||
{speed: interpreted_state.turn_speed})
|
|
||||||
return # early — no idle-stop this call
|
|
||||||
if StopInterpretedMotion(0x6500000D Turn…, {}) == 0:
|
|
||||||
add_to_queue(ctx=0, Ready, tick) # idle bookkeeping (S3 wires fully)
|
|
||||||
```
|
|
||||||
|
|
||||||
Live-trace confirmation (actor minterp 18e8b0f8): per UM exactly
|
|
||||||
`[DIM] 0x8000003D` then `[DIM] <fwd>` (0x45000005 / 0x41000003 / 0x44000007)
|
|
||||||
then sidestep/turn stops (0x6500000F / 0x6500000D) — order verbatim.
|
|
||||||
|
|
||||||
## 5. CMotionInterp::DoInterpretedMotion (0x00528360, 305575)
|
|
||||||
|
|
||||||
```
|
|
||||||
DoInterpretedMotion(motion, params):
|
|
||||||
if physics_obj == null: return 8
|
|
||||||
if contact_allows_move(motion):
|
|
||||||
if standing_longjump and motion in jump-set: goto apply_only
|
|
||||||
if motion == 0x40000011: physics_obj.RemoveLinkAnimations()
|
|
||||||
result = physics_obj.DoInterpretedMotion(motion, params)
|
|
||||||
# → CPartArray → MotionTableManager::PerformMovement
|
|
||||||
# → CMotionTable::GetObjectSequence ≙ AnimationSequencer.SetCycle
|
|
||||||
if result == 0:
|
|
||||||
add_to_queue(params.context_id, motion, jumpAllowed) # pending_motions (S3)
|
|
||||||
if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params)
|
|
||||||
elif (motion & 0x10000000) == 0:
|
|
||||||
apply_only:
|
|
||||||
if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params)
|
|
||||||
result = 0
|
|
||||||
else:
|
|
||||||
result = 0x24
|
|
||||||
if physics_obj != null and physics_obj.cell == 0:
|
|
||||||
physics_obj.RemoveLinkAnimations()
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
## acdream mapping (surgical)
|
|
||||||
|
|
||||||
- **New in `MotionInterpreter` (Core.Physics):** `MoveToInterpretedState(ims)`,
|
|
||||||
`ApplyInterpretedMovement()`, using the EXISTING `DoInterpretedMotion` /
|
|
||||||
`StopInterpretedMotion` (extended to retail semantics above) with the
|
|
||||||
sequencer as the `GetObjectSequence` backend. `InterpretedMotionStateData`
|
|
||||||
= a plain struct mirroring the ctor defaults; built from
|
|
||||||
`UpdateMotion.Parsed` (absent wire fields → defaults — the parser already
|
|
||||||
yields nullables; the CONVERSION applies the defaults).
|
|
||||||
- **`OnLiveMotionUpdated` remote SubState branch collapses** to: build ims →
|
|
||||||
`remoteMot.Motion.MoveToInterpretedState(ims, sequencer)`. Style-on-change
|
|
||||||
at the unpack level (today's `fullStyle` preserve-current logic maps to
|
|
||||||
`get_current_style()` compare). PRESERVE deliberately: K-fix17 airborne
|
|
||||||
cycle guard, HasCycle fallback chain, MoveTo (case 6/7) seeding, overlay
|
|
||||||
(Action-class) routing — these live in the DIM backend, not deleted.
|
|
||||||
- **Stop** rides the same path: empty UM → ims defaults → fwd=Ready →
|
|
||||||
apply → sequencer Ready + `get_state_velocity`→0 → body velocity zero. The
|
|
||||||
acdream-invented 300 ms stop-detection window + UP-near-zero StopCompletely
|
|
||||||
become dead code to remove in the same slice (DEV-3).
|
|
||||||
- **`my_run_rate` caching** per remote (apply_interpreted_movement head).
|
|
||||||
- Tests first: fake-sequencer call-order tests per UM shape (empty, walk,
|
|
||||||
run@2.85, run+turn, action list, stale action stamp), then live smoke.
|
|
||||||
|
|
||||||
## Live-trace answer to the S0-open question
|
|
||||||
|
|
||||||
Retail observer applies EVERY accepted UM wholesale — including empties as
|
|
||||||
full stops. "Correct behavior" (user-verified during the trace) comes from
|
|
||||||
this + the chase, not from any inference. acdream's S2 target = exactly this.
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
# Phase R handoff — fresh-session entry point (2026-07-03)
|
|
||||||
|
|
||||||
> **POSTSCRIPT (2026-07-03, later session): R4 is COMPLETE.** V5 (local-player
|
|
||||||
> cutover) shipped as `b3decdfa` — P1 gate ported verbatim, B.6 auto-walk
|
|
||||||
> deleted, TS-36 bound, run-rate re-anchored to PD skills + mt-6/7
|
|
||||||
> my_run_rate with NO AD row (the contingency wasn't needed — see the
|
|
||||||
> V0-pins.md P1 postscript), MoveToComplete seam widened to natural
|
|
||||||
> completion for AD-27, plus three adversarial-review fixes (remote
|
|
||||||
> HitGround relay, mt-8 wire_heading degrade, remote curTime clock). V6
|
|
||||||
> register/roadmap/memory sweep shipped in the follow-up commit. The V4
|
|
||||||
> smoke log was analyzed CLEAN (mt-6/8 events all dispatched, arrival
|
|
||||||
> stop transitions, zero exceptions/stalls). Suite: 3,956 green.
|
|
||||||
> REMAINING: the ONE combined R2+R3+R4 user visual pass, then R5.
|
|
||||||
> The sections below are the historical entry state, kept verbatim.
|
|
||||||
>
|
|
||||||
> **POSTSCRIPT 2 (2026-07-03, the live verify session):** four live bugs
|
|
||||||
> found+fixed during the user's pass, each root-caused (trail:
|
|
||||||
> `c2dc1a88` StopCompletely's missing StopCompletely_Internal animation
|
|
||||||
> dispatch orphaned pending_motions → wait-for-anims wedge; `24569fd2`
|
|
||||||
> login SetPosition ran before the sink bind → one immortal orphan node;
|
|
||||||
> `006cf659` remote-player movetos never ticked (glide) + door UMs
|
|
||||||
> dropped (no RemoteMotion for UP-less entities); `350fb5e3` the
|
|
||||||
> detached-object guard's ObjCellId proxy stripped ALL remote transition
|
|
||||||
> links → door-swing snap, register TS-40). CONFIRMED WORKING by user:
|
|
||||||
> local use-walk + door open + door swing + remote close-range walk.
|
|
||||||
> STILL OPEN: ISSUES **#160** (remote run-anim pace vs ACE movement
|
|
||||||
> speed), **#161** (remote landing pose — retest first, TS-40 may have
|
|
||||||
> fixed it), **#162** (glide-class ACE autonomous-reflection cancels —
|
|
||||||
> adjudication pending the user's retail-vs-retail A/B; adaptation needs
|
|
||||||
> explicit approval), **#163** (strip the temp diagnostics).
|
|
||||||
|
|
||||||
**Read this first, then the plan of record**
|
|
||||||
(`docs/plans/2026-07-02-retail-motion-animation-rewrite.md`). This session
|
|
||||||
ran the R2→R4 arc of the Phase R mandate; the fresh session picks up at
|
|
||||||
**R4-V4 smoke verification → R4-V5**.
|
|
||||||
|
|
||||||
## The mandate (user, standing)
|
|
||||||
|
|
||||||
Total overhaul of movement + animation as a VERBATIM retail port — all
|
|
||||||
movement, inbound and outbound, all animation, for players/NPCs/monsters.
|
|
||||||
No guessing, no approximations, no bandaids. The user cancelled the /loop
|
|
||||||
self-scheduling near the end of this session — **work continues on user
|
|
||||||
prompts, not self-scheduled wakeups**, but the drive-autonomously rules in
|
|
||||||
CLAUDE.md still apply within a turn.
|
|
||||||
|
|
||||||
## State at handoff (worktree `vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`)
|
|
||||||
|
|
||||||
- Tree: CLEAN at `7016b26c` (only `v4-smoke.log` untracked — session
|
|
||||||
scratch, delete freely). Full suite green: **3,948 tests**
|
|
||||||
(385 Net + 425 UI + 713 App + 2,425 Core, +4 pre-existing skips).
|
|
||||||
- **R1 ✅ R2 ✅ R3 ✅ shipped** (trail + commit SHAs recorded in the plan
|
|
||||||
doc's stage entries). **R4: V0–V4 shipped, V5+V6 remain.**
|
|
||||||
- A smoke client was launched with the V4 build (`ACDREAM_DUMP_MOTION=1`,
|
|
||||||
log `v4-smoke.log` in the worktree root, UTF-16) — **the user had NOT yet
|
|
||||||
reported an NPC-behavior verdict at handoff time.** First order of
|
|
||||||
business: get that verdict (or relaunch and ask). Watch items: NPC
|
|
||||||
chase/wander legs, turn-to-face, melee-range stop distance (arrival is
|
|
||||||
now retail cylinder distance — the old max() gate is gone).
|
|
||||||
|
|
||||||
## What just landed (this session, newest first)
|
|
||||||
|
|
||||||
| Commit | What |
|
|
||||||
|---|---|
|
|
||||||
| `7016b26c` | **R4-V4** remote cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED; P4 TargetTracker adapter; retail unpack_movement dispatch (head-interrupt every UM; mt 6–9 never touch the interpreted funnel) |
|
|
||||||
| `a144e873` | **R4-V3** wire: mt 8/9 parsing (TurnToPathData), raw bitfield surfaced, mt-0 sticky trailer |
|
|
||||||
| `addc8e97` | **R4-V2** the verbatim MoveToManager (33 members, 101 tests; seams listed in its final report — ctor takes the interp + 14 providers; the identity-quaternion TDD catch) |
|
|
||||||
| `e0d2492c` | **R4-V1** command-selection family (GetCommand + CanCharge fast-path; MoveToMath incl. the Ghidra-confirmed heading_diff mirror) |
|
|
||||||
| `386b1ce5` | **R4-V0** pins P1–P7 (see below) |
|
|
||||||
| `988304e1` | R4 research base (`docs/research/2026-07-03-r4-moveto/` — decomp, ACE cross-ref, port plan) |
|
|
||||||
| `30115d96` | **W6b fix** — the "press W and stop instantly" live regression (see gotchas) |
|
|
||||||
| `fb7beb70`…`8eff3978` | R3 W0–W7 + R2 Q0–Q6 (see the plan doc's stage trails) |
|
|
||||||
|
|
||||||
## NEXT: R4-V5 — LOCAL PLAYER cutover (the delicate one)
|
|
||||||
|
|
||||||
Spec: `docs/research/2026-07-03-r4-moveto/r4-port-plan.md` §3 V5.
|
|
||||||
**ONE commit, GameWindow + PlayerMovementController, do NOT fan out**
|
|
||||||
(feedback_dont_parallelize_coupled_plan_slices). Shape:
|
|
||||||
|
|
||||||
- The player gets a MoveToManager bound like remotes (the
|
|
||||||
EnsureRemoteMotionBindings block at GameWindow is the reference wiring;
|
|
||||||
the player's Motion/seams already exist from R3 — DefaultSink is bound,
|
|
||||||
`_playerController.Motion` exposed).
|
|
||||||
- **B.6 auto-walk DELETED**: `DriveServerAutoWalk` + `BeginServerAutoWalk`
|
|
||||||
+ the `_autoWalk*` fields + the local mt-6 branch in GameWindow
|
|
||||||
(`BeginServerAutoWalk` call site) + the two relocated constants
|
|
||||||
(`AutoWalkArrivalEpsilon`, `AutoWalkTurnRateFor` — marked V5-scope in the
|
|
||||||
controller).
|
|
||||||
- **P1 pin applies here**: port the missing tail of retail's
|
|
||||||
`CPhysics::SetObjectMovement` gate — DROP any 0xF74C event whose wire
|
|
||||||
`IsAutonomous` byte is set when guid == local player (parsed at
|
|
||||||
`UpdateMotion.cs` isAutonomous, relayed by WorldSession, consumed
|
|
||||||
NOWHERE yet). That's what makes retail's unconditional head-interrupt
|
|
||||||
safe against ACE's mt-0 echo. `V0-pins.md` P1 has the full adjudication +
|
|
||||||
the LoseControlToServer note. This RETIRES the informal
|
|
||||||
"don't cancel on non-MoveTo" comment block in the local UM branch.
|
|
||||||
- TS-36 retires (InterruptCurrentMovement bound for the player);
|
|
||||||
AD-26 retires; AD-27/AP-23 re-anchor.
|
|
||||||
- Movement keys auto-cancel a server MoveTo (retail: the CommandInterpreter
|
|
||||||
DoMotion's cancel bit → interrupt → CancelMoveTo — the edge-driven W6
|
|
||||||
input already calls DoMotion with ctor-default params whose CancelMoveTo
|
|
||||||
is TRUE, so this may fall out for free — verify, don't assume).
|
|
||||||
- Tests: the V2 harness pattern for the player-side bind; the W6
|
|
||||||
edge-driven regression suite (`W6EdgeDrivenMovementTests.cs`) is the
|
|
||||||
template for controller-level tests with a real sink.
|
|
||||||
- Then **V6**: register/roadmap/plan/memory sweep (V6 spec in the plan).
|
|
||||||
After V6, R4 is done → R5 (MovementSystem facade + StickyManager +
|
|
||||||
ConstraintManager + TargetManager full port), R6 (per-tick
|
|
||||||
UpdateObjectInternal order — retires the 300ms stop window, PlanFromVelocity
|
|
||||||
AP-80, ObservedOmega AP-76, AP-75/AP-77 velocity adaptations), R7
|
|
||||||
(outbound cadence TS-33), R8 (audit + final visual pass).
|
|
||||||
|
|
||||||
## Pending user verifications (visual passes)
|
|
||||||
|
|
||||||
1. **V4 smoke verdict** (see above — NPC movement).
|
|
||||||
2. **R2+R3 combined visual pass** (task #9): jump instant-Falling engage,
|
|
||||||
walk-off-ledge momentum (NEW behavior), running-in-circles blend, stop
|
|
||||||
settle, strafe pacing (EXPECTED-DIFF: #45's 1.248× factor retired —
|
|
||||||
local now matches remotes), retail-observer view (no rubber-band).
|
|
||||||
Partially confirmed ("works a lot better now") after the W6b fix.
|
|
||||||
|
|
||||||
## Load-bearing gotchas from this session (do not relearn these)
|
|
||||||
|
|
||||||
1. **BN hoisted-register artifact class** (caused the W6b live bug): the
|
|
||||||
pseudo-C renders pre-call register caches as post-call field reads.
|
|
||||||
`ApplyInterpretedMovement` must ENTRY-CACHE the axis values — the style
|
|
||||||
dispatch's `ApplyMotion(style)` resets forward to Ready (verbatim,
|
|
||||||
raw 0051ea6c) and retail self-heals via the cached fwd re-apply. The
|
|
||||||
183-case suite can NOT catch sink-result-dependent bugs: its
|
|
||||||
RecordingSink's bool return doesn't mirror the real
|
|
||||||
MotionTableDispatchSink (style dispatches return TRUE → the resetting
|
|
||||||
state-write runs). Regression suite: `W6EdgeDrivenMovementTests.cs`.
|
|
||||||
2. **The dispatch RESULT gates queue+state writes** (W5 discovery):
|
|
||||||
`IInterpretedMotionSink.ApplyMotion/StopMotion` return bool for this
|
|
||||||
reason — documented on the interface.
|
|
||||||
3. **Retail's echo discriminator is the wire autonomous byte** (V0 P1):
|
|
||||||
not a workaround — `CPhysics::SetObjectMovement` 0x00509690's final
|
|
||||||
branch. Port it in V5, don't invent gating.
|
|
||||||
4. **MoveToManager entry points never drain pending_actions** — re-issues
|
|
||||||
MUST route through `PerformMovement` (V2 finding, tested).
|
|
||||||
5. **`default(Quaternion)` is zero, not identity** — heading math reads
|
|
||||||
90° off a zero quat (V2 TDD catch; `IdentityPosition` constant).
|
|
||||||
6. **fail_progress_count is WRITE-ONLY in retail** — there is no MoveTo
|
|
||||||
give-up threshold; do not invent one.
|
|
||||||
7. **Delegation pattern that works**: sonnet implementers against
|
|
||||||
committed pinned specs (research → pins with adversarial verify →
|
|
||||||
implement → orchestrator independently verifies scope + suite +
|
|
||||||
spot-checks against the raw decomp → commit). GameWindow/controller
|
|
||||||
coupled surgery stays with the orchestrator. Workflow tool for
|
|
||||||
research fan-outs (r3/r4 extraction + pin workflows are the templates).
|
|
||||||
8. **py (not python) exists on this box**; logs from Tee-Object are
|
|
||||||
UTF-16; heredocs with quotes inside python strings break — write
|
|
||||||
scripts to the scratchpad with the Write tool and run `py file.py`.
|
|
||||||
9. **Ghidra MCP** (port 8080/8081, needs the user to open patchmem.gpr):
|
|
||||||
used live this session for heading_diff/get_desired_heading
|
|
||||||
(`ghidra-confirmations.md`). Ask the user to open it when a BN garble
|
|
||||||
blocks a pin.
|
|
||||||
10. **ACE session care**: graceful client close (CloseMainWindow) or ACE
|
|
||||||
holds the session ~3 min (exit 29 on relaunch). Don't kill clients
|
|
||||||
the user launched — ask.
|
|
||||||
|
|
||||||
## Key file map (R4 additions)
|
|
||||||
|
|
||||||
- `src/AcDream.Core/Physics/Motion/MoveToManager.cs` + `MoveToNode.cs` —
|
|
||||||
the V2 port (seams doc'd on the ctor).
|
|
||||||
- `src/AcDream.Core/Physics/Motion/MoveToMath.cs` — heading/cylinder math
|
|
||||||
+ `OriginToWorld` (relocated from the deleted RemoteMoveToDriver).
|
|
||||||
- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — `GetCommand` /
|
|
||||||
`FromWire` / `FromWireTurnTo` / `GetDesiredHeading` / `TowardsAndAway`.
|
|
||||||
- `docs/research/2026-07-03-r4-moveto/` — decomp + ACE cross-ref +
|
|
||||||
port plan + `V0-pins.md` + `ghidra-confirmations.md`.
|
|
||||||
- GameWindow: `EnsureRemoteMotionBindings` (the full per-remote wiring
|
|
||||||
reference), the remote UM dispatch (mt 6–9 vs mt-0 funnel), the
|
|
||||||
per-tick tracker+UseTime block.
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
# R4-V0 — ambiguity pins and ACE-oddity adjudications
|
|
||||||
|
|
||||||
The verbatim extraction is `r4-moveto-decomp.md` (line-anchored); the port plan
|
|
||||||
with the full ambiguity table is `r4-port-plan.md` §0. This note records the
|
|
||||||
PINNED resolutions the V1–V6 ports code against. Every pin below was produced by
|
|
||||||
an independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
|
||||||
(line numbers cited are that file's), cross-read against ACE source
|
|
||||||
(`references/ACE/Source/ACE.Server/`, MoveToManager under `Physics/Managers/`),
|
|
||||||
and P1/P3 additionally survived an adversarial refutation pass (P3 by direct
|
|
||||||
byte-level disassembly of the PDB-matched retail binary — evidence one tier
|
|
||||||
stronger than a Ghidra decompile). **No pin was refuted; none blocks on cdb or
|
|
||||||
Ghidra.** Ghidra MCP was down for the whole pass; the deferred decompile checks
|
|
||||||
are listed per-pin and in the tail section — all confirmatory, none load-bearing.
|
|
||||||
|
|
||||||
## Pinned
|
|
||||||
|
|
||||||
- **P1 — ACE's companion mt-0 echo vs retail's unpack-head cancel: PINNED — no
|
|
||||||
adaptation needed; port the missing TAIL of retail's own gate.**
|
|
||||||
`CPhysics::SetObjectMovement` (0x00509690) drops any movement event whose wire
|
|
||||||
autonomous byte is set when the addressed object `IsThePlayer`, BEFORE
|
|
||||||
`unpack_movement` is ever called — so retail's unconditional head-interrupt
|
|
||||||
never sees an autonomous echo. Plan option (c) (suppress-when-command-matches)
|
|
||||||
is DISCARDED. Confidence: **high** (adversarially verified; refutation failed
|
|
||||||
on every angle).
|
|
||||||
|
|
||||||
Port shape: in the local-player 0xF74C path, after the S1 `MotionSequenceGate`
|
|
||||||
passes, drop the event entirely — no interrupt, no state application — when
|
|
||||||
`update.IsAutonomous && guid == local player guid`. Verbatim (raw 271370–271431):
|
|
||||||
`ebx = weenie_obj->vtable->IsThePlayer()` @005096a8; two wraparound sequence
|
|
||||||
gates; then @0050972e `if ((arg7 == 0 || ebx == 0)) {
|
|
||||||
arg2->last_move_was_autonomous = arg7; CPhysicsObj::unpack_movement(arg2,
|
|
||||||
&arg3, arg4); if (ebx != 0) return 1; }` else fall to `return 0`. Note
|
|
||||||
`last_move_was_autonomous` is stored ONLY on the unpack path — do not store it
|
|
||||||
for dropped events. The `||`-vs-`&&` BN-inversion worry is refuted
|
|
||||||
behaviorally: with `&&` the local player would never unpack ANY movement event,
|
|
||||||
yet server MoveTos demonstrably drive retail clients against ACE. The 4-arg
|
|
||||||
overload (0x00509790, raw 271458–271485) pins arg7 = the wire byte at offset +4
|
|
||||||
after two u16 sequences with align-to-4 — byte-identical to ACE's writer
|
|
||||||
(MovementData.cs:189-200: `GetNextSequence(ObjectMovement)`; autonomous ?
|
|
||||||
`GetCurrentSequence` : `GetNextSequence(ObjectServerControl)`;
|
|
||||||
`Convert.ToByte(motion.IsAutonomous)`; `writer.Align()`).
|
|
||||||
|
|
||||||
With the gate in place, the verbatim `unpack_movement` head-cancel ports
|
|
||||||
cleanly: `CPhysicsObj::interrupt_current_movement` + `unstick_from_object` on
|
|
||||||
EVERY UM type 0/6/7/8/9 before the type switch (0x00524440, raw 300566–300573;
|
|
||||||
jump table @300707–300719 — types 1–5 fall to return 0, head already ran).
|
|
||||||
`interrupt_current_movement` = `if (movement_manager)
|
|
||||||
MovementManager::CancelMoveTo(0x36)` (raw 278189–278200), a no-op when
|
|
||||||
`moveto_manager == 0` (raw 300277–300288 @0x005241b0). mt 6/7/8/9 keep the
|
|
||||||
retail unconditional cancel as planned (PerformMovement re-cancels anyway, §3a).
|
|
||||||
|
|
||||||
Why the echo was never a retail problem: the "companion mt-0 echo" is NOT
|
|
||||||
unconditional ACE behavior — it is ACE reflecting the client's OWN outbound
|
|
||||||
MoveToState back to the sender. `BroadcastMovement` has exactly ONE caller
|
|
||||||
(GameActionMoveToState.cs:36); `MovementData(Creature, MoveToState)` hardcodes
|
|
||||||
`IsAutonomous = true` (MovementData.cs:162) and Player_Networking.cs:365 sends
|
|
||||||
it to self (`EnqueueBroadcast(true, movementEvent)`; ACE's own comment
|
|
||||||
"shouldn't need to go to originating player?"). The 2026-05-14 trace behind
|
|
||||||
GameWindow.cs:4534-4546 was self-inflicted: the pre-#75 auto-walk synthesized
|
|
||||||
Forward+Run input that leaked outbound as a MoveToState
|
|
||||||
(PlayerMovementController.cs:546-557 preserves the finding); post-#75
|
|
||||||
(:298-308) no MoveToState is built during auto-walk, so no echo exists at
|
|
||||||
moveto start — **the GameWindow.cs:4534-4546 causal story ("ACE follows every
|
|
||||||
mt=0x06 with an mt=0x00") is stale**. ACE's mt-6/7/8/9 senders are
|
|
||||||
`IsAutonomous = false` (Motion.cs:18 default, copied at MovementData.cs:44;
|
|
||||||
only two `IsAutonomous = true` sites in ACE.Server: MovementData.cs:162 and
|
|
||||||
the Player.cs:944-951 jump hack), so they pass the gate and the auto-walk
|
|
||||||
trigger survives. The gate also absorbs ACE's other self-addressed autonomous
|
|
||||||
mt-0 class (the charged-jump hack) — and it is precisely why retail WASD
|
|
||||||
against ACE (every keypress echoed back IsAutonomous=1) doesn't stutter:
|
|
||||||
standing behavioral corroboration.
|
|
||||||
|
|
||||||
**Register adjudication (the exact wording V5/V6 execute):** NO new AD row.
|
|
||||||
The informal "do NOT cancel local moveto on a non-MoveTo UM" adaptation at
|
|
||||||
GameWindow.cs:4534-4546 has no register row (verified — a row-less deviation)
|
|
||||||
and is RETIRED by the ported retail mechanism; delete the comment block with
|
|
||||||
the code in V5. ONE CONTINGENCY: the gate severs acdream's only live runSkill
|
|
||||||
sync feed — GameWindow.cs:4364-4372 wires
|
|
||||||
`ApplyServerRunRate(update.MotionState.ForwardSpeed)` and its ONLY live source
|
|
||||||
is the autonomous mt-0 echo. V5 must re-anchor run-rate sync to retail's own
|
|
||||||
mechanism (mt-6/7 `my_run_rate` wire read = plan item M13, plus
|
|
||||||
PlayerDescription). If V5 instead taps the echo's ForwardSpeed BEFORE the drop,
|
|
||||||
that tap IS an ACE-compat adaptation requiring this row in the same commit:
|
|
||||||
|
|
||||||
> **AD-new | Local-player autonomous-echo ForwardSpeed tap** — acdream reads
|
|
||||||
> `InterpretedMotionState.ForwardSpeed` from the self-addressed autonomous
|
|
||||||
> mt-0 UM (ACE's MoveToState reflection, MovementData.cs:162
|
|
||||||
> IsAutonomous=true hardcoded) before the ported retail
|
|
||||||
> SetObjectMovement autonomous/IsThePlayer drop (0x00509690 @0050972e)
|
|
||||||
> discards it, feeding `PlayerMovementController.ApplyServerRunRate`. Retail
|
|
||||||
> discards the event entirely. | Risk: local/observer run-speed desync if the
|
|
||||||
> echo changes; masks the missing my_run_rate wire feed. | Retire when M13's
|
|
||||||
> mt-6/7 my_run_rate + PlayerDescription feed lands.
|
|
||||||
|
|
||||||
Adjacent seam (R5 note): retail's 0xF74C dispatch calls
|
|
||||||
`cmdinterp->vtable->LoseControlToServer()` whenever SetObjectMovement returns
|
|
||||||
nonzero — i.e. whenever a NON-autonomous movement event is applied to the
|
|
||||||
local player (raw 357214–357235: instance-seq gate then `if
|
|
||||||
(CPhysics::SetObjectMovement(...) != 0) this->cmdinterp->vtable->
|
|
||||||
LoseControlToServer();`). This is the retail autonomy-handoff hook that
|
|
||||||
`BeginServerAutoWalk` currently approximates — R5/MovementManager scope.
|
|
||||||
|
|
||||||
Ghidra deferred (non-blocking): decompile `0x00509690` (re-verify the
|
|
||||||
`(arg7 == 0 || ebx == 0)` condition + which sequence stamps are written on the
|
|
||||||
reject paths — the function has BN self-subtraction flag-test garbles at
|
|
||||||
@005096db/@00509712, but the decisive gate is a plain integer test with the
|
|
||||||
adjacent `last_move_was_autonomous` store); decompile the 0xF74C dispatch case
|
|
||||||
at ~`0x00559850` (confirm LoseControlToServer fires iff SetObjectMovement != 0).
|
|
||||||
|
|
||||||
- **P2 — `get_desired_heading` (0x0052aad0) 180/0 constants: PINNED to ACE's
|
|
||||||
shape.** `RunForward|WalkForward → movingAway ? 180 : 0`; `WalkBackwards →
|
|
||||||
movingAway ? 0 : 180`; default 0. Confidence: **high**. The BN raw
|
|
||||||
(308016–308033) confirms only the command GROUPING — {RunForward 0x44000007,
|
|
||||||
WalkForward 0x45000005} vs {WalkBackwards 0x45000006} vs default — while both
|
|
||||||
constant branches render identically (`result = arg3`) and the default renders
|
|
||||||
as x87 flag-synthesis nonsense (`return (arg2 - 0x45000006)`), the known
|
|
||||||
setcc-garble class. Constants + per-branch polarity are 100% ACE-sourced
|
|
||||||
(MovementParameters.cs:186-198) but doubly corroborated: (a) caller math (raw
|
|
||||||
307227–307233, HandleMoveToPosition @0x00529e25) adds the return to
|
|
||||||
`Position::heading(to target)` then wraps [0,360) — a degree OFFSET; (b)
|
|
||||||
physics: §5c pure-away picks WalkForward+movingAway=1 ("heading flips via
|
|
||||||
get_desired_heading +180", r4-moveto-decomp.md:619-620) → away-walk faces
|
|
||||||
away = +180; §5d towards_and_away picks WalkBackwards+movingAway=1 inside the
|
|
||||||
min band ("backs up with WalkBackwards (no turn)", :656) → backstep faces the
|
|
||||||
target = 0. Ghidra deferred (non-blocking): decompile `0x0052aad0`; mark the
|
|
||||||
pseudocode doc UNVERIFIED-BY-GHIDRA until then.
|
|
||||||
|
|
||||||
- **P3 — `heading_diff` (0x00528fb0) third-arg mirror: PINNED as MIRROR
|
|
||||||
PRESENT — port ACE's body verbatim.** `result = h1 − h2; if |result| < ε
|
|
||||||
result = 0; if result < −ε result += 360; if (result > ε && motion !=
|
|
||||||
TurnRight) result = 360 − result;` with ε = 0.000199999995f and TurnRight =
|
|
||||||
0x6500000D. r4-moveto-decomp §5g's "arg3 UNUSED in the body" is a BN x87-setcc
|
|
||||||
artifact (the W6b class); r4-ace-moveto §1's suspicion was correct.
|
|
||||||
Confidence: **high** — sealed by direct disassembly of the PDB-matched binary
|
|
||||||
(`C:\Turbine\Asheron's Call\acclient.exe`, CodeView GUID
|
|
||||||
9e847e2f-777c-4bd9-886c-22256bb87f32 = MATCH), which supersedes the deferred
|
|
||||||
Ghidra check entirely. The tail BN garbled into a fake "return flag word"
|
|
||||||
(raw 306344–306346) disassembles as exactly the mirror guard:
|
|
||||||
|
|
||||||
```
|
|
||||||
00528fec fcom dword [0x007c8a00] ; result vs ε (0.00019999...)
|
|
||||||
00528ff2 fnstsw ax
|
|
||||||
00528ff4 test ah, 0x41 ; C0|C3 → result <= ε
|
|
||||||
00528ff7 jnz 0x00529009 ; skip mirror unless result > ε STRICT
|
|
||||||
00528ff9 cmp dword [esp+0xC], 0x6500000D ; arg3 vs TurnRight — arg3 IS read
|
|
||||||
00529001 je 0x00529009 ; TurnRight → skip mirror
|
|
||||||
00529003 fsubr dword [0x0079bc60] ; ST0 = 360.0f − result
|
|
||||||
00529009 ret ; float return in ST0
|
|
||||||
```
|
|
||||||
|
|
||||||
The 29 bytes BN collapsed (0x00528fec→0x00529008) fit the mirror encoding to
|
|
||||||
the byte; `test ah,0x41 / jnz` encodes strict `>` (skip on less OR equal), so
|
|
||||||
ACE's `>` comparison (Managers/MoveToManager.cs:825-826) is the correct port.
|
|
||||||
Call sites: `BeginTurnToHeading` @0x00529be0 passes CONSTANT 0x6500000d
|
|
||||||
TurnRight (mirror explicitly disabled — direction pick stays the ≤180 test);
|
|
||||||
`HandleTurnToHeading` @0x0052a1bc (raw :307495) passes the LIVE
|
|
||||||
`current_command`. Two refinements over the plan's P3 row: (1) the pin's
|
|
||||||
"previous_heading update path differs" clause is FALSE — both branches of the
|
|
||||||
progress test write `previous_heading` identically (raw @0x0052a1ef /
|
|
||||||
@0x0052a206; ACE :611-621); (2) the mirror is behaviorally DEAD in this build —
|
|
||||||
its only divergent effect is `fail_progress_count` reset-vs-increment, and
|
|
||||||
that counter is write-only (6 refs in the whole raw, all writes: =0
|
|
||||||
@0x005292b1/@0x00529f3e/@0x0052a13a/@0x0052a1e5, +=1 @0x0052a014/@0x0052a224;
|
|
||||||
zero reads) — port it for verbatim fidelity + conformance tests, not because
|
|
||||||
retail-visible behavior hangs on it. No deferred check remains (Ghidra + cdb
|
|
||||||
fallback both MOOT).
|
|
||||||
|
|
||||||
- **P4 — TargetManager scope: DECISION CONFIRMED — R4 ships the minimal
|
|
||||||
App-side `TargetTracker` adapter, not a TargetManager port.** Confidence:
|
|
||||||
**high** (as a recorded decision; the retail feed constants and delivery
|
|
||||||
contract are pinned from the extraction). On `set_target(0, tlid, 0.5, 0)`
|
|
||||||
(verbatim call sites: r4-moveto-decomp.md:359 MoveToObject §3b, :423
|
|
||||||
TurnToObject §3d; constants table :1168 — radius 0.5f, initial quantum 0)
|
|
||||||
GameWindow's entity table delivers one immediate
|
|
||||||
`TargetInfo{ObjectId, Ok, target_position = entity pos, interpolated_position
|
|
||||||
= same}`, then re-delivers per tick when the target has moved > 0.5 units
|
|
||||||
since last delivery; `clear_target` unsubscribes; target despawn/teleport
|
|
||||||
delivers `status = ExitWorld/Teleported` so `CancelMoveTo 0x37/0x38` falls out
|
|
||||||
of the verbatim manager (§6d @307802–307867: `!= Ok` on first callback →
|
|
||||||
0x38 NoObject :857-858; on retarget → 0x37 ObjectGone :866-867; retarget sets
|
|
||||||
`sought_position = interpolated`, `current_target_position = target`
|
|
||||||
:869-870 + FLT_MAX progress reseeds :871-874). `set_target_quantum` is
|
|
||||||
accepted and recorded but does NOT throttle (over-delivery is
|
|
||||||
convergence-safe). TargetStatus values from ACE (r4-ace-moveto.md:654-656):
|
|
||||||
Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5,
|
|
||||||
TimedOut=6; ACE's HandleTargetting cadence is 0.5 s with a 10 s
|
|
||||||
Undefined→TimedOut timeout. Wire inputs available today: mt-6 TargetGuid
|
|
||||||
(UpdateMotion.cs:310-315, parsed-but-unused = gap M8 — the subscription key);
|
|
||||||
wire Origin (:319-326) doubles as the first-delivery fallback when the guid
|
|
||||||
is unresolvable, mirroring retail's own case-6 degrade to
|
|
||||||
MoveToPosition(wire origin) (r4-moveto-decomp.md:290-292). NOT on the wire:
|
|
||||||
target radius/height for `cylinder_distance` (from the local entity table)
|
|
||||||
and ongoing target updates (the adapter's per-tick tracking; ACE's ~1 Hz
|
|
||||||
MoveTo re-emit is the backstop). **Register row (AP class) lands in V4,
|
|
||||||
replacing AP-8's "no target re-tracking" clause; full TargetManager port is
|
|
||||||
R5 scope.** Fallback if the adapter proves too loose in V4: grep
|
|
||||||
`TargetManager::` in the raw (~200 lines) and port verbatim.
|
|
||||||
|
|
||||||
- **P5 — Position::heading convention: PINNED — compass degrees, 0 = North
|
|
||||||
(+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion faces heading
|
|
||||||
0.** Formula: `heading(from, to) = (450 − atan2Deg(dy, dx)) % 360`. Wire
|
|
||||||
`DesiredHeading` is in THIS convention (retail @0052a71d feeds it straight
|
|
||||||
into `Frame::set_heading` on sought_position, §3e). Golden cardinals:
|
|
||||||
N(0,+1)→0, E(+1,0)→90, S(0,−1)→180, W(−1,0)→270. Confidence: **high**.
|
|
||||||
Evidence: `Position::heading` 0x005a9520 (raw 438288–438290): `450.0 −
|
|
||||||
fpatan(...) × 57.295779513082323`, fmod tail; ACE Position.cs:200-209 pins the
|
|
||||||
atan2 arg order (`Atan2(dir.Y, dir.X)`); `Frame::get_heading` 0x00535760 (raw
|
|
||||||
319781) same formula on the forward basis (ACE AFrame.cs:147-152);
|
|
||||||
`Frame::set_heading` 0x00535e40 (raw 320055–320066) round-trips — h=0 →
|
|
||||||
forward (sin 0, cos 0) = +Y. An in-tree twin of the formula already exists at
|
|
||||||
SceneryHelpers.cs:75.
|
|
||||||
|
|
||||||
**⚠ CORRECTION to the plan's own pin method — the packer-reuse trap.** The
|
|
||||||
plan says "the outbound AP packer already encodes body yaw→AC heading; REUSE
|
|
||||||
that exact conversion". Reuse it at the **QUATERNION level ONLY**
|
|
||||||
(`YawToAcQuaternion`/`AcQuaternionToYaw` are wire-correct end-to-end — its
|
|
||||||
two internal errors cancel). Its internal SCALAR intermediate
|
|
||||||
(GameWindow.cs:8083-8103: `headingDeg = 180f − yawDeg`) is **holtburger's
|
|
||||||
convention = retail + 90°**, and its comment "AC heading: 0=West, 90=North,
|
|
||||||
180=East, 270=South" (from holtburger math.rs:56, whose identity-quat note
|
|
||||||
:146-148 says "Heading 90 (North)") is WRONG as a statement of the retail wire
|
|
||||||
convention. Literal scalar reuse would face `use_final_heading` and every
|
|
||||||
`get_desired_heading` offset 90° wrong. The correct scalar bridge from acdream
|
|
||||||
yaw (yaw=0 faces +X per PlayerMovementController.cs:1022-1025) is
|
|
||||||
`heading = (90 − yawDeg) mod 360`. Quaternion-level proof:
|
|
||||||
YawToAcQuaternion(yaw=0) → (z=−0.7071, w=+0.7071), from which ACE's
|
|
||||||
AFrame.get_heading extracts 90 = East — correct for a +X-facing body.
|
|
||||||
|
|
||||||
- **P6 — TurnToObject (mt 8) wire layout: PINNED byte-for-byte from ACE's
|
|
||||||
writer, matching the decomp read order exactly.** After the common UM header
|
|
||||||
(u8 movementType, u8 motionFlags, u16 currentStyle — MovementData.cs:202-205):
|
|
||||||
`[u32 object guid][f32 standalone heading][u32 params bitfield][f32 speed]
|
|
||||||
[f32 params desired_heading]`. mt 9 (TurnToHeading) = the 3-dword
|
|
||||||
TurnToParameters only. Confidence: **high**. The standalone second field is
|
|
||||||
ACE `TurnToObject.DesiredHeading` = retail's `wire_heading`, consumed ONLY in
|
|
||||||
the unresolvable-object fallback: substituted into `params.desired_heading`,
|
|
||||||
then degrade to TurnToHeading (decomp §2f case 8, r4-moveto-decomp.md:293-298:
|
|
||||||
`object_id = read_dword(); wire_heading = read_dword(); UnPackNet(...); if
|
|
||||||
(GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; goto
|
|
||||||
label_524725; }`; ACE TurnToObject.cs:25-31 + field comment :10-11 "used
|
|
||||||
instead of the DesiredHeading in the TurnToParameters"). UnPackNet 0xc-byte
|
|
||||||
form per §2g (:311): bitfield, speed, desired_heading. Gap M7 confirmed:
|
|
||||||
UpdateMotion.cs:252 `else if (movementType is 6 or 7)` — mt 8/9 payloads
|
|
||||||
silently discarded today. **V3 fixture caveat:** ACE populates BOTH heading
|
|
||||||
fields from the same source (TurnToObject.cs:17 and TurnToParameters.cs:18
|
|
||||||
both copy `motion.DesiredHeading`), so ACE-golden fixtures always have
|
|
||||||
field2 == field5 — distinguish the two fields by OFFSET (hand-vary one in
|
|
||||||
synthetic fixtures), never by value.
|
|
||||||
|
|
||||||
- **P7 — remote Contact for the UseTime gate: PINNED — Contact IS live for
|
|
||||||
grounded remotes; no state-writer fix needed.** Confidence: **high**. Remote
|
|
||||||
bodies are BORN with `Contact | OnWalkable | Active` (GameWindow.cs:580-591)
|
|
||||||
and BOTH remote per-tick paths re-assert `TransientState |= Contact |
|
|
||||||
OnWalkable | Active` unconditionally every tick while `!rm.Airborne`
|
|
||||||
(:9366-9370 player-remote pipeline; :9570-9574 NPC/legacy — the path
|
|
||||||
RemoteMoveToDriver runs on today). The bits are cleared ONLY for the airborne
|
|
||||||
arc (:4833-4841, `Velocity.Z > 0.5f` launch) and restored on landing by two
|
|
||||||
independent paths (:5161-5167 UP grounded snap; :9808-9818 resolver landing —
|
|
||||||
which already calls `rm.Motion.HitGround()`, the exact retail re-arm seam
|
|
||||||
`MoveToManager::HitGround` §6e binds to: `if (movement_type == Invalid)
|
|
||||||
return; BeginNextNode(this);`, r4-moveto-decomp.md:884-889). Retail UseTime
|
|
||||||
gates on `transient_state & 1` (Contact only, @307781) — a strict SUBSET of
|
|
||||||
the funnel's `Contact && OnWalkable` that demonstrably works for remote
|
|
||||||
Falling dispatch today (MotionInterpreter.cs:2089-2092). A chasing grounded
|
|
||||||
NPC passes the gate every tick; an airborne remote stalls the moveto until
|
|
||||||
landing — retail behavior by design. The pin-method's live
|
|
||||||
`ACDREAM_DUMP_MOTION` smoke was NOT run (read-only pass; the write is
|
|
||||||
unconditional so the smoke is confirmatory only) — **folded into V4's
|
|
||||||
acceptance run** (already listed there: "Live smoke: NPC chase +
|
|
||||||
ACDREAM_DUMP_MOTION").
|
|
||||||
|
|
||||||
## Pending
|
|
||||||
|
|
||||||
None. No pin was refuted or left uncorrectable.
|
|
||||||
|
|
||||||
## Deferred non-blocking checks (Ghidra MCP down during V0)
|
|
||||||
|
|
||||||
All three are confirmatory — the textual adjudications above are decisive.
|
|
||||||
|
|
||||||
| Address | Function | What to confirm |
|
|
||||||
|---|---|---|
|
|
||||||
| `0x00509690` | `CPhysics::SetObjectMovement` (7-arg) | The `(arg7 == 0 \|\| ebx == 0)` gate condition; which sequence stamps are written on the reject paths (BN garbles @005096db/@00509712 are self-subtraction flag-test artifacts) |
|
|
||||||
| ~`0x00559850` | 0xF74C dispatch case | `LoseControlToServer()` fires iff `SetObjectMovement != 0` |
|
|
||||||
| `0x0052aad0` | `MovementParameters::get_desired_heading` | The 180/0 constants + per-branch polarity (currently ACE-sourced; grouping BN-verified) |
|
|
||||||
|
|
||||||
P3's deferred check (`0x00528fb0`) is MOOT — resolved by direct disassembly of
|
|
||||||
the PDB-matched binary (see the pin).
|
|
||||||
|
|
||||||
## Adjacent findings (load-bearing for V-commits, discovered while pinning)
|
|
||||||
|
|
||||||
- **runSkill-sync severance (V5 trap, from P1):** GameWindow.cs:4364-4372's
|
|
||||||
`ApplyServerRunRate(ForwardSpeed)` is fed EXCLUSIVELY by the autonomous mt-0
|
|
||||||
echo the P1 gate will drop. V5 re-anchors run-rate sync to M13 (mt-6/7
|
|
||||||
`my_run_rate` wire read → `Motion.MyRunRate`) + PlayerDescription — or ships
|
|
||||||
the contingent AD row quoted in P1.
|
|
||||||
**V5 EXECUTED (2026-07-03): re-anchor taken, NO AD row needed.** Both
|
|
||||||
retail-mechanism feeds already existed: PlayerDescription run/jump skills
|
|
||||||
flow via `SetCharacterSkills` (K-fix7, wired since before V5 — the
|
|
||||||
"we don't parse PD yet" comment in the controller ctor was stale) into
|
|
||||||
`PlayerWeenie.InqRunRate`, which `apply_run_to_command`/`get_state_velocity`
|
|
||||||
PREFER over `MyRunRate`; and V5's shared `RouteServerMoveTo` performs the
|
|
||||||
M13 `Motion.MyRunRate = MoveToRunRate` write for the local player on
|
|
||||||
mt-6/7. `ApplyServerRunRate` was deleted outright — its
|
|
||||||
`InterpretedState.ForwardSpeed` overwrite was a pre-R3 mechanism that
|
|
||||||
fought the ported machinery (any DoMotion edge recomputes ForwardSpeed
|
|
||||||
through adjust_motion/apply_run_to_command).
|
|
||||||
- **LoseControlToServer seam (R5):** retail hands autonomy to the server at the
|
|
||||||
0xF74C dispatch whenever a non-autonomous movement event is applied to the
|
|
||||||
local player (raw 357214–357235). Note for R5/MovementManager — the retail
|
|
||||||
hook `BeginServerAutoWalk` approximates today.
|
|
||||||
- **GameWindow.cs:4534-4546 comment is stale** (from P1): the "ACE follows every
|
|
||||||
mt=0x06 with an mt=0x00" causal story described the pre-#75 build's
|
|
||||||
self-inflicted echo; post-#75 no echo exists at moveto start. The comment
|
|
||||||
block dies with the code in V5 — do not port against it.
|
|
||||||
- **RemoteMoveToDriver.cs:53-57 stale claim FIXED in this commit** (plan §0
|
|
||||||
mandate): the class doc claimed "ACE swaps the chase/flee arrival predicates"
|
|
||||||
— refuted by r4-ace-moveto §1 and by the file's own :186-199 comment; retail
|
|
||||||
and ACE agree (chase arrives `dist <= distance_to_object`, flee arrives
|
|
||||||
`dist >= min_distance`).
|
|
||||||
|
|
||||||
## V0 cdb capture (pending, non-blocking)
|
|
||||||
|
|
||||||
No pin above depends on it — P1/P3 were adversarially sealed textually (P3 down
|
|
||||||
to the instruction bytes). One live retail session feeds V2/V4 goldens: bp
|
|
||||||
`MoveToManager::PerformMovement` / `BeginMoveForward` / `BeginTurnToHeading` /
|
|
||||||
`HandleMoveToPosition` / `HandleUpdateTarget` /
|
|
||||||
`MovementParameters::get_command` (args+ret) while the user runs the plan §0
|
|
||||||
protocol (door-use at range, use-while-facing-away, monster chase, outrun). A
|
|
||||||
`heading_diff` arg3+ST0 log during a TurnLeft-direction TurnToHeading would
|
|
||||||
spot-check P3's mirror live — nice-to-have only.
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# R4-V0 — Ghidra ground-truth decompiles (patchmem.gpr, fetched live 2026-07-03)
|
|
||||||
|
|
||||||
Authoritative resolutions for the two pins the BN pseudo-C garbled. Fetched
|
|
||||||
via the Ghidra MCP while the V0 pin workflow ran; these OVERRIDE any textual
|
|
||||||
adjudication that disagrees.
|
|
||||||
|
|
||||||
## P3 — `heading_diff` 0x00528fb0: the TurnLeft mirror EXISTS
|
|
||||||
|
|
||||||
```c
|
|
||||||
float __cdecl heading_diff(float param_1, float param_2, ulong param_3)
|
|
||||||
{
|
|
||||||
float fVar1;
|
|
||||||
|
|
||||||
fVar1 = param_1 - param_2;
|
|
||||||
if (ABS(param_1 - param_2) < F_EPSILON) {
|
|
||||||
fVar1 = 0.0f;
|
|
||||||
}
|
|
||||||
if (fVar1 < -F_EPSILON) {
|
|
||||||
fVar1 = fVar1 + 360.0f; // ___real_43b40000
|
|
||||||
}
|
|
||||||
if ((F_EPSILON < fVar1) && (param_3 != 0x6500000d)) {
|
|
||||||
fVar1 = 360.0f - fVar1; // the mirror — NOT-TurnRight only
|
|
||||||
}
|
|
||||||
return fVar1;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjudication of the research contradiction:
|
|
||||||
- `r4-moveto-decomp.md` §5g ("turn-command arg UNUSED") is **WRONG** — the
|
|
||||||
arg gates the mirror.
|
|
||||||
- `r4-ace-moveto.md`'s suspicion (an x87-garbled `360−diff` TurnLeft branch)
|
|
||||||
is **CONFIRMED**: the mirror applies whenever the turn command is not
|
|
||||||
TurnRight (0x6500000d), i.e. the TurnLeft direction measures the
|
|
||||||
complementary angle. Port verbatim with the epsilon literal
|
|
||||||
0.000199999995f (F_EPSILON).
|
|
||||||
|
|
||||||
## P2 — `MovementParameters::get_desired_heading` 0x0052aad0: ACE-shaped constants CONFIRMED
|
|
||||||
|
|
||||||
```c
|
|
||||||
float __thiscall MovementParameters::get_desired_heading(
|
|
||||||
MovementParameters* this, ulong command, int movingAway)
|
|
||||||
{
|
|
||||||
if ((command == 0x44000007) || (command == 0x45000005)) { // Run/WalkForward
|
|
||||||
if (movingAway == 0)
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (command != 0x45000006) // not WalkBackward
|
|
||||||
return 0.0f;
|
|
||||||
if (movingAway != 0)
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
return 180.0f; // ___real_43340000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Truth table: forward+towards → 0°; forward+away → 180°;
|
|
||||||
backward+towards → 180°; backward+away → 0°; any other command → 0°.
|
|
||||||
|
|
@ -1,857 +0,0 @@
|
||||||
# R4 ACE cross-reference — MoveToManager / MovementParameters
|
|
||||||
|
|
||||||
**Purpose:** ACE-side oracle for the R4 MoveToManager port. Per-method transcription of
|
|
||||||
`references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` (874 lines) +
|
|
||||||
`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`, call-site map,
|
|
||||||
ACE-ism flags (each spot-checked against the named retail decomp where noted), and the
|
|
||||||
acdream R4 blast-radius inventory.
|
|
||||||
|
|
||||||
**Note on paths:** the ACE reference lives in the MAIN repo tree
|
|
||||||
(`C:/Users/erikn/source/repos/acdream/references/ACE/...`), NOT in the worktree — the worktree's
|
|
||||||
`references/` only contains WorldBuilder. ACE's file is `Physics/Managers/MoveToManager.cs`
|
|
||||||
(namespace still says `Physics.Animation` — ACE moved the file without renaming the namespace).
|
|
||||||
|
|
||||||
**Retail anchor table** (named pseudo-C, `docs/research/named-retail/acclient_2013_pseudo_c.txt`):
|
|
||||||
|
|
||||||
| Function | Address | pseudo-C line |
|
|
||||||
|---|---|---|
|
|
||||||
| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 |
|
|
||||||
| `heading_greater` (free fn) | 0x00528f60 | 306281 |
|
|
||||||
| `heading_diff` (free fn) | 0x00528fb0 | 306327 |
|
|
||||||
| `MoveToManager::_DoMotion` | 0x00529010 | 306351 |
|
|
||||||
| `MoveToManager::_StopMotion` | 0x00529080 | 306368 |
|
|
||||||
| `MoveToManager::CheckProgressMade` | 0x005290f0 | 306385 |
|
|
||||||
| `MoveToManager::GetCurrentDistance` | 0x005291b0 | 306435 |
|
|
||||||
| `MoveToManager::is_moving_to` | 0x00529220 | 306464 |
|
|
||||||
| `MoveToManager::InitializeLocalVariables` | 0x00529250 | 306490 |
|
|
||||||
| `MoveToManager::RemovePendingActionsHead` | 0x00529380 | 306538 |
|
|
||||||
| `MoveToManager::MoveToManager` (ctor) | 0x005293b0 | 306554 |
|
|
||||||
| `MoveToManager::CleanUp` | 0x005295c0 | 306710 |
|
|
||||||
| `MoveToManager::CleanUpAndCallWeenie` | 0x00529650 | 306740 |
|
|
||||||
| `MoveToManager::MoveToObject` | 0x00529680 | 306756 |
|
|
||||||
| `MoveToManager::TurnToObject` | 0x005297d0 | 306820 |
|
|
||||||
| `MoveToManager::CancelMoveTo` | 0x00529930 | 306886 |
|
|
||||||
| `MoveToManager::BeginMoveForward` | 0x00529a00 | 306957 |
|
|
||||||
| `MoveToManager::BeginTurnToHeading` | 0x00529b90 | 307046 |
|
|
||||||
| `MoveToManager::BeginNextNode` | 0x00529cb0 | 307123 |
|
|
||||||
| `MoveToManager::HitGround` | 0x00529d70 | 307175 |
|
|
||||||
| `MoveToManager::HandleMoveToPosition` | 0x00529d80 | 307187 |
|
|
||||||
| `MoveToManager::HandleTurnToHeading` | 0x0052a0c0 | 307442 |
|
|
||||||
| `MoveToManager::MoveToPosition` | 0x0052a240 | 307521 |
|
|
||||||
| `MoveToManager::MoveToObject_Internal` | 0x0052a400 | 307597 |
|
|
||||||
| `MoveToManager::TurnToObject_Internal` | 0x0052a550 | 307667 |
|
|
||||||
| `MoveToManager::TurnToHeading` | 0x0052a630 | 307706 |
|
|
||||||
| `MoveToManager::UseTime` | 0x0052a780 | 307776 |
|
|
||||||
| `MoveToManager::HandleUpdateTarget` | 0x0052a7d0 | 307802 |
|
|
||||||
| `MoveToManager::PerformMovement` | 0x0052a900 | 307871 |
|
|
||||||
| `MovementParameters::towards_and_away` | 0x0052a9a0 | 307917 |
|
|
||||||
| `MovementParameters::get_command` | 0x0052aa00 | 307946 |
|
|
||||||
| `MovementParameters::get_desired_heading` | 0x0052aad0 | 308016 |
|
|
||||||
| `MovementParameters::Pack / UnPack / UnPackNet` | 0x0052ab20 / 0x0052abc0 / 0x0052ac50 | 308037 / 308078 / 308118 |
|
|
||||||
|
|
||||||
Retail struct layout (verbatim PDB, `acclient.h:31473-31497`): `movement_type, sought_position,
|
|
||||||
current_target_position, starting_position, movement_params, previous_heading, previous_distance,
|
|
||||||
previous_distance_time (long double), original_distance, original_distance_time, fail_progress_count,
|
|
||||||
sought_object_id, top_level_object_id, sought_object_radius, sought_object_height, current_command,
|
|
||||||
aux_command, moving_away, initialized, pending_actions (DLList<MovementNode>), physics_obj, weenie_obj`.
|
|
||||||
`MovementNode : DLListData { MovementTypes::Type type; float heading; }` (`acclient.h:57702`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ACE MoveToManager — state
|
|
||||||
|
|
||||||
`MoveToManager.cs:13-35`:
|
|
||||||
|
|
||||||
```
|
|
||||||
MovementType MovementType // enum: Invalid=0 ... MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9
|
|
||||||
Position SoughtPosition // MoveToObject: interpolated target pos; also carries heading for TurnTo*
|
|
||||||
Position CurrentTargetPosition // raw target position (distance measured against THIS)
|
|
||||||
Position StartingPosition // for FailDistance check
|
|
||||||
MovementParameters MovementParams
|
|
||||||
float PreviousHeading // NOTE: BeginTurnToHeading stores a heading DIFF here, HandleTurnToHeading stores headings (retail quirk, faithful)
|
|
||||||
float PreviousDistance; double PreviousDistanceTime
|
|
||||||
float OriginalDistance; double OriginalDistanceTime
|
|
||||||
int FailProgressCount // incremented, NEVER read (see §5 negative results — retail-faithful)
|
|
||||||
uint SoughtObjectID, TopLevelObjectID
|
|
||||||
float SoughtObjectRadius, SoughtObjectHeight
|
|
||||||
uint CurrentCommand // active forward/turn motion (full 32-bit command)
|
|
||||||
uint AuxCommand // in-motion heading-correction turn command
|
|
||||||
bool MovingAway, Initialized
|
|
||||||
List<MovementNode> PendingActions // MovementNode { MovementType Type; float Heading }
|
|
||||||
PhysicsObj PhysicsObj; WeenieObject WeenieObj
|
|
||||||
bool AlwaysTurn // ACE-ONLY (no retail counterpart) — see flag A7
|
|
||||||
```
|
|
||||||
|
|
||||||
MotionCommand constants used: `WalkForward = 0x45000005`, `WalkBackwards = 0x45000006`,
|
|
||||||
`TurnRight = 0x6500000D`, `TurnLeft = 0x6500000E`, `RunForward = 0x44000007`.
|
|
||||||
`PhysicsGlobals.EPSILON = 0.0002f` (retail literal `0.000199999995f` everywhere in this family).
|
|
||||||
|
|
||||||
### Init / InitializeLocalVars (`:57-89`)
|
|
||||||
|
|
||||||
```
|
|
||||||
Init(): MovementParams = new MovementParameters(); PendingActions = new List<MovementNode>();
|
|
||||||
InitializeLocalVars():
|
|
||||||
MovementType = Invalid
|
|
||||||
MovementParams.DistanceToObject = 0 // ⚠ flag A2 — retail zeroes the FLAGS word here, not distance_to_object
|
|
||||||
MovementParams.ContextID = 0
|
|
||||||
PreviousDistanceTime = OriginalDistanceTime = now
|
|
||||||
PreviousHeading = 0
|
|
||||||
FailProgressCount = 0; CurrentCommand = 0; AuxCommand = 0; MovingAway = false; Initialized = false
|
|
||||||
SoughtPosition = new Position(); CurrentTargetPosition = new Position()
|
|
||||||
SoughtObjectID = TopLevelObjectID = 0; SoughtObjectRadius = SoughtObjectHeight = 0
|
|
||||||
// ⚠ flag A3 — retail ALSO sets previous_distance = original_distance = FLT_MAX here (0052926c/0052928a); ACE omits both
|
|
||||||
```
|
|
||||||
|
|
||||||
### PerformMovement(MovementStruct mvs) (`:91-112`) — retail 0x0052a900 ✓ matches
|
|
||||||
|
|
||||||
```
|
|
||||||
CancelMoveTo(WeenieError.ActionCancelled) // 0x36 — retail same constant
|
|
||||||
PhysicsObj.unstick_from_object() // ⚠ no null check; retail also derefs unconditionally here
|
|
||||||
switch (mvs.Type):
|
|
||||||
MoveToObject → MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params)
|
|
||||||
MoveToPosition → MoveToPosition(mvs.Position, mvs.Params)
|
|
||||||
TurnToObject → TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params)
|
|
||||||
TurnToHeading → TurnToHeading(mvs.Params)
|
|
||||||
return WeenieError.None // retail returns 0 unconditionally too
|
|
||||||
```
|
|
||||||
|
|
||||||
### MoveToObject(objectID, topLevelID, radius, height, params) (`:114-139`) — retail 0x00529680 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) return // retail: null branch still runs StopCompletely-if-nonnull tail (no-op)
|
|
||||||
PhysicsObj.StopCompletely(false)
|
|
||||||
StartingPosition = copy(PhysicsObj.Position)
|
|
||||||
SoughtObjectID = objectID; SoughtObjectRadius = radius; SoughtObjectHeight = height
|
|
||||||
MovementType = MoveToObject; TopLevelObjectID = topLevelID
|
|
||||||
MovementParams = copy(movementParams) // retail: full field-by-field copy incl flags; Sticky NOT unset here
|
|
||||||
Initialized = false
|
|
||||||
if (PhysicsObj.ID != topLevelID):
|
|
||||||
PhysicsObj.set_target(0, TopLevelObjectID, 0.5f, 0.0) // contextID=0, radius=0.5, quantum=0 — retail identical constants
|
|
||||||
return
|
|
||||||
CleanUp(); PhysicsObj.StopCompletely(false) // self-target degenerate path
|
|
||||||
```
|
|
||||||
|
|
||||||
No nodes are queued here — the chain continues via TargetManager voyeur callback →
|
|
||||||
`HandleUpdateTarget` → `MoveToObject_Internal`.
|
|
||||||
|
|
||||||
### MoveToObject_Internal(targetPosition, interpolatedPosition) (`:141-182`) — retail 0x0052a400 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject=8); return }
|
|
||||||
SoughtPosition = copy(interpolatedPosition); CurrentTargetPosition = copy(targetPosition)
|
|
||||||
iHeading = PhysicsObj.Position.heading(interpolatedPosition) // note: heading measured to INTERPOLATED pos
|
|
||||||
heading = iHeading - PhysicsObj.get_heading()
|
|
||||||
dist = GetCurrentDistance()
|
|
||||||
if (|heading| < EPSILON) heading = 0; if (heading < -EPSILON) heading += 360
|
|
||||||
MovementParams.get_command(dist, heading, ref motionID, ref holdKey, ref moveAway) // on the STORED member — retail 0052a4d6 same
|
|
||||||
if (motionID != 0): AddTurnToHeadingNode(iHeading); AddMoveToPositionNode()
|
|
||||||
if (MovementParams.UseFinalHeading): // member read is CORRECT here (params stored by MoveToObject already)
|
|
||||||
dHeading = iHeading + MovementParams.DesiredHeading; if (dHeading >= 360) dHeading -= 360
|
|
||||||
AddTurnToHeadingNode(dHeading)
|
|
||||||
Initialized = true
|
|
||||||
BeginNextNode()
|
|
||||||
```
|
|
||||||
|
|
||||||
### MoveToPosition(position, params) (`:184-228`) — retail 0x0052a240 — ⚠ flag A1
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) return
|
|
||||||
PhysicsObj.StopCompletely(false)
|
|
||||||
CurrentTargetPosition = copy(position); SoughtObjectRadius = 0
|
|
||||||
distance = GetCurrentDistance()
|
|
||||||
headingDiff = Position.heading(position) - get_heading(); epsilon-normalize as above
|
|
||||||
movementParams.get_command(distance, headingDiff, ref command, ref holdKey, ref moveAway) // on the ARGUMENT — retail 0052a304 same
|
|
||||||
if (command != 0): AddTurnToHeadingNode(heading(position)); AddMoveToPositionNode()
|
|
||||||
if (MovementParams.UseFinalHeading) // ⚠ A1: reads the STALE MEMBER (old params) — retail 0052a33b reads
|
|
||||||
AddTurnToHeadingNode(movementParams.DesiredHeading) // the ARGUMENT's flag byte (arg3->__inner0 & 0x40)
|
|
||||||
SoughtPosition = copy(position); StartingPosition = copy(PhysicsObj.Position)
|
|
||||||
MovementType = MoveToPosition
|
|
||||||
MovementParams = copy(movementParams); MovementParams.Sticky = false // retail: full copy then __inner0 &= 0xffffff7f ✓
|
|
||||||
BeginNextNode()
|
|
||||||
```
|
|
||||||
|
|
||||||
### TurnToObject(objectID, topLevelID, params) (`:230-259`) — retail 0x005297d0 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
|
|
||||||
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) // retail gates on flag byte2 & 1 (0x10000) ✓
|
|
||||||
MovementType = TurnToObject; SoughtObjectID = objectID
|
|
||||||
CurrentTargetPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052986c writes current_target_position ✓
|
|
||||||
TopLevelObjectID = topLevelID
|
|
||||||
MovementParams = copy(movementParams) // retail: full copy; Sticky NOT unset (unlike TurnToHeading)
|
|
||||||
if (PhysicsObj.ID != topLevelID):
|
|
||||||
Initialized = false
|
|
||||||
PhysicsObj.set_target(0, topLevelID, 0.5f, 0.0)
|
|
||||||
return
|
|
||||||
CleanUp(); PhysicsObj.StopCompletely(false)
|
|
||||||
```
|
|
||||||
|
|
||||||
Note the desired-heading value written into `CurrentTargetPosition.Frame` is CLOBBERED by
|
|
||||||
`TurnToObject_Internal` before it is ever read (Internal overwrites CurrentTargetPosition from the
|
|
||||||
wire target and reads `SoughtPosition.Frame`'s heading, which InitializeLocalVars reset to 0).
|
|
||||||
Verified this is a *retail* quirk, not an ACE bug — the retail decomp does exactly the same
|
|
||||||
(0052986c write → 0052a571 clobber → 0052a58d read-from-sought). Port it verbatim.
|
|
||||||
|
|
||||||
### TurnToObject_Internal(targetPosition) (`:261-282`) — retail 0x0052a550 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
|
|
||||||
CurrentTargetPosition = copy(targetPosition)
|
|
||||||
targetHeading = PhysicsObj.Position.heading(CurrentTargetPosition)
|
|
||||||
soughtHeading = SoughtPosition.Frame.get_heading()
|
|
||||||
heading = (targetHeading + soughtHeading) % 360 // retail: fmod(…, 360) via CIfmod
|
|
||||||
SoughtPosition.Frame.set_heading(heading)
|
|
||||||
PendingActions.Add(TurnToHeading node, heading) // retail: node type 9
|
|
||||||
Initialized = true
|
|
||||||
BeginNextNode()
|
|
||||||
```
|
|
||||||
|
|
||||||
### TurnToHeading(params) (`:284-304`) — retail 0x0052a630 — ⚠ flag A4
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
|
|
||||||
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false)
|
|
||||||
MovementParams = copy(movementParams); MovementParams.Sticky = false // retail __inner0 &= 0xffffff7f ✓
|
|
||||||
SoughtPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052a71d ✓
|
|
||||||
MovementType = TurnToHeading
|
|
||||||
PendingActions.Add(TurnToHeading node, DesiredHeading)
|
|
||||||
// ⚠ A4: ACE STOPS HERE. Retail 0052a76d calls BeginNextNode(this) immediately after the insert.
|
|
||||||
```
|
|
||||||
|
|
||||||
### BeginNextNode (`:316-349`) — retail 0x00529cb0 — ⚠ flag A5 (weenie callback)
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PendingActions non-empty):
|
|
||||||
head.Type == MoveToPosition → BeginMoveForward()
|
|
||||||
head.Type == TurnToHeading → BeginTurnToHeading()
|
|
||||||
(other types: fall through / return — retail explicitly returns for any other type)
|
|
||||||
else:
|
|
||||||
if (MovementParams.Sticky): // retail: signed test on flags byte0 (bit 0x80)
|
|
||||||
capture (radius, height, topLevelID) BEFORE CleanUp
|
|
||||||
CleanUpAndCallWeenie(WeenieError.None) // retail: CleanUp + StopCompletely inline — NO weenie call
|
|
||||||
if (PhysicsObj != null)
|
|
||||||
PhysicsObj.get_position_manager().StickTo(topLevelObjectID, radius, height)
|
|
||||||
else:
|
|
||||||
CleanUpAndCallWeenie(WeenieError.None)
|
|
||||||
```
|
|
||||||
|
|
||||||
### BeginMoveForward (`:351-399`) — retail 0x00529a00 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
|
|
||||||
dist = GetCurrentDistance()
|
|
||||||
heading = Position.heading(CurrentTargetPosition) - get_heading(); epsilon-normalize
|
|
||||||
MovementParams.get_command(dist, heading, ref motion, ref holdKey, ref moveAway) // stored member ✓
|
|
||||||
if (motion == 0) { RemovePendingActionsHead(); BeginNextNode(); return }
|
|
||||||
mp = new MovementParameters { HoldKeyToApply = holdKey, // the get_command RESULT — retail 00529af4 ✓
|
|
||||||
CancelMoveTo = false, // retail flags &= 0xffff7fff ✓
|
|
||||||
Speed = MovementParams.Speed }
|
|
||||||
result = _DoMotion(motion, mp); if (result != None) { CancelMoveTo(result); return }
|
|
||||||
CurrentCommand = motion; MovingAway = moveAway
|
|
||||||
MovementParams.HoldKeyToApply = holdKey // write-back onto stored params — retail 00529b45 ✓
|
|
||||||
PreviousDistance = OriginalDistance = dist
|
|
||||||
PreviousDistanceTime = OriginalDistanceTime = now
|
|
||||||
```
|
|
||||||
|
|
||||||
### HandleMoveToPosition (`:404-504`) — per-tick chase driver — retail 0x00529d80 — ⚠ flags A6, A8, A9
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
|
|
||||||
curPos = copy(Position)
|
|
||||||
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
|
|
||||||
HoldKeyToApply = MovementParams.HoldKeyToApply } // retail 00529dc7-00529dea ✓
|
|
||||||
if (!PhysicsObj.IsAnimating): // retail: motions_pending() == 0
|
|
||||||
heading = MovementParams.get_desired_heading(CurrentCommand, MovingAway) + curPos.heading(CurrentTargetPosition)
|
|
||||||
if (heading >= 360) heading -= 360
|
|
||||||
diff = heading - get_heading(); epsilon-normalize (0 clamp, +360 if < -EPSILON)
|
|
||||||
if (diff > 20 && diff < 340): // retail constants 20 / (360-20) ✓
|
|
||||||
motionID = (diff >= 180) ? TurnLeft : TurnRight
|
|
||||||
if (motionID != AuxCommand) { _DoMotion(motionID, mp); AuxCommand = motionID }
|
|
||||||
else:
|
|
||||||
if (AuxCommand != 0) PhysicsObj.set_heading(heading, true) // ⚠ A6: ACE-ONLY ("custom: sync for server ticrate")
|
|
||||||
stop_aux_command(mp) // retail only stops the aux command, no snap
|
|
||||||
else:
|
|
||||||
stop_aux_command(mp) // retail: aux stop under motions_pending ✓
|
|
||||||
dist = GetCurrentDistance()
|
|
||||||
if (!CheckProgressMade(dist)):
|
|
||||||
if (!IsInterpolating() && !IsAnimating) FailProgressCount++ // retail identical gate ✓
|
|
||||||
else:
|
|
||||||
inRange = false // ⚠ A8: whole inRange block is ACE-ONLY ("custom for low monster update rate")
|
|
||||||
if (!MovementParams.UseSpheres): // including the extra PreviousDistance/Time refresh
|
|
||||||
if (dist < 1.0 && PreviousDistance < dist) inRange = true
|
|
||||||
PreviousDistance = dist; PreviousDistanceTime = now
|
|
||||||
FailProgressCount = 0
|
|
||||||
if (MovingAway && dist >= MinDistance || !MovingAway && dist <= DistanceToObject || inRange):
|
|
||||||
PendingActions.RemoveAt(0)
|
|
||||||
_StopMotion(CurrentCommand, mp); CurrentCommand = 0
|
|
||||||
stop_aux_command(mp) // ⚠ A9: retail stops aux INSIDE the same block, before BeginNextNode — same order ✓
|
|
||||||
BeginNextNode()
|
|
||||||
else:
|
|
||||||
if (StartingPosition.Distance(Position) > MovementParams.FailDistance)
|
|
||||||
CancelMoveTo(YouChargedTooFar) // 0x3D — retail same constant (00529f79)
|
|
||||||
if (TopLevelObjectID != 0 && MovementType != Invalid):
|
|
||||||
v = |get_velocity()|
|
|
||||||
if (v > 0.1): // retail double literal 0.1 ✓
|
|
||||||
time = dist / v
|
|
||||||
if (|time - get_target_quantum()| > 1.0) set_target_quantum(time) // retail 1.0 ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
Arrival predicate cross-check: flee branch is explicit in the retail decomp
|
|
||||||
(`dist >= min_distance` → arrival, 00529f44-00529f51). The chase branch's literal BN rendering
|
|
||||||
*appears* inverted (`dist <= distance_to_object` branching to the fail-distance check) — that is a
|
|
||||||
known `test ah, 0x41` branch-sense garble; the flee branch + symmetric structure + two prior
|
|
||||||
independent research passes (see `RemoteMoveToDriver.cs:186-199`) fix the correct reading as:
|
|
||||||
**chase arrives at `dist <= distance_to_object`, flee arrives at `dist >= min_distance`** — which
|
|
||||||
is exactly ACE's line 476. (The stale class-doc comment in acdream's `RemoteMoveToDriver.cs:53-57`
|
|
||||||
claiming "ACE swaps the predicates vs retail" is WRONG and superseded by the later comment in the
|
|
||||||
same file — see §6 blast radius.)
|
|
||||||
|
|
||||||
### BeginTurnToHeading (`:510-565`) — retail 0x00529b90 — ⚠ flags A7, A10
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
|
|
||||||
// ⚠ A10: retail ALSO CancelMoveTo(8) when pending_actions head == null;
|
|
||||||
// ACE indexes PendingActions[0] later (throws if empty — callers guard)
|
|
||||||
if (PhysicsObj.IsAnimating && !AlwaysTurn) return // ⚠ A7: retail is a plain motions_pending()!=0 → return; AlwaysTurn is ACE-only
|
|
||||||
headingDiff = heading_diff(node.Heading, get_heading(), TurnRight)
|
|
||||||
if (headingDiff <= 180):
|
|
||||||
if (headingDiff > EPSILON) motionID = TurnRight
|
|
||||||
else { RemovePendingActionsHead(); BeginNextNode(); return }
|
|
||||||
else:
|
|
||||||
if (headingDiff + EPSILON <= 360) motionID = TurnLeft
|
|
||||||
else { RemovePendingActionsHead(); BeginNextNode(); return }
|
|
||||||
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
|
|
||||||
HoldKeyToApply = MovementParams.HoldKeyToApply }
|
|
||||||
result = _DoMotion(motionID, mp); if (result != None) { CancelMoveTo(result); return }
|
|
||||||
CurrentCommand = motionID
|
|
||||||
PreviousHeading = headingDiff // stores the DIFF, not a heading — retail 00529c82 identical quirk
|
|
||||||
```
|
|
||||||
|
|
||||||
(Retail decomp's turn-direction branch senses are x87-garbled here too; the retail structure —
|
|
||||||
`heading_diff(node, current, TurnRight)`, ≤180 → TurnRight, else TurnLeft, pop-when-within-epsilon —
|
|
||||||
matches ACE's reading. Constants: 180, 360, EPSILON.)
|
|
||||||
|
|
||||||
### HandleTurnToHeading (`:570-621`) — per-tick turn driver — retail 0x0052a0c0 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
|
|
||||||
if (CurrentCommand != TurnRight && CurrentCommand != TurnLeft) { BeginTurnToHeading(); return }
|
|
||||||
node = PendingActions[0]; heading = get_heading()
|
|
||||||
if (heading_greater(heading, node.Heading, CurrentCommand)): // crossed the target heading
|
|
||||||
FailProgressCount = 0
|
|
||||||
PhysicsObj.set_heading(node.Heading, true) // snap exactly — retail 0052a146 ✓ (this snap IS retail)
|
|
||||||
RemovePendingActionsHead()
|
|
||||||
mp = new MovementParameters { CancelMoveTo = false, HoldKeyToApply = MovementParams.HoldKeyToApply }
|
|
||||||
_StopMotion(CurrentCommand, mp); CurrentCommand = 0
|
|
||||||
BeginNextNode(); return
|
|
||||||
diff = heading_diff(heading, PreviousHeading, CurrentCommand)
|
|
||||||
if (diff > EPSILON && diff < 180):
|
|
||||||
FailProgressCount = 0; PreviousHeading = heading
|
|
||||||
else:
|
|
||||||
PreviousHeading = heading
|
|
||||||
if (!IsInterpolating() && !IsAnimating) FailProgressCount++
|
|
||||||
```
|
|
||||||
|
|
||||||
Note ACE omits `movementParams.Speed` in the stop-params here (retail also builds a default-speed
|
|
||||||
params for the stop — retail 0052a16a copies only hold_key + clears CancelMoveTo; ACE ✓).
|
|
||||||
|
|
||||||
### HandleUpdateTarget(TargetInfo) (`:623-666`) — retail 0x0052a7d0 ✓ (reordered but equivalent)
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
|
|
||||||
if (TopLevelObjectID != targetInfo.ObjectID) return
|
|
||||||
if (Initialized):
|
|
||||||
if (status == OK):
|
|
||||||
if (MovementType == MoveToObject):
|
|
||||||
SoughtPosition = copy(InterpolatedPosition); CurrentTargetPosition = copy(TargetPosition)
|
|
||||||
PreviousDistance = OriginalDistance = float.MaxValue
|
|
||||||
PreviousDistanceTime = OriginalDistanceTime = now
|
|
||||||
else CancelMoveTo(ObjectGone) // 0x37 — retail same (0052a80f)
|
|
||||||
else if (TopLevelObjectID == PhysicsObj.ID):
|
|
||||||
SoughtPosition = CurrentTargetPosition = copy(Position)
|
|
||||||
CleanUpAndCallWeenie(None)
|
|
||||||
else if (status == OK):
|
|
||||||
MovementType == MoveToObject → MoveToObject_Internal(TargetPosition, InterpolatedPosition)
|
|
||||||
MovementType == TurnToObject → TurnToObject_Internal(TargetPosition)
|
|
||||||
else CancelMoveTo(NoObject) // 0x38 — retail same (0052a8bd)
|
|
||||||
```
|
|
||||||
|
|
||||||
Retail evaluates `!initialized` first and nests the self-target + status checks inside it; ACE
|
|
||||||
flipped to `if (Initialized)` first. Same reachable behavior; error codes verified identical
|
|
||||||
(0x37 initialized-path, 0x38 uninitialized-path — no transposition).
|
|
||||||
|
|
||||||
### CheckProgressMade(currDistance) (`:668-688`) — retail 0x005290f0 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
deltaTime = now - PreviousDistanceTime
|
|
||||||
if (deltaTime > 1.0): // 1-second window
|
|
||||||
diffDist = MovingAway ? curr - PreviousDistance : PreviousDistance - curr
|
|
||||||
if (diffDist / deltaTime < 0.25) return false // 0.25 m/s minimum closing rate
|
|
||||||
PreviousDistance = curr; PreviousDistanceTime = now
|
|
||||||
dOrigDist = MovingAway ? curr - OriginalDistance : OriginalDistance - curr
|
|
||||||
if (dOrigDist / (now - OriginalDistanceTime) < 0.25) return false
|
|
||||||
return true
|
|
||||||
```
|
|
||||||
|
|
||||||
### CancelMoveTo(retval) (`:690-699`) — retail 0x00529930 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (MovementType == Invalid) return
|
|
||||||
PendingActions.Clear() // retail: DLList drain + delete
|
|
||||||
CleanUpAndCallWeenie(retval)
|
|
||||||
```
|
|
||||||
|
|
||||||
### CleanUp (`:701-719`) — retail 0x005295c0 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
mp = new MovementParameters { HoldKeyToApply = MovementParams.HoldKeyToApply, CancelMoveTo = false }
|
|
||||||
if (PhysicsObj != null):
|
|
||||||
if (CurrentCommand != 0) _StopMotion(CurrentCommand, mp)
|
|
||||||
if (AuxCommand != 0) _StopMotion(AuxCommand, mp)
|
|
||||||
if (TopLevelObjectID != 0 && MovementType != Invalid) PhysicsObj.clear_target()
|
|
||||||
InitializeLocalVars()
|
|
||||||
```
|
|
||||||
|
|
||||||
### CleanUpAndCallWeenie(status) (`:721-729`) — retail 0x00529650 — ⚠ flag A5
|
|
||||||
|
|
||||||
```
|
|
||||||
CleanUp()
|
|
||||||
if (PhysicsObj != null) PhysicsObj.StopCompletely(false)
|
|
||||||
WeenieObj.OnMoveComplete(status) // ⚠ ACE "server custom": retail body is CleanUp + StopCompletely ONLY
|
|
||||||
// (despite the function name, the 2013 client build has no weenie
|
|
||||||
// call here — compiled out / client-side no-op).
|
|
||||||
// ⚠ also: WeenieObj is NOT null-checked while PhysicsObj is → NPE if
|
|
||||||
// constructed via the parameterless ctor (flag A11).
|
|
||||||
```
|
|
||||||
|
|
||||||
### GetCurrentDistance (`:731-741`) — retail 0x005291b0
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) return float.MaxValue
|
|
||||||
if (!UseSpheres flag) return Position.Distance(CurrentTargetPosition)
|
|
||||||
return Position.CylinderDistance(GetRadius(), GetHeight(), Position,
|
|
||||||
SoughtObjectRadius, SoughtObjectHeight, CurrentTargetPosition)
|
|
||||||
```
|
|
||||||
|
|
||||||
Distance is measured against **CurrentTargetPosition** in both variants; the sphere variant folds
|
|
||||||
in both bodies' radius/height (this is why ACE chase packets can carry `distance_to_object = 0.6`
|
|
||||||
and still stop at melee range).
|
|
||||||
|
|
||||||
### HitGround (`:743-747`) — retail 0x00529d70 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (MovementType != Invalid) BeginNextNode()
|
|
||||||
```
|
|
||||||
|
|
||||||
### UseTime (`:765-787`) — retail 0x0052a780 — ⚠ flag A12 (gate inversion)
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null || !TransientState.Contact) return // retail: transient_state & 1 ✓
|
|
||||||
if (PendingActions.Count == 0) return
|
|
||||||
if (TopLevelObjectID != 0 || MovementType != Invalid || Initialized): // ⚠ A12: retail gate is
|
|
||||||
head.Type == MoveToPosition → HandleMoveToPosition() // (top_level_object_id == 0
|
|
||||||
head.Type == TurnToHeading → HandleTurnToHeading() // || movement_type == Invalid
|
|
||||||
// || initialized != 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
Retail (0052a7b4): proceed when `topLevel == 0 || type == Invalid || initialized` — i.e. **skip
|
|
||||||
only while a MoveToObject/TurnToObject is still waiting for its first target resolution**. ACE
|
|
||||||
negated the first two clauses. In practice ACE is masked by the `PendingActions.Count == 0` early
|
|
||||||
return (nodes only exist after `*_Internal` ran, which also sets `Initialized`), but port the
|
|
||||||
retail condition verbatim — the masked edge (stale nodes surviving a CleanUp that doesn't clear
|
|
||||||
PendingActions) differs.
|
|
||||||
|
|
||||||
### _DoMotion / _StopMotion (`:789-815`) — retail 0x00529010 / 0x00529080 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (PhysicsObj == null) return NoPhysicsObject // 8
|
|
||||||
minterp = PhysicsObj.get_minterp(); if null return NoMotionInterpreter // 0xB
|
|
||||||
minterp.adjust_motion(ref motion, ref movementParams.Speed, movementParams.HoldKeyToApply)
|
|
||||||
return minterp.DoInterpretedMotion(motion, movementParams) // _StopMotion → StopInterpretedMotion
|
|
||||||
```
|
|
||||||
|
|
||||||
The adjust-then-dispatch double (DoInterpretedMotion internally copies params and adjusts again —
|
|
||||||
`MotionInterp.cs:117-129`) **is retail-faithful**: retail's 0x00529057 calls
|
|
||||||
`CMotionInterp::adjust_motion` and `CMotionInterp::DoInterpretedMotion` also adjusts internally.
|
|
||||||
Do NOT "fix" the double adjust.
|
|
||||||
|
|
||||||
### heading_diff(h1, h2, motion) (`:817-828`) — retail free fn 0x00528fb0 (x87-garbled)
|
|
||||||
|
|
||||||
```
|
|
||||||
result = h1 - h2
|
|
||||||
if (|result| < EPSILON) result = 0
|
|
||||||
if (result < -EPSILON) result += 360
|
|
||||||
if (result > EPSILON && motion != TurnRight) result = 360 - result // mirror for TurnLeft
|
|
||||||
```
|
|
||||||
|
|
||||||
The BN pseudo-C for the retail fn is return-value garbled (float in ST0 rendered as a flags-word
|
|
||||||
int) and doesn't show the `motion != TurnRight` mirror — the R4 port should verify the mirror once
|
|
||||||
in Ghidra (`/decompile_function?address=0x00528fb0`), but ACE's shape is consistent with every call
|
|
||||||
site (BeginTurnToHeading passes TurnRight explicitly to get the raw clockwise diff).
|
|
||||||
|
|
||||||
### heading_greater(h1, h2, motion) (`:830-858`) — retail free fn 0x00528f60 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
diff = |h1 - h2|
|
|
||||||
if (diff <= 180): result = h1 > h2
|
|
||||||
else: result = h2 > h1
|
|
||||||
if (motion != TurnRight) result = !result
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
ACE's rewrite verified equivalent to the retail comparison tree (including the equality case —
|
|
||||||
equal headings → false for TurnRight). The original decompiled expression is preserved in an ACE
|
|
||||||
comment at `:832-836`.
|
|
||||||
|
|
||||||
### is_moving_to / stop_aux_command / small helpers (`:860-872`, `:306-314`, `:749-763`)
|
|
||||||
|
|
||||||
```
|
|
||||||
is_moving_to() => MovementType != Invalid
|
|
||||||
stop_aux_command(mp): if (AuxCommand != 0) { _StopMotion(AuxCommand, mp); AuxCommand = 0 }
|
|
||||||
AddMoveToPositionNode(): PendingActions.Add(node(MoveToPosition)) // retail node type 7
|
|
||||||
AddTurnToHeadingNode(h): PendingActions.Add(node(TurnToHeading, h)) // retail node type 9
|
|
||||||
RemovePendingActionsHead(): if (Count > 0) RemoveAt(0)
|
|
||||||
SetPhysicsObject / SetWeenieObject: plain assignments
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. ACE MovementParameters (`Physics/Animation/MovementParameters.cs`)
|
|
||||||
|
|
||||||
Bool-per-flag class; `Flags` property round-trips through `MovementParamFlagsHelper`
|
|
||||||
(`MovementParamFlags.cs`): `CanWalk 0x1, CanRun 0x2, CanSidestep 0x4, CanWalkBackwards 0x8,
|
|
||||||
CanCharge 0x10, FailWalk 0x20, UseFinalHeading 0x40, Sticky 0x80, MoveAway 0x100, MoveTowards 0x200,
|
|
||||||
UseSpheres 0x400, SetHoldKey 0x800, Autonomous 0x1000, ModifyRawState 0x2000,
|
|
||||||
ModifyInterpretedState 0x4000, CancelMoveTo 0x8000, StopCompletely 0x10000,
|
|
||||||
DisableJumpDuringLink 0x20000`. Matches `acclient.h` bit assignments (cross-checked against
|
|
||||||
acdream `UpdateMotion.cs` wire comments).
|
|
||||||
|
|
||||||
### Defaults (ctor, `:52-85`) — retail 0x00524380 — ⚠ flags A13, A14
|
|
||||||
|
|
||||||
| Field | ACE default | Retail default |
|
|
||||||
|---|---|---|
|
|
||||||
| flags | CanWalk+CanRun+CanSidestep+CanWalkBackwards+**CanCharge**+MoveTowards+UseSpheres+SetHoldKey+ModifyRawState+ModifyInterpretedState+CancelMoveTo+StopCompletely = **0x1EE1F** | **0x1EE0F** (CanCharge 0x10 CLEAR) — ⚠ A13 |
|
|
||||||
| DistanceToObject | 0.6f | 0.6f ✓ |
|
|
||||||
| FailDistance | float.MaxValue | FLT_MAX ✓ |
|
|
||||||
| MinDistance | 0 | 0 ✓ |
|
|
||||||
| Speed | 1.0f | 1.0f ✓ |
|
|
||||||
| WalkRunThreshold | **1.0f** (15.0f commented out) | **15.0f** — ⚠ A14 (ACE server tuning) |
|
|
||||||
| DesiredHeading | 0 | 0 ✓ |
|
|
||||||
| HoldKeyToApply | Invalid | Invalid ✓ |
|
|
||||||
| ContextID / ActionStamp | 0 | 0 ✓ |
|
|
||||||
|
|
||||||
(Retail caches the default flag word in a static `set_moveto_flags::normal_bitfield` — one-time
|
|
||||||
compute of 0x1EE0F, 005243c1-005243e1.)
|
|
||||||
|
|
||||||
### Copy constructors (`:90-150`)
|
|
||||||
|
|
||||||
- `MovementParameters(mvp, onlyBits=false)` — copies Flags always, plus non-flag fields
|
|
||||||
(`CopyNonFlag`: DistanceToObject, FailDistance, DesiredHeading, MinDistance, Speed,
|
|
||||||
WalkRunThreshold, ContextID, HoldKeyToApply, ActionStamp) unless `onlyBits`.
|
|
||||||
Retail assigns field-by-field at each MoveTo*/TurnTo* site — full copy. Equivalent.
|
|
||||||
- `CopySome(mvp)` — used by MotionInterp.DoInterpretedMotion/StopMotion: copies the "capability +
|
|
||||||
behavior" flags and all non-flag fields EXCEPT FailWalk, UseFinalHeading, Sticky, MoveAway,
|
|
||||||
Autonomous, DisableJumpDuringLink, DesiredHeading, ContextID, ActionStamp. (ACE `TODO: review` —
|
|
||||||
interop with the R3 MotionInterp port, not an R4 concern per se.)
|
|
||||||
- `MovementParameters(MoveToParameters)` — wire-side ctor (server outbound); flags from the packed
|
|
||||||
dword + the six floats. Matches `MovementParameters::UnPackNet` 0x0052ac50 field order.
|
|
||||||
|
|
||||||
### get_command(dist, heading, ref motion, ref holdKey, ref movingAway) (`:152-184`) — retail 0x0052aa00 — ⚠ flag A15 (CanCharge gate dropped)
|
|
||||||
|
|
||||||
ACE:
|
|
||||||
|
|
||||||
```
|
|
||||||
if (MoveTowards || !MoveAway):
|
|
||||||
if (MoveAway): towards_and_away(dist, heading, ref motion, ref movingAway)
|
|
||||||
else:
|
|
||||||
if (dist > DistanceToObject) { motion = WalkForward; movingAway = false } else motion = 0
|
|
||||||
else if (MoveAway):
|
|
||||||
if (dist < MinDistance) { motion = WalkForward; movingAway = true } else motion = 0
|
|
||||||
if (CanRun && (!CanWalk || dist - DistanceToObject > WalkRunThreshold)) holdKey = Run
|
|
||||||
else holdKey = None
|
|
||||||
```
|
|
||||||
|
|
||||||
Retail (0052aa00, byte-1 flag reads = bits 0x100/0x200; verified decode):
|
|
||||||
|
|
||||||
```
|
|
||||||
if (MoveTowards):
|
|
||||||
if (MoveAway) towards_and_away(...) // both set → bidirectional band
|
|
||||||
else chase-only (dist > distance_to_object → WalkForward, away=0; else 0)
|
|
||||||
else if (!MoveAway): chase-only // neither set → still chase-only (matches ACE's OR)
|
|
||||||
else: flee-only (dist < min_distance → WalkForward, away=1; else 0)
|
|
||||||
// HoldKey (0052aa90-0052aab4):
|
|
||||||
if (flags & 0x10 /*CanCharge*/) → HoldKey_Run // ⚠ unconditional run fast-path
|
|
||||||
else if (!(flags & 0x2 /*CanRun*/)) → HoldKey_None
|
|
||||||
else if ((flags & 0x1 /*CanWalk*/) && dist - distance_to_object <= walk_run_threshhold) → HoldKey_None
|
|
||||||
else → HoldKey_Run
|
|
||||||
```
|
|
||||||
|
|
||||||
Movement-command selection matches ACE exactly. The hold-key selection does NOT: **retail's
|
|
||||||
CanCharge (0x10) bit short-circuits straight to HoldKey_Run** before any CanRun/CanWalk/threshold
|
|
||||||
logic; ACE dropped it entirely. (This is the already-documented #77 finding —
|
|
||||||
`memory/feedback_autowalk_cancharge_bit.md`; acdream's B.6 honors the wire CanCharge bit.) Note the
|
|
||||||
two ACE deviations A13 (default CanCharge=true) + A15 (gate removed) roughly cancel within ACE's
|
|
||||||
own server, but a verbatim port must take retail's version of BOTH.
|
|
||||||
|
|
||||||
### towards_and_away(dist, heading, ref command, ref movingAway) (`:200-214`) — retail 0x0052a9a0 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
if (dist > DistanceToObject) { command = WalkForward; movingAway = false }
|
|
||||||
else if (dist - MinDistance < EPSILON) { command = WalkBackwards; movingAway = true } // retail 0x45000006 ✓
|
|
||||||
else command = 0
|
|
||||||
```
|
|
||||||
|
|
||||||
(Note: `heading` param is unused in both ACE and retail.)
|
|
||||||
|
|
||||||
### get_desired_heading(motion, movingAway) (`:186-198`) — retail 0x0052aad0 ✓
|
|
||||||
|
|
||||||
```
|
|
||||||
RunForward / WalkForward → movingAway ? 180 : 0
|
|
||||||
WalkBackwards → movingAway ? 0 : 180
|
|
||||||
default → 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Call sites into MoveToManager (ACE)
|
|
||||||
|
|
||||||
**MovementManager** (`Physics/Managers/MovementManager.cs`) — owns exactly one `MotionInterpreter`
|
|
||||||
+ one `MoveToManager`:
|
|
||||||
|
|
||||||
- `PerformMovement(mvs)` (`:124-157`): `PhysicsObj.set_active(true)` (ACE-ism, server activity
|
|
||||||
tracking), then dispatch — types 1-5 → MotionInterp.PerformMovement (lazy-create + enter_default_state),
|
|
||||||
types 6-9 → MoveToManager.PerformMovement (lazy-create), default → `WeenieError.GeneralMovementFailure`.
|
|
||||||
- `CancelMoveTo(err)` (`:27`), `HandleUpdateTarget(targetInfo)` (`:60`), `UseTime()` (`:176`,
|
|
||||||
forwards to MoveToManager only), `HitGround()` (`:66-73`, forwards to BOTH MotionInterp.HitGround
|
|
||||||
and MoveToManager.HitGround), `IsMovingTo()` (`:97`), `MakeMoveToManager()` (`:112`),
|
|
||||||
`SetWeenieObject` (`:167`).
|
|
||||||
|
|
||||||
**PhysicsObj** (`Physics/PhysicsObj.cs`) seams that drive the manager:
|
|
||||||
|
|
||||||
- `UseTime` ticks: `:1729` + `:1791` (`MovementManager.UseTime()` from the per-tick update paths,
|
|
||||||
followed by `PositionManager.UseTime()`).
|
|
||||||
- `set_on_walkable` (`:3791-3814`): OnWalkable rising edge → `MovementManager.HitGround()`, falling
|
|
||||||
edge → `LeaveGround()`. Additional LeaveGround sites `:1243`, `:2242`, `:2911` (clear_transient_states
|
|
||||||
and friends).
|
|
||||||
- `cancel_moveto()` (`:2143-2147`): → `MovementManager.CancelMoveTo(ActionCancelled)`. Called from
|
|
||||||
MotionInterp on every user-motion (`DoInterpretedMotion :123-124`, `StopMotion :371-372`,
|
|
||||||
`StopCompletely :305`, `jump :714`, `move_to_interpreted_state :794`) — i.e. any fresh motion with
|
|
||||||
the CancelMoveTo param bit set kills the active MoveTo.
|
|
||||||
- `HandleUpdateTarget(targetInfo)` (`:637-644`): forwards to MovementManager AND PositionManager.
|
|
||||||
- Teleport: `:4036-4037` → `CancelMoveTo(WeenieError.ITeleported)`.
|
|
||||||
- `StopCompletely(bool)` (`:1546-1551`): builds `MovementStruct(StopCompletely)` →
|
|
||||||
MovementManager.PerformMovement → MotionInterp.StopCompletely (which itself calls cancel_moveto —
|
|
||||||
recursion is broken because CancelMoveTo no-ops when MovementType == Invalid, and CleanUp runs
|
|
||||||
InitializeLocalVars BEFORE the StopCompletely call in CleanUpAndCallWeenie).
|
|
||||||
- `set_target(ctx, objID, radius, quantum)` (`:3947`), `clear_target()` (`:2231`),
|
|
||||||
`get_target_quantum()` (`:2638`, returns `TargetManager.TargetInfo.Quantum` or 0),
|
|
||||||
`set_target_quantum(q)` (`:3955`), `unstick_from_object()` (`:4134` → PositionManager.Unstick).
|
|
||||||
|
|
||||||
**TargetManager / TargetInfo** (`Physics/Managers/TargetManager.cs`, `Physics/Combat/TargetInfo.cs`):
|
|
||||||
`SetTarget` clears old target, registers this obj as a voyeur on the target
|
|
||||||
(`target.add_voyeur(ID, radius, quantum)`); target movement beyond `radius` from last-sent →
|
|
||||||
`SendVoyeurUpdate` → `voyeurObj.receive_target_update` → `TargetManager.ReceiveUpdate` →
|
|
||||||
`PhysicsObj.HandleUpdateTarget` → MoveToManager.HandleUpdateTarget. `TargetStatus`:
|
|
||||||
`Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6`.
|
|
||||||
`HandleTargetting` runs on a 0.5 s cadence with a 10 s Undefined→TimedOut timeout. This whole
|
|
||||||
subsystem is the "target re-tracking" acdream currently skips (server re-emits MoveTo ~1 Hz instead).
|
|
||||||
|
|
||||||
**MotionInterp seams used by MoveToManager:** `adjust_motion(ref motion, ref speed, holdKey)`
|
|
||||||
(`MotionInterp.cs:394-428` — WalkBackwards→WalkForward·−BackwardsFactor, TurnLeft→TurnRight·−1,
|
|
||||||
SideStepLeft→SideStepRight·−1, sidestep scale, holdKey Invalid→RawState.CurrentHoldKey, Run→
|
|
||||||
apply_run_to_command), `DoInterpretedMotion`, `StopInterpretedMotion`. These are R3-ported in
|
|
||||||
acdream's `MotionInterpreter` already.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ACE-ism / oddity flags for the R4 retail cross-check
|
|
||||||
|
|
||||||
Ranked. "RETAIL-VERIFIED" = I read the named decomp this session; anchors above.
|
|
||||||
|
|
||||||
- **A1 — stale-member UseFinalHeading read in MoveToPosition** (`MoveToManager.cs:214`).
|
|
||||||
ACE reads `MovementParams.UseFinalHeading` (the PREVIOUS move's stored params — the new ones
|
|
||||||
aren't assigned until `:222`); retail 0052a33b reads the ARGUMENT's flag (`arg3->__inner0 & 0x40`).
|
|
||||||
RETAIL-VERIFIED divergence — exactly the R3 NPE/transposition class. Port the argument read.
|
|
||||||
- **A2 — InitializeLocalVars zeroes the wrong field** (`:68`). ACE: `MovementParams.DistanceToObject = 0`.
|
|
||||||
Retail 0052925b: `movement_params.__inner0 = 0` (the FLAGS word) + `context_id = 0`.
|
|
||||||
RETAIL-VERIFIED transposition.
|
|
||||||
- **A3 — InitializeLocalVars omits distance resets**. Retail 0052926c/0052928a also set
|
|
||||||
`previous_distance = original_distance = FLT_MAX`; ACE leaves them stale (masked because
|
|
||||||
BeginMoveForward reseeds them). RETAIL-VERIFIED.
|
|
||||||
- **A4 — TurnToHeading missing BeginNextNode** (`:284-304`). Retail 0052a76d calls
|
|
||||||
`BeginNextNode()` immediately after queuing the node; ACE returns without it (turn starts one
|
|
||||||
UseTime tick late, and an empty-queue Sticky/complete path can never fire from TurnToHeading).
|
|
||||||
RETAIL-VERIFIED control-flow divergence.
|
|
||||||
- **A5 — CleanUpAndCallWeenie weenie callback** (`:728`). `WeenieObj.OnMoveComplete(status)` is an
|
|
||||||
ACE server addition (drives the server's MoveToChain completion). Retail 0x00529650 is
|
|
||||||
CleanUp + StopCompletely only. Client-side R4 equivalent: this is where acdream's
|
|
||||||
`AutoWalkArrived`-style completion notification belongs architecturally — but retail does NOT
|
|
||||||
notify anything here. RETAIL-VERIFIED.
|
|
||||||
- **A6 — HandleMoveToPosition aligned-branch `set_heading` snap** (`:444-446`). ACE-only, self-labeled
|
|
||||||
"custom: sync for server ticrate". Retail only stops the aux command when within the ±20° band —
|
|
||||||
the residual alignment is finished by the normal per-tick turn integration. acdream runs at
|
|
||||||
client tick rates → do NOT port the snap. RETAIL-VERIFIED absent.
|
|
||||||
- **A7 — `AlwaysTurn` field** (`:35`, used `:520`). ACE-only override of the IsAnimating gate in
|
|
||||||
BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent.
|
|
||||||
- **A8 — HandleMoveToPosition `inRange` block** (`:463-473`). ACE-only ("custom for low monster
|
|
||||||
update rate"): when `!UseSpheres`, arrival also fires if `dist < 1.0 && PreviousDistance < dist`
|
|
||||||
(distance started increasing inside 1 m), plus an extra PreviousDistance/Time refresh outside
|
|
||||||
CheckProgressMade. Retail has neither. RETAIL-VERIFIED absent — do not port.
|
|
||||||
- **A9 — (non-flag) arrival stop order** — ACE pops the node, stops CurrentCommand, zeroes it, stops
|
|
||||||
aux, BeginNextNode — same order as retail 00529f94-00529fef. Listed only so nobody "fixes" it.
|
|
||||||
- **A10 — BeginTurnToHeading missing empty-head cancel**. Retail 00529bad treats
|
|
||||||
`pending_actions.head == null` the same as physics-obj-null → `CancelMoveTo(8)`; ACE would throw
|
|
||||||
on `PendingActions[0]` instead (unreachable through current callers, but port the retail guard).
|
|
||||||
RETAIL-VERIFIED.
|
|
||||||
- **A11 — CleanUpAndCallWeenie NPE asymmetry** (`:721-729`). `PhysicsObj` is null-checked,
|
|
||||||
`WeenieObj` is not — the parameterless `MoveToManager()` ctor leaves WeenieObj null → NPE on any
|
|
||||||
cancel. Same null-check-typo class the R3 pass caught in ACE. Irrelevant once A5 is resolved
|
|
||||||
client-side, but don't transcribe.
|
|
||||||
- **A12 — UseTime gate negation** (`:775`). ACE: `TopLevelObjectID != 0 || MovementType != Invalid || Initialized`.
|
|
||||||
Retail 0052a7b4: `top_level_object_id == 0 || movement_type == Invalid || initialized != 0`.
|
|
||||||
The first two clauses are NEGATED in ACE (masked by the empty-queue early-out; still, port retail's).
|
|
||||||
RETAIL-VERIFIED boundary inversion.
|
|
||||||
- **A13 — default CanCharge flag**. ACE ctor sets CanCharge=true (flags 0x1EE1F); retail default is
|
|
||||||
0x1EE0F (CanCharge clear). RETAIL-VERIFIED.
|
|
||||||
- **A14 — Default_WalkRunThreshold = 1.0f** vs retail 15.0f (0x005243b5). ACE deliberately retuned
|
|
||||||
(15.0f left commented out at `MovementParameters.cs:49-50`). acdream already treats 15 m as the
|
|
||||||
retail/wire default (#77 work). RETAIL-VERIFIED.
|
|
||||||
- **A15 — get_command drops the CanCharge fast-path**. Retail 0052aa95: `flags & 0x10` →
|
|
||||||
unconditional `HoldKey_Run` BEFORE the CanRun/CanWalk/threshold cascade; ACE has only the cascade.
|
|
||||||
A13+A15 cancel inside ACE's ecosystem; a verbatim port needs retail's version of both.
|
|
||||||
RETAIL-VERIFIED (and consistent with `memory/feedback_autowalk_cancharge_bit.md`).
|
|
||||||
- **A16 — server-tick constants to re-check at 30 Hz.** ACE runs MoveToManager on its own cadence;
|
|
||||||
the constants (20°/340° aux band, 1.0 s / 0.25 m/s progress, 0.1 velocity floor, 1.0 quantum
|
|
||||||
hysteresis) are retail-identical (verified), so no rescaling is needed — but the *feel* of the
|
|
||||||
aux-turn band differs with tick rate; retail integrates at 30 Hz physics ticks.
|
|
||||||
|
|
||||||
### BN-artifact warnings for the R4 decomp pass (do NOT trust the literal pseudo-C)
|
|
||||||
|
|
||||||
- `HandleMoveToPosition` chase-arrival branch (00529f8f `test ah,0x41`) renders INVERTED — the flee
|
|
||||||
branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives at
|
|
||||||
`dist <= distance_to_object`. See §1 HandleMoveToPosition note.
|
|
||||||
- `BeginTurnToHeading` turn-direction branches (00529bf4/00529c23 `test ah,0x41`/`0x5`) — senses
|
|
||||||
garbled; ACE's ≤180→TurnRight reading is correct.
|
|
||||||
- `heading_diff` (0x00528fb0) — x87 return garble; the `motion != TurnRight → 360 - result` mirror
|
|
||||||
is invisible in the pseudo-C. Verify once via Ghidra MCP before porting.
|
|
||||||
- `get_command`'s first flag read is BN-rendered as a bogus `(int16_t)` pointer deref of the flags
|
|
||||||
word — it's just `flags & 0x200` / `& 0x100` on byte 1.
|
|
||||||
|
|
||||||
### Negative results (look like ACE-isms, are retail-faithful)
|
|
||||||
|
|
||||||
- **FailProgressCount is vestigial in RETAIL too** — grep shows only writes (reset/increment) at
|
|
||||||
005292b1 / 0052a014 / 00529f3e / 0052a13a / 0052a1e5 / 0052a224; no threshold read anywhere.
|
|
||||||
Do not invent a cancel-after-N-failures mechanism.
|
|
||||||
- **Double adjust_motion** (_DoMotion + DoInterpretedMotion-internal) — retail does the same.
|
|
||||||
- **TurnToObject desired-heading clobber** (write CurrentTargetPosition → Internal overwrites and
|
|
||||||
reads SoughtPosition) — retail quirk, verified against the PDB struct layout; port verbatim.
|
|
||||||
- **BeginTurnToHeading storing a DIFF into PreviousHeading** — retail 00529c82 does the same.
|
|
||||||
- **PerformMovement returning success unconditionally** — retail returns 0 too.
|
|
||||||
- **Error codes all line up** (no ordinal traps): NoPhysicsObject=0x8, NoMotionInterpreter=0xB,
|
|
||||||
ActionCancelled=0x36, ObjectGone=0x37, NoObject=0x38, YouChargedTooFar=0x3D — verified against
|
|
||||||
both retail immediates and ACE's WeenieError enum.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. acdream R4 blast radius — what exists today
|
|
||||||
|
|
||||||
All paths worktree-relative (`.claude/worktrees/vigorous-joliot-f0c3ad`).
|
|
||||||
|
|
||||||
### `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (341 lines, static)
|
|
||||||
|
|
||||||
Per-tick steering for **server-controlled remotes** during mt 6/7. Approximates
|
|
||||||
`HandleMoveToPosition`'s aux-turn + arrival only:
|
|
||||||
|
|
||||||
- `Drive(bodyPos, bodyOrient, destWorld, minDistance, distanceToObject, dt, moveTowards, out newOrient)`
|
|
||||||
(`:170-254`): horizontal distance; arrival `moveTowards ? dist <= distanceToObject + ε : dist >= minDistance − ε`
|
|
||||||
(`ArrivalEpsilon = 0.05` — acdream fudge, not retail); heading via quaternion yaw; **±20° snap-to-heading**
|
|
||||||
(`HeadingSnapToleranceRad`, borrowed from ACE's A6 custom snap — retail has no snap in the ≤20° band);
|
|
||||||
otherwise rotate at `TurnRateRadPerSec = π/2`.
|
|
||||||
- Constants: `RunTurnFactor = 1.5` (retail run_turn_factor @ 0x007c8914), `BaseTurnRateRadPerSec = π/2`,
|
|
||||||
`TurnRateFor(running)`, `StaleDestinationSeconds = 1.5` (acdream-only staleness guard — replaces
|
|
||||||
retail's TargetManager re-tracking), `ClampApproachVelocity` (`:296-329`, acdream-only overshoot clamp),
|
|
||||||
`OriginToWorld` (`:263-277`, landblock-local → render world).
|
|
||||||
- ⚠ Its class-level doc (`:53-57`) still claims "ACE swaps the chase/flee arrival predicates vs
|
|
||||||
retail" — WRONG, contradicted by its own later comment (`:186-199`) and by this document. Fix the
|
|
||||||
doc comment during R4.
|
|
||||||
- What it deliberately skips (doc `:44-51`): target re-tracking, Sticky/StickTo, fail-distance +
|
|
||||||
progress detector, cylinder-sphere distance — the R4 port's main new surface.
|
|
||||||
|
|
||||||
### `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (88 lines, static)
|
|
||||||
|
|
||||||
Chooses the visible cycle for remotes on mt 6/7 (which carry no ForwardCommand):
|
|
||||||
`PlanMoveToStart(moveToSpeed, runRate, canRun)` ≈ `BeginMoveForward → get_command → adjust_motion`
|
|
||||||
collapsed into "RunForward @ speed·runRate if canRun else WalkForward" — no distance/threshold logic
|
|
||||||
at all (no DistanceToObject, no WalkRunThreshold, no CanCharge). `PlanFromVelocity` (fallback from
|
|
||||||
observed velocity; `StopSpeed=0.20`, `RunThreshold=1.25` — acdream heuristics, not retail).
|
|
||||||
R4 replaces this seeding with real `MovementParameters.get_command`.
|
|
||||||
|
|
||||||
### `src/AcDream.App/Input/PlayerMovementController.cs` — B.6 auto-walk block (KEEP-LIST until R4)
|
|
||||||
|
|
||||||
- Fields `:264-296`: `_autoWalkActive/_autoWalkDestination/_autoWalkMinDistance/
|
|
||||||
_autoWalkDistanceToObject/_autoWalkMoveTowards/_autoWalkInitiallyRunning`; turn-direction tracker
|
|
||||||
`_autoWalkTurnDirectionThisFrame` (`:323`); `IsServerAutoWalking` (`:308`); `AutoWalkArrived` event
|
|
||||||
(`:340`, host re-sends the Use/PickUp action on natural arrival — ACE MoveToChain timeout workaround).
|
|
||||||
- `BeginServerAutoWalk(destWorld, minDistance, distanceToObject, moveTowards, canCharge)` (`:452-487`):
|
|
||||||
installs state; walk-vs-run is a ONE-SHOT decision from the wire CanCharge bit (#77 fix — matches
|
|
||||||
retail A15 semantics relayed by ACE's server-side `Creature.SetWalkRunThreshold`).
|
|
||||||
- `EndServerAutoWalk(reason)` (`:495-503`): idempotent; fires `AutoWalkArrived` on `reason=="arrived"`.
|
|
||||||
- `DriveServerAutoWalk(dt, input)` (`:567-790`) — the local-player approximation of
|
|
||||||
HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel;
|
|
||||||
horizontal distance; arrival `moveTowards ? dist <= distanceToObject : dist >= minDistance + ε`
|
|
||||||
**gated on 5° alignment** (acdream-only: retail fires arrival purely on distance; the alignment
|
|
||||||
gate exists because ACE rotates server-side before the Use callback); continuous turn at
|
|
||||||
`TurnRateFor(_autoWalkInitiallyRunning)` with no snap (deliberately retail-er than
|
|
||||||
RemoteMoveToDriver); 30° walk-while-turning band (acdream-only — retail's equivalent is the
|
|
||||||
discrete TurnToHeading node THEN MoveToPosition node, plus the 20° aux band while moving);
|
|
||||||
turn-in-place phase issues `DoMotion(Ready)` + zeroes horizontal velocity; forward phase issues
|
|
||||||
`DoMotion(WalkForward, runRate|1.0)` + `set_local_velocity` from `get_state_velocity`.
|
|
||||||
R4 replaces this whole function with real pending-node MoveToManager driving.
|
|
||||||
- Call site: `Update` `:896` (`autoWalkConsumedMotion` skips the user-input motion/velocity blocks);
|
|
||||||
`:1028` and `:1528` carry R4 KEEP-LIST notes (auto-walk owns motion until R4 cutover).
|
|
||||||
|
|
||||||
### `src/AcDream.App/Rendering/GameWindow.cs` — MoveTo packet handling
|
|
||||||
|
|
||||||
- Inbound UM seeding `:4401-4435`: when `update.MotionState.IsServerControlledMoveTo` and
|
|
||||||
ForwardCommand absent → `ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate,
|
|
||||||
MoveToCanRun)` provides `fullMotion/speedMod` for the animation funnel.
|
|
||||||
- Local player `:4507-4547`: mt 6/7 with `MoveToPath` payload → `OriginToWorld` →
|
|
||||||
`_playerController.BeginServerAutoWalk(dest, MinDistance, DistanceToObject, MoveTowards, CanCharge)`.
|
|
||||||
Deliberately does NOT cancel on the companion mt 0x00 InterpretedMotionState echo (`:4534-4546`,
|
|
||||||
trace-verified ACE behavior).
|
|
||||||
- Remotes `:4570-4610`: `remoteMot.ServerMoveToActive = IsServerControlledMoveTo`; path payload →
|
|
||||||
`MoveToDestinationWorld/MinDistance/DistanceToObject/MoveTowards/HasMoveToDestination/
|
|
||||||
LastMoveToPacketTime`; non-MoveTo UM clears `HasMoveToDestination`.
|
|
||||||
- Remote per-tick `:9594-9682`: staleness guard (1.5 s) → else `RemoteMoveToDriver.Drive` →
|
|
||||||
orientation write; Arrived → zero velocity; Steering → `rm.Motion.apply_current_movement` +
|
|
||||||
`ClampApproachVelocity`. MoveTo-active-but-no-path → hold velocity zero (882a07c stance).
|
|
||||||
- Speculative install `:12122-12164`: `InstallSpeculativeTurnToTarget` — local prediction of ACE's
|
|
||||||
MoveToObject before the wire packet arrives; predicts CanCharge via the 7.5 m rule
|
|
||||||
(`AceCanChargeDistance = WalkRunThreshold/2`); per-type use radius (0.6 default / 3.0 creature /
|
|
||||||
2.0 door-lifestone-portal-corpse).
|
|
||||||
- `:4942`: second `PlanMoveToStart` consumer (same seeding on another path — CreateObject-time).
|
|
||||||
|
|
||||||
### Wire layer
|
|
||||||
|
|
||||||
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`: parses mt 6/7 union → `CreateObject.MoveToPathData`
|
|
||||||
(`:157`, `:287-344`); exposes `IsServerControlledMoveTo` (mt is 6 or 7), `MoveToCanRun`
|
|
||||||
(params bit 0x2), `MoveTowards` (0x200), `CanCharge` (0x10), `MoveToSpeed`, `MoveToRunRate`.
|
|
||||||
- `src/AcDream.Core.Net/Messages/CreateObject.cs:292-302`: `MoveToPathData(TargetGuid, OriginCellId,
|
|
||||||
OriginX/Y/Z, DistanceToObject, MinDistance, FailDistance, WalkRunThreshold, DesiredHeading)` —
|
|
||||||
the full retail `MovementParameters` float block is already captured on the wire; FailDistance and
|
|
||||||
WalkRunThreshold and DesiredHeading are parsed but currently UNUSED by any driver (R4 consumers).
|
|
||||||
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`: `ProbeAutoWalkEnabled` (`ACDREAM_PROBE_AUTOWALK`)
|
|
||||||
gates `[autowalk-mt]/[autowalk-begin]/[autowalk-end]` traces.
|
|
||||||
|
|
||||||
### What R4 must reconcile
|
|
||||||
|
|
||||||
Today there are **three independent approximations** of one retail mechanism: DriveServerAutoWalk
|
|
||||||
(local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart
|
|
||||||
(animation seed). Retail runs ONE MoveToManager per CPhysicsObj (player and remotes identical),
|
|
||||||
driven by pending nodes + UseTime, issuing motions through CMotionInterp (already R3-ported as
|
|
||||||
`MotionInterpreter`). The wire already delivers every field the retail manager needs
|
|
||||||
(MoveToPathData + params bits). Missing retail apparatus: pending-node queue, TurnToHeading/
|
|
||||||
MoveToPosition node alternation, get_command/towards_and_away, the aux-turn band, CheckProgressMade +
|
|
||||||
fail-distance cancel, HitGround → BeginNextNode, Sticky/StickTo (position manager), and the
|
|
||||||
TargetManager re-tracking loop (acdream substitutes ACE's ~1 Hz MoveTo re-emit + the 1.5 s staleness
|
|
||||||
guard — decide in R4 whether that substitution stays as a documented divergence-register row).
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,452 +0,0 @@
|
||||||
# R4 port work-list — MoveToManager verbatim + the three-approximation cutover
|
|
||||||
|
|
||||||
Inputs: `r4-moveto-decomp.md` (verbatim retail extraction, same dir),
|
|
||||||
`r4-ace-moveto.md` (ACE cross-ref + blast radius, same dir), plan of record
|
|
||||||
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R4), R3 pins
|
|
||||||
`docs/research/2026-07-02-r3-motioninterp/W0-pins.md` (A4 MovementParameters masks —
|
|
||||||
used as-is), current code `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340
|
|
||||||
lines, static), `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines),
|
|
||||||
`src/AcDream.App/Input/PlayerMovementController.cs` (1604 lines, post-R3-W6; B.6
|
|
||||||
auto-walk block :275-:766 + :896), `src/AcDream.App/Rendering/GameWindow.cs`
|
|
||||||
(local MoveTo routing :4401-4547, remote seeding :4549-4620, remote per-tick
|
|
||||||
:9594-9682, speculative install :11915/:12004/:12122),
|
|
||||||
`src/AcDream.Core.Net/Messages/UpdateMotion.cs` (mt 6/7 parse :252-356),
|
|
||||||
`src/AcDream.Core/Physics/Motion/MovementParameters.cs` (R3-W1, flags+scalars only),
|
|
||||||
`src/AcDream.Core/Physics/MotionInterpreter.cs` (R3-complete: DoInterpretedMotion
|
|
||||||
:2861, StopInterpretedMotion :3016, adjust_motion :1241, MotionsPending :2118,
|
|
||||||
InterruptCurrentMovement seam :645, UnstickFromObject seam :633, MyRunRate :585).
|
|
||||||
|
|
||||||
**Precondition / state at R4 start:** R1/R2/R3 SHIPPED (R3 through W6 `fb7beb70`;
|
|
||||||
R2+R3 share one pending visual pass — R4 code work does not block on it). The
|
|
||||||
R3 seams R4 consumes all exist: `DoInterpretedMotion(uint, MovementParameters)` /
|
|
||||||
`StopInterpretedMotion(uint, MovementParameters)` / `adjust_motion(ref,ref,HoldKey)`
|
|
||||||
(the entire retail `_DoMotion` shape), `MotionsPending()`, `TransientStateFlags.Contact`
|
|
||||||
on `PhysicsObj`, `HitGround`/`LeaveGround` (W4), the `InterruptCurrentMovement`
|
|
||||||
no-op `Action?` seam (register TS-36 — "R4 is where cancel_moveto lands"), and
|
|
||||||
`MovementParameters` with retail ctor defaults (0x1EE0F, threshhold 15, CanCharge
|
|
||||||
false). What does NOT exist: any MoveToManager, the command-selection family
|
|
||||||
(`get_command`/`towards_and_away`/`get_desired_heading`), MovementType values
|
|
||||||
0/6/7/8/9, WeenieError 0xB/0x36/0x37/0x38/0x3D, mt 8/9 wire parsing, and any
|
|
||||||
heading/cylinder-distance helper in Core. MovementManager itself is **R5** —
|
|
||||||
R4 binds MoveToManager to the interp directly and keeps type-dispatch at the
|
|
||||||
existing call sites (same "null-guarded relay inserted later without behavior
|
|
||||||
change" pattern R3 §4 used for MotionDone).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. DECOMP AMBIGUITIES TO PIN before porting (the V0 pins commit)
|
|
||||||
|
|
||||||
| # | Ambiguity | Evidence each way | Pin method |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P1 | **ACE's companion mt-0 echo vs retail's unpack-head cancel — the V5 landmine.** Retail `MovementManager::unpack_movement` (0x00524440 @300566-300598) calls `CPhysicsObj::interrupt_current_movement` (→ CancelMoveTo 0x36) at the HEAD of EVERY movement event, including case 0. But ACE follows every mt=0x06 MoveToObject with an mt=0x00 InterpretedMotionState echo (cmd=RunForward, fwdSpd≈2.86) one packet later — trace-verified 2026-05-14 (`launch-slice2.log`; the finding is preserved verbatim in GameWindow.cs:4534-4546: "Cancelling on the InterpretedMotionState killed the auto-walk on frame 1"). A verbatim head-interrupt port re-breaks auto-walk on frame 1 against ACE. Retail servers evidently never sent an interpreted UM mid-moveto to the mover (any interpreted UM IS the cancel+replace signal). | decomp §2f head vs GameWindow:4534-4546 trace note; ACE `Player.OnMoveComplete`/MoveToChain broadcast code | (a) Re-capture the echo with `ACDREAM_PROBE_AUTOWALK=1` + `ACDREAM_DUMP_MOTION=1` and record the echo's movement/serverControl sequence stamps + isAutonomous flag vs the mt-6 packet's; (b) read ACE's broadcast path (does the echo bump ServerControlSequence? is it flagged autonomous?) for a principled discriminator; (c) if no wire discriminator exists, the adaptation is: **suppress the head-interrupt for a local-player mt-0 UM whose interpreted ForwardCommand equals the moveto's `current_command` (post-adjust) while `is_moving_to()`** — an ACE-compat register row (AD class, sibling of AD-32), NOT a silent divergence. Whatever the pin, mt 6/7/8/9 arrivals keep the retail unconditional cancel (MoveToManager::PerformMovement re-cancels anyway, §3a). |
|
|
||||||
| P2 | **`get_desired_heading` (0x0052aad0) 180/0 constants.** BN body is an x87 setcc garble (decomp §5e); ACE gives `WalkForward\|RunForward → movingAway ? 180 : 0`, `WalkBackwards → movingAway ? 0 : 180`, default 0. The command GROUPING is visible in the BN text; the constants are not. | decomp §5e vs ACE MovementParameters.cs:186-198 | One Ghidra MCP decompile of `0x0052aad0` (`/decompile_function?address=0x0052aad0`) when a CodeBrowser is up (was down during extraction — decomp §10). Confidence already high; port ACE's shape if Ghidra stays down, with the pin noted UNVERIFIED-BY-GHIDRA in the pseudocode doc. |
|
|
||||||
| P3 | **`heading_diff` (0x00528fb0) third-arg mirror — the two research docs CONTRADICT.** r4-moveto-decomp §5g: "arg3 (the turn command) is UNUSED in the body — keep the parameter for signature parity". r4-ace-moveto §1: ACE has `if (result > EPSILON && motion != TurnRight) result = 360 - result` and says the mirror is "invisible in the pseudo-C — verify once in Ghidra". Behavior-bearing: `HandleTurnToHeading` @0052a1bc passes the LIVE `current_command` (can be TurnLeft) into heading_diff for its progress test — with the mirror, a TurnLeft turn measures progress as 360−raw; without it, the progress window `(ε, 180)` reads the wrong side and TurnLeft turns would increment fail_progress_count every tick (harmless in retail — the counter is dead — but the `previous_heading` update path differs). | decomp §5g vs ACE MoveToManager.cs:817-828 | Ghidra decompile of `0x00528fb0` — decisive (the fn is 40 lines). If Ghidra stays down: cdb bp on `acclient!heading_diff` logging arg3+ST0 during a TurnLeft-direction TurnToHeading. Do not port either reading unpinned. |
|
|
||||||
| P4 | **TargetManager scope — what feeds `HandleUpdateTarget` for type 6/8.** Retail: `set_target` registers the mover as a voyeur on the target (radius 0.5, quantum 0); the target's movement fans back via `receive_target_update` → `HandleUpdateTarget` (decomp §9f; ACE `TargetManager.cs` with a 0.5 s `HandleTargetting` cadence + 10 s timeout). The r4 extraction covers only the CALL SHAPES — TargetManager bodies were NOT extracted. Without SOME feed, `initialized` never flips and type-6/8 moves never start (UseTime gate §6a). | decomp §9f (shapes only) vs ACE TargetManager.cs (full impl, server-side) | DECISION (made here, confirm in V0): R4 ships a **minimal App-side `TargetTracker` adapter**, not a TargetManager port: on `set_target(0, tlid, 0.5, 0)` GameWindow's entity table delivers one immediate `TargetInfo{ObjectId, Ok, target_position=entity pos, interpolated_position=same}`, then re-delivers per tick when the target has moved > 0.5 units since last delivery (the voyeur radius from the call site); `clear_target` unsubscribes; despawn/teleport of the target delivers `status=ExitWorld/Teleported` (→ CancelMoveTo 0x37/0x38 falls out of the verbatim manager). `set_target_quantum` is accepted and recorded but does NOT throttle the tracker (retail uses it to slow updates; over-delivery is convergence-safe). Register row (AP class) replacing AP-8's "no target re-tracking" clause; the full TargetManager port is R5 scope alongside PositionManager. If V0 finds this too loose, the fallback pin is extracting `TargetManager::SetTarget/ReceiveUpdate/HandleTargetting` from the raw (grep `TargetManager::` — est. ~200 lines) and porting verbatim in V4 instead. |
|
|
||||||
| P5 | **Position::heading convention + coordinate space.** MoveToManager consumes `Position::heading(from, to)` (degrees), `Frame::get_heading/set_heading`, `Position::distance`, `Position::cylinder_distance`. acdream has NO Core heading helper (only `SceneryHelpers.SetHeading`); RemoteMoveToDriver derives yaw from quaternions ad hoc. Wire `DesiredHeading` is degrees in AC convention. The manager will run in acdream's streaming-world space (Vector3 + cell id) via `OriginToWorld`, not retail's block-local Position — distances are equivalent (Euclidean after rebase); headings must match the wire convention exactly or `use_final_heading` faces the wrong way. | acclient Position::heading (not in this extraction); `RemoteMoveToDriver.OriginToWorld` :263-277; L.2b outbound heading packers (already convert yaw→AC heading) | Grep the named raw for `Position::heading` + `Frame::get_heading` and pin the formula (expected: heading = fmod(450 − atan2-degrees, 360) or the L.2b-established equivalent — the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion, don't derive a second one). Golden test: heading(a,b) for the four cardinal offsets == {0, 90, 180, 270} per the wire convention ACE round-trips. |
|
|
||||||
| P6 | **TurnToObject (mt 8) wire layout.** Decomp §2f case 8 reads `object_id` dword, then a `wire_heading` dword, THEN UnPackNet's 3 dwords (bitfield, speed, desired_heading); on unresolvable object it substitutes `desired_heading = wire_heading` and degrades to TurnToHeading. Two heading fields exist on the wire; ACE's server-side writer is the cheap cross-check for which is which. | decomp §2f @300653-300676; ACE MovementEvents TurnToObject writer | Read ACE's `MoveToObject/TurnToObject` GameMessage writers (Network/GameMessages) and byte-diff against the decomp read order; build the V3 parse fixtures from ACE's writer directly (golden bytes). |
|
|
||||||
| P7 | **Remote contact for the UseTime gate.** `MoveToManager::UseTime` gates on `transient_state & 1` (CONTACT). Remotes' `PhysicsObj.TransientState` is maintained well enough that R3's funnel `contact_allows_move` works for remote Falling dispatch — but confirm the Contact bit specifically is live for grounded NPCs (not just OnWalkable), else remote movetos stall forever. | MotionInterpreter.cs:1979/2089 (funnel reads Contact+OnWalkable for remotes today) | Code read of the remote PhysicsBody state writers + one `ACDREAM_DUMP_MOTION` smoke: a chasing NPC must pass the gate every grounded tick. If remotes lack the bit, fixing the STATE WRITER is in scope (root cause), not softening the gate. |
|
|
||||||
|
|
||||||
**V0 also commits the research docs into the repo**
|
|
||||||
(`docs/research/2026-07-03-r4-movetomanager/`: the decomp extraction, the ACE
|
|
||||||
cross-ref, this plan, and a `V0-pins.md` in the W0-pins format), and fixes the
|
|
||||||
two known stale comments so nobody ports against them: RemoteMoveToDriver.cs:53-57
|
|
||||||
("ACE swaps the chase/flee arrival predicates" — WRONG, refuted in r4-ace-moveto §1;
|
|
||||||
the file dies in V4 but V0-readers must not absorb the claim) — a doc-only touch.
|
|
||||||
|
|
||||||
**V0 cdb capture (optional, non-blocking):** all seven pins are textually
|
|
||||||
resolvable (P1 needs an ACE trace, not retail cdb). If a retail session happens
|
|
||||||
anyway: bp `MoveToManager::PerformMovement` / `BeginMoveForward` /
|
|
||||||
`BeginTurnToHeading` / `HandleMoveToPosition` / `HandleUpdateTarget` /
|
|
||||||
`MovementParameters::get_command` (args+ret) while the user runs: use a door at
|
|
||||||
range (MoveToObject + arrival), use while facing away (TurnToHeading node first),
|
|
||||||
let a monster chase (aux-turn band), outrun it (fail-distance NOT firing — dead
|
|
||||||
counter), /follow-style sticky if reachable. Feeds V2/V4 goldens; synthetic +
|
|
||||||
ACE-writer fixtures suffice without it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ITEMIZED GAPS — current vs retail (R4 scope)
|
|
||||||
|
|
||||||
Severity: **BLOCKER** = stage deliverable impossible without it; **HIGH** =
|
|
||||||
visible behavior wrongness / blocks R5-R6; **MED** = edge-visible; **LOW** = textual.
|
|
||||||
|
|
||||||
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| M1 | **No MoveToManager exists** — no state block (movement_type, sought/current_target/starting positions, params copy, progress clocks, current/aux command, moving_away, initialized), no `pending_actions` node queue, none of the 33 members. THREE independent approximations stand in for the one retail mechanism: `DriveServerAutoWalk` (local player), `RemoteMoveToDriver.Drive` (remotes), `ServerControlledLocomotion.PlanMoveToStart` (animation seed). | struct acclient.h:31473; whole extraction §§1-7 | RemoteMoveToDriver.cs; PlayerMovementController.cs:567-766; ServerControlledLocomotion.cs:28-53; GameWindow.cs:4412/4942 | **BLOCKER** |
|
|
||||||
| M2 | **Command-selection family absent**: `get_command` (0x0052aa00 — move/flee band pick + THE walk-vs-run rule: CanCharge 0x10 fast-path → Run, else CanRun && (!CanWalk \|\| dist−dto > threshhold 15) → Run else walk), `towards_and_away` (0x0052a9a0 — WalkBackwards inside the min band), `get_desired_heading` (0x0052aad0, P2). B.6's one-shot wire-CanCharge decision (#77) approximates the fast-path only — no per-tick re-evaluation, so a running auto-walk never demotes to walk-pace near the target (the R3 visual-pass expected-diff "auto-walk-at-run walk-pace legs (R4)"). `PlanMoveToStart` has NO distance logic at all. | decomp §5c/§5d/§5e | PlayerMovementController.cs:452-487 (`_autoWalkInitiallyRunning` one-shot), :701-706; ServerControlledLocomotion.cs:35-53 | **BLOCKER** |
|
|
||||||
| M3 | **No node plan** (`[TurnToHeading(face)] → [MoveToPosition] → [TurnToHeading(final)]?` with `BeginNextNode` stepping): auto-walk's turn-first phase + 30° walk-while-turning band are invented substitutes (AD-26's 5°/30°); the `use_final_heading` (0x40) node is entirely missing — wire `DesiredHeading` is parsed and thrown away. | §3c/§4a/§4b/§6f | PlayerMovementController.cs:606-706; CreateObject.MoveToPathData.DesiredHeading unused (r4-ace §5 wire layer) | **BLOCKER** |
|
|
||||||
| M4 | **No aux-command steering**: retail corrects heading DURING the move by issuing TurnRight/TurnLeft as interpreted motions through `_DoMotion` when \|diff\| leaves the ±20° deadband, and STOPS them re-entering it — observers see actual turn cycles. acdream rotates remotes by quaternion math (`RemoteMoveToDriver` π/2 rad/s + the borrowed ACE snap) and the local player via yaw writes — no turn motions dispatched, wrong legs. | HandleMoveToPosition Phase 1 @307225-307280 (20/340 deadband) | RemoteMoveToDriver.cs:68 (snap tolerance), :236 (ACE set_heading fudge); PlayerMovementController.cs:658-672 | **HIGH** |
|
|
||||||
| M5 | **Arrival predicate approximated + wrong distance metric**: retail `GetCurrentDistance` uses `cylinder_distance(own r/h, target r/h)` when `use_spheres` (0x400 — set in every ACE MoveTo packet) — edge-to-edge; acdream measures center-distance and compensates with AD-8's `max(minDistance, distanceToObject)` hack + an invented `ArrivalEpsilon 0.05` + the AD-26 facing gate. Retail: `moving_away ? dist ≥ min_distance : dist ≤ distance_to_object`, distance-only. | GetCurrentDistance §5a; arrival @307306-307331 (adjudicated r4-ace §1) | RemoteMoveToDriver.cs:161 (AD-8), :170-254; PlayerMovementController.cs:606-620 (AD-26 gate at :618) | **HIGH** |
|
|
||||||
| M6 | **No progress detector / fail distance**: `CheckProgressMade` (1 s window, 0.25 units/s incremental AND overall), `fail_distance` → CancelMoveTo(0x3D YouChargedTooFar), the FLT_MAX progress-clock reseeds on retarget. acdream substitutes the AD-9 1.5 s staleness timer (a liveness guard retail doesn't have) and parses FailDistance into `MoveToPathData` without a consumer. | CheckProgressMade §5b; @307300-307331; HandleUpdateTarget retarget @307843-307861 | RemoteMoveToDriver.cs:136 (AD-9); CreateObject.cs MoveToPathData.FailDistance unused | **HIGH** |
|
|
||||||
| M7 | **Types 8/9 (TurnToObject/TurnToHeading) dropped end-to-end** — the wire parser handles mt 6/7 only (mt 8/9 fall through to an empty Parsed: heading + params silently discarded); no consumer exists. These are the plan-of-record's "dropped D9/DEV-5 commands". | unpack §2f cases 8/9; UnPackNet 0xc-byte form §2g | UpdateMotion.cs:252 (`movementType is 6 or 7` — no 8/9 branch) | **HIGH** |
|
|
||||||
| M8 | **Target re-tracking absent** (type 6/8 initialization + retarget): no set_target/HandleUpdateTarget chain; the wire target guid (mt 6) is parsed into `MoveToPathData.TargetGuid` and never used — acdream chases the packet-time ORIGIN and relies on ACE's ~1 Hz MoveTo re-emit for freshness. Also no quantum retune (Phase 3, dist/speed ETA). | HandleUpdateTarget §6d; MoveToObject §3b; Phase 3 @307378-307436; §9f seams | RemoteMoveToDriver.cs:44-51 (doc'd skip = AP-8); UpdateMotion.cs:313 (guid parsed, unused) | **HIGH** (P4 pins the R4 answer) |
|
|
||||||
| M9 | **TS-36 dangling**: `InterruptCurrentMovement` is a no-op Action — `jump()` (:921/:1833 sites), `StopCompletely` (:1062), and DoMotion/StopMotion's cancel_moveto bit interrupt NOTHING. Once a real moveto exists, a jump or user input mid-moveto would leave the moveto running alongside — the exact "silent double-motion" the TS-36 row predicts. | interrupt_current_movement §9e → MovementManager::CancelMoveTo §2c | MotionInterpreter.cs:645/:921/:998/:1062/:1833; register TS-36 | **BLOCKER** for V5 |
|
|
||||||
| M10 | **PerformMovement discipline absent**: every new moveto must CancelMoveTo(0x36) + `unstick_from_object` FIRST (§3a); MoveToObject/MoveToPosition StopCompletely unconditionally, TurnTo\* only on the stop_completely bit (0x10000); self-target → CleanUp+Stop degenerate. acdream's `BeginServerAutoWalk` just overwrites fields. | PerformMovement §3a; §3b-§3e | PlayerMovementController.cs:452-487 | **HIGH** |
|
|
||||||
| M11 | **MovementType/MovementStruct too narrow**: enum lacks `Invalid=0, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`; MovementStruct lacks `ObjectId/TopLevelId/Pos/Radius/Height/Params`. | MovementTypes acclient.h:2856; MovementStruct acclient.h:38069 | MotionInterpreter.cs:101-113 (1-5 only), :408-422 | MED (mechanical) |
|
|
||||||
| M12 | **WeenieError lacks the moveto codes**: 0x0B NoMotionInterpreter, 0x36 ActionCancelled, 0x37 ObjectGone, 0x38 NoObject, 0x3D YouChargedTooFar (0x47 exists). Local-only, safe add. | codes §7c/§7a; A10 table | MotionInterpreter.cs:136-205 | LOW |
|
|
||||||
| M13 | **`my_run_rate` not fed from the MoveTo wire**: retail unpack cases 6/7 write `minterp->my_run_rate = read_float()`; acdream parses `MoveToRunRate` but only feeds the PlanMoveToStart seed — the interp's `MyRunRate` (:585) never sees it, so `apply_run_to_command`'s speed scale during a moveto uses stale rate. | unpack @300603/@300660 | UpdateMotion.cs:342; GameWindow.cs:4415 | MED |
|
|
||||||
| M14 | **Sticky discipline absent**: MoveToPosition/TurnToHeading must CLEAR sticky (0x80) on the stored params (`&= 0xffffff7f`); BeginNextNode's empty-queue sticky branch hands off to `PositionManager::StickTo(tlid, radius, height)` — reads the three fields BEFORE CleanUp zeroes them. R4 ships the seam (Action, no-op → R5 StickyManager) + the verbatim ordering. Wire side: the 0xF74C header sticky bit (motionFlags & 0x1 → trailing sticky guid dword) is parsed by nobody (`_motionFlags` dead at UpdateMotion.cs:135). | §3c @0052a3e5; §3e @0052a70c; BeginNextNode §4b; unpack case 0 @0052455d | nothing; UpdateMotion.cs:135 | MED (seam now, body R5) |
|
|
||||||
| M15 | **Heading/geometry helpers absent in Core**: `Position::heading`, `heading_diff` (P3), `heading_greater`, `Frame::get/set_heading`, `cylinder_distance`. Local yaw↔AC-heading conversion exists only in the outbound packers and ad-hoc quaternion math. | §5f/§5g/§5a; P5 | SceneryHelpers.cs:80 (render-side only); RemoteMoveToDriver quaternion yaw | **BLOCKER** (mechanical) |
|
|
||||||
| M16 | **`PlanFromVelocity` invented heuristics carry NO register row** (StopSpeed 0.20, RunThreshold 1.25 — acdream constants for the UP-dead-reckoning cycle pick, GameWindow.cs:4942/:5350 "until S6"). Found during this audit: a divergence without a row is a register-rule violation regardless of R4. Its CONSUMER (UP-DR cycle selection) is R6 scope, so the code survives R4 — the row must land now. | none (that's the problem) | ServerControlledLocomotion.cs:54-87; GameWindow.cs:4937-4943, :5350 | MED (bookkeeping, fix in V4) |
|
|
||||||
| M17 | **Speculative use-turn bypasses the (future) manager**: `InstallSpeculativeTurnToTarget` (GameWindow.cs:12122) locally predicts ACE's MoveTo before the wire packet via the auto-walk machinery + the 7.5 m CanCharge guess + AP-23 radius buckets. Retail's client-side use flow issues local `CPhysicsObj::TurnToObject/MoveToObject` through the SAME manager (§9a/§9b callers). | §9a/§9b | GameWindow.cs:11915/:12004/:12122-12164 | MED (rewire in V5) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. KEEP LIST — already matching retail (do not re-port)
|
|
||||||
|
|
||||||
| Behavior | Retail anchor | acdream anchor |
|
|
||||||
|---|---|---|
|
|
||||||
| `MovementParameters` flags+scalars class: A4 masks, ctor defaults 0x1EE0F / dto 0.6 / threshhold 15 / FLT_MAX / speed 1 / HoldKey.Invalid, CanCharge-false + threshold-15 ACE-trap notes | ctor 0x00524380; W0-pins A4 | `Motion/MovementParameters.cs` — V1 EXTENDS with get_command family; no field changes |
|
|
||||||
| The entire `_DoMotion`/`_StopMotion` seam target: `adjust_motion(ref,ref,HoldKey)` + `DoInterpretedMotion`/`StopInterpretedMotion(uint, MovementParameters)` incl. the retail double-adjust (MoveToManager adjusts, DoInterpretedMotion adjusts again — r4-ace §1 "do NOT fix") | §7a/§7b; 0x00529010/0x00529080 | MotionInterpreter.cs:1241/:2861/:3016 — untouched |
|
|
||||||
| WeenieError numerics 0x8/0x24/0x3f-0x49/0x47 (R3-W1 renumber) — V1 only ADDS values | A10 table | MotionInterpreter.cs:136-205 |
|
|
||||||
| mt 6/7 wire parse: UnPackNet field order (bitfield, dto, min, fail, speed, threshhold, desiredHeading) + runRate tail + mt-6 guid head — verified against §2g this session | UnPackNet 0x0052ac50 §2g | UpdateMotion.cs:280-356 + `CreateObject.MoveToPathData` — V3 adds mt 8/9 beside it |
|
|
||||||
| `OriginToWorld` (landblock-local origin → streaming world) | Position rebase equivalence (P5) | RemoteMoveToDriver.cs:263-277 — MOVES to the manager's home in V4 (the one survivor of the file) |
|
|
||||||
| MotionSequenceGate (S1) in front of ALL 0xF74C routing — R4 changes nothing upstream of the gate | 0xF74C dispatch pc:357214 | GameWindow OnLiveMotionUpdated head |
|
|
||||||
| R2 pipeline (MotionTableManager/GetObjectSequence/funnel sink) — MoveToManager's dispatched motions flow through it like any other interpreted motion; the animation side needs ZERO new work | R2/R3 shipped | Motion/ classes + MotionTableDispatchSink |
|
|
||||||
| Contact/OnWalkable transient-state plumbing (local + remote) — UseTime's `transient_state & 1` gate reads the same source `contact_allows_move` uses (P7 confirms) | @307781 | MotionInterpreter.cs:1979/2089 pattern; PhysicsBody TransientState |
|
|
||||||
| HitGround/LeaveGround verbatim bodies (R3-W4) — V4/V5 add the moveto HitGround call BESIDE interp.HitGround (retail order: minterp first, then moveto, §2d); LeaveGround has NO moveto side (COMDAT no-op, §2e) | MovementManager::HitGround 0x00524300 | MotionInterpreter.cs HitGround/LeaveGround + their App call sites |
|
|
||||||
| Jump charge UI (AP-24), run/jump skill defaults (TS-21), AP diff cadence (AP-30), outbound packers — untouched by R4 | — | PlayerMovementController.cs sections 4-8 |
|
|
||||||
| AD-27 Use/PickUp re-send on arrival (ACE MoveToChain-timeout workaround) — SURVIVES, re-anchored to the new `MoveToComplete` seam (§4); retail notifies nothing on arrival (CleanUpAndCallWeenie is CleanUp+Stop only, §7e) | §7e | GameWindow.cs:11915 subscription; register AD-27 |
|
|
||||||
| AP-23 per-type use-radius heuristic — survives (ACE's close-branch broadcasts nothing actionable; unchanged by R4), re-anchored | ACE Player_Move.cs:66 | GameWindow.cs:12102-12164; register AP-23 |
|
|
||||||
| `PlanFromVelocity` (UP-DR cycle heuristic) — survives to R6 with its NEW register row (M16); only `PlanMoveToStart` dies in R4 | retire R6 (per-tick order) | ServerControlledLocomotion.cs:54-87 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
|
|
||||||
|
|
||||||
New code target: `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (+
|
|
||||||
`MoveToMath.cs` for the free functions) per plan rule 4 — pure logic, GL-free,
|
|
||||||
seams only. NAME WATCH: retail's `MoveToManager::MovementNode {type, heading}`
|
|
||||||
must NOT collide with R2's `Motion/MotionNode.cs` (the pending_motions node) —
|
|
||||||
name it `MoveToNode`, register-note the rename. Tests:
|
|
||||||
`tests/AcDream.Core.Tests/Physics/Motion/`. Every commit: build+test green,
|
|
||||||
register rows added/retired in-commit.
|
|
||||||
|
|
||||||
**V0 — pins + research docs (docs only).**
|
|
||||||
Commit `docs/research/2026-07-03-r4-movetomanager/` (decomp extraction, ACE
|
|
||||||
cross-ref, this plan, `V0-pins.md` resolving P1-P7 in the W0-pins format —
|
|
||||||
P1's ACE trace + source read and P3's Ghidra decompile are load-bearing; P2/P5/P6
|
|
||||||
are cheap; P4 is a recorded decision). Fix the RemoteMoveToDriver.cs:53-57 stale
|
|
||||||
claim in the same commit. Deps: none.
|
|
||||||
|
|
||||||
**V1 — command-selection family + state widening (Core, no consumers).** (closes M2-mechanics, M11, M12, M15)
|
|
||||||
`MovementParameters` gains `GetCommand(dist, heading, out motion, out holdKey,
|
|
||||||
out movingAway)` (verbatim §5c INCLUDING the CanCharge 0x10 fast-path ACE dropped
|
|
||||||
— A13+A15 trap: retail's version of BOTH), `TowardsAndAway` (§5d),
|
|
||||||
`GetDesiredHeading` (P2 pin), plus a `FromWire(uint bitfield, dto, min, fail,
|
|
||||||
speed, threshhold, desiredHeading)` factory (UnPackNet semantics — bit masks per
|
|
||||||
A4) and the field-by-field copy the entry points do. `MoveToMath.cs`: `HeadingDiff`
|
|
||||||
(P3 pin), `HeadingGreater` (§5f), `PositionHeading` + `GetHeading/SetHeading`
|
|
||||||
(P5 pin — REUSING the outbound packer's yaw↔heading conversion, one convention
|
|
||||||
in the codebase), `CylinderDistance` (§5a signature). `MovementType` gains
|
|
||||||
Invalid/6/7/8/9; `MovementStruct` gains `ObjectId/TopLevelId/Pos(world+cell)/
|
|
||||||
Radius/Height/Params`. `WeenieError` += 0x0B/0x36/0x37/0x38/0x3D.
|
|
||||||
Tests first: get_command truth table (all four flag quadrants × distance bands ×
|
|
||||||
the hold-key cascade incl. CanCharge fast-path, threshold-edge ≤ vs <,
|
|
||||||
walk-incapable), towards_and_away three bands, heading helpers wrap/epsilon
|
|
||||||
(0.000199999995f literal) tables, cardinal-heading goldens (P5).
|
|
||||||
Fixture source: **synthetic + A4/A10 pins**. Deps: V0.
|
|
||||||
|
|
||||||
**V2 — MoveToManager verbatim (Core class + conformance harness; no App wiring).** (closes M1/M3/M4/M5/M6/M10/M14-core)
|
|
||||||
All 33 members per the extraction: ctor/Create/InitializeLocalVariables (flags
|
|
||||||
word + context_id zeroed, floats stale, FLT_MAX distance resets — NOT ACE's A2/A3)
|
|
||||||
/Destroy; PerformMovement (cancel 0x36 + unstick first, 4-way); MoveToObject/
|
|
||||||
MoveToPosition/TurnToObject (desired-heading clobber quirk VERBATIM)/TurnToHeading
|
|
||||||
(immediate BeginNextNode — ACE's A4 gap not copied); MoveToObject_Internal/
|
|
||||||
TurnToObject_Internal (fmod, sought-heading read); node factories + `MoveToNode`
|
|
||||||
(managed List<> — AD-34 wording); BeginNextNode (sticky handoff reads
|
|
||||||
radius/height/tlid BEFORE CleanUp, StickTo seam no-op); BeginMoveForward
|
|
||||||
(localParams: clear 0x8000, holdKey from get_command, write-back to stored
|
|
||||||
params, progress-clock seed); BeginTurnToHeading (empty-head → CancelMoveTo(8)
|
|
||||||
per A10 — not ACE's throw; PreviousHeading := DIFF quirk verbatim);
|
|
||||||
UseTime (retail gate polarity: `tlid == 0 || type == Invalid || initialized` —
|
|
||||||
NOT ACE's A12 negation); HandleMoveToPosition (Phase 1 aux 20°/340° — NO ACE A6
|
|
||||||
set_heading snap, NO A8 inRange block; Phase 2 arrival `moving_away ? dist ≥ min
|
|
||||||
: dist ≤ dto`, fail-distance 0x3D; Phase 3 quantum ETA retune via seam);
|
|
||||||
HandleTurnToHeading (heading_greater → snap set_heading(node.Heading, send:true)
|
|
||||||
+ pop + stop + next); HandleUpdateTarget (init/retarget split, 0x37/0x38 codes,
|
|
||||||
FLT_MAX reseeds); HitGround; CheckProgressMade (1 s / 0.25 both-rates);
|
|
||||||
GetCurrentDistance (use_spheres → cylinder); CleanUp (stop current+aux,
|
|
||||||
clear_target gate, InitializeLocalVariables — does NOT drain nodes);
|
|
||||||
CleanUpAndCallWeenie (CleanUp THEN StopCompletely — reentrancy-safe ordering
|
|
||||||
+ the `MoveToComplete` seam, §4); CancelMoveTo (drain + CleanUp + Stop; the
|
|
||||||
WeenieError arg kept-but-unread per §7c); _DoMotion/_StopMotion (§7a — adjust
|
|
||||||
then dispatch); is_moving_to; fail_progress_count as a write-only field (§8).
|
|
||||||
Seams (ctor-injected, §4): interp, StopCompletely, position/heading accessors,
|
|
||||||
own+target radius/height, IsInterpolating, Contact, set_target/clear_target/
|
|
||||||
quantum, unstick, StickTo, MoveToComplete.
|
|
||||||
Tests first: per-function conformance tables from the extraction's constants
|
|
||||||
inventory (§12); the reentrancy test (CancelMoveTo → CleanUpAndCallWeenie →
|
|
||||||
interp.StopCompletely → InterruptCurrentMovement → CancelMoveTo no-ops on
|
|
||||||
Invalid); quirk goldens (TurnToObject heading clobber ⇒ final = face-object;
|
|
||||||
BeginTurnToHeading stores diff; UseTime gate matrix incl. uninitialized type-6
|
|
||||||
stall); a scripted end-to-end table drive (positions fed per tick → expected
|
|
||||||
node pops + dispatched motion ids/hold keys, incl. run→walk demote inside
|
|
||||||
threshold 15).
|
|
||||||
Fixture source: **synthetic + §12 constants (+ V0 cdb goldens if captured)**.
|
|
||||||
Deps: V1.
|
|
||||||
|
|
||||||
**V3 — wire completion: mt 8/9 + my_run_rate + params exposure.** (closes M7, M13, M14-wire-note)
|
|
||||||
UpdateMotion parses mt 8 (guid + wire_heading + 3-dword UnPackNet, P6 order) and
|
|
||||||
mt 9 (3-dword UnPackNet) into a widened `MoveToPathData` (or a sibling
|
|
||||||
`TurnToPathData`) carrying the DECODED bitfield; mt 6/7 exposure widened so ALL
|
|
||||||
UnPackNet fields reach the consumer as a `MovementParameters` via `FromWire`
|
|
||||||
(today only ad-hoc bit properties). Parse the 0xF74C motionFlags sticky-guid
|
|
||||||
trailer (bit 0x1 → read dword; carried, unconsumed until R5) so the buffer
|
|
||||||
cursor is honest; standing_longjump bit (0x2) NOTED as an R5 unpack_movement
|
|
||||||
item, not consumed here. `MoveToRunRate` documented as the `MyRunRate` write
|
|
||||||
the V4/V5 consumers perform (unpack @300603).
|
|
||||||
Tests first: golden-byte fixtures generated from ACE's event writers (P6) for
|
|
||||||
all four types + flag permutations; existing mt 6/7 fixtures green unchanged.
|
|
||||||
Fixture source: **ACE-writer golden bytes**. Deps: V1 (types), parallel with V2.
|
|
||||||
|
|
||||||
**V4 — REMOTE cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED.** (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9; adds the M16 row)
|
|
||||||
Each `RemoteMotion` gains a `MoveTo` manager bound to its existing
|
|
||||||
`Motion` interp + body (construction beside the R3 sink bind, §4). GameWindow
|
|
||||||
remote UM routing (:4549-4620): mt 6 → resolve guid against the entity table →
|
|
||||||
`MovementStruct{MoveToObject, radius/height from the entity's setup}` →
|
|
||||||
`MoveTo.PerformMovement`; unresolvable → degrade to MoveToPosition(wire origin)
|
|
||||||
per §2f; mt 7/8/9 likewise; `Motion.MyRunRate = MoveToRunRate`. The P4
|
|
||||||
`TargetTracker` adapter feeds `HandleUpdateTarget` (register row). Per-tick: the
|
|
||||||
:9594-9682 block becomes `rm.MoveTo.UseTime()` (same slot the legacy driver
|
|
||||||
occupied — see the placement decision below); remote HitGround sites add
|
|
||||||
`rm.MoveTo.HitGround()` after `rm.Motion.HitGround()` (§2d order). DELETE:
|
|
||||||
`RemoteMoveToDriver.cs` (OriginToWorld moves to `MoveToMath`; TurnRateFor's
|
|
||||||
surviving consumers, if any outside the deleted paths, get the constant from the
|
|
||||||
interp's own apply_run_to_command home), `PlanMoveToStart` + its :4412 seeding
|
|
||||||
branch (the manager's own `_DoMotion` → funnel sink now produces the cycle,
|
|
||||||
identical mechanism to every other interpreted motion), the
|
|
||||||
`HasMoveToDestination/MoveToDestinationWorld/LastMoveToPacketTime/
|
|
||||||
ServerMoveToActive` RemoteMotion fields and their :4917/:5329 reads.
|
|
||||||
Registers in-commit: AD-8/AD-9/AP-8/AP-9 DELETED; NEW AP row "TargetTracker
|
|
||||||
minimal re-tracking adapter (P4) — full TargetManager port R5"; NEW AP row for
|
|
||||||
PlanFromVelocity's constants (M16, survives to R6); note that arrival now uses
|
|
||||||
retail cylinder distance (the AD-8 max() class is GONE — watch melee-range stop
|
|
||||||
distance in the visual pass).
|
|
||||||
Tests first: scripted chase/flee/retarget/fail-distance scenario harness driving
|
|
||||||
a manager against a mocked tracker; dispatched-motion trace conformance (NPC
|
|
||||||
chase emits WalkForward+HoldKey_Run → aux turns → stop, per V2's table);
|
|
||||||
existing remote funnel suites green. Live smoke: NPC chase + ACDREAM_DUMP_MOTION.
|
|
||||||
Fixture source: **V2 harness + live smoke**. Deps: V2+V3.
|
|
||||||
|
|
||||||
**V5 — LOCAL PLAYER cutover: B.6 auto-walk DELETED; TS-36 bound (ONE commit, GameWindow + controller — do NOT fan out, feedback_dont_parallelize_coupled_plan_slices).** (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23; adds the P1 row if pinned as an adaptation)
|
|
||||||
`PlayerMovementController` gains a `MoveTo` manager bound to its `Motion` interp
|
|
||||||
(exposed like `Motion` was for R3-W2's bind). GameWindow local routing
|
|
||||||
(:4507-4547): the `BeginServerAutoWalk` call becomes MovementStruct →
|
|
||||||
`MoveTo.PerformMovement` (OriginToWorld unchanged); the P1 pin governs the mt-0
|
|
||||||
companion echo (adaptation row if that's the pin). `Motion.InterruptCurrentMovement`
|
|
||||||
binds to `MoveTo.CancelMoveTo(ActionCancelled)` — TS-36 RETIRED; user input now
|
|
||||||
cancels the moveto exactly the retail way (input edge → DoMotion with
|
|
||||||
CancelMoveTo bit → interrupt seam → CancelMoveTo), so the explicit
|
|
||||||
"user-input cancel" check dies with the block. DELETE the whole B.6 block:
|
|
||||||
fields :275-:340 (incl. `IsServerAutoWalking`, `_autoWalkTurnDirectionThisFrame`
|
|
||||||
+ its :1528-1540 consumer), `BeginServerAutoWalk` :452, `EndServerAutoWalk` :495,
|
|
||||||
`DriveServerAutoWalk` :567-766, the :896 call + `autoWalkConsumedMotion` skip
|
|
||||||
plumbing (:1028). `AutoWalkArrived` is replaced by the `MoveToComplete(None)`
|
|
||||||
seam subscription (AD-27 re-anchored, same Use-resend behavior).
|
|
||||||
`InstallSpeculativeTurnToTarget` (:12122) rewires to a LOCAL
|
|
||||||
`MovementStruct{TurnToObject/MoveToObject}` through the player's manager
|
|
||||||
(retail §9a/§9b client-initiated shape; AP-23 buckets survive as the radius
|
|
||||||
source, row re-anchored). Per-tick: `MoveTo.UseTime()` at the :896 slot
|
|
||||||
(placement decision below). `[autowalk-*]` probes retarget to the manager
|
|
||||||
(PhysicsDiagnostics name kept).
|
|
||||||
Tests first: full suite green; controller edge-driven suites unchanged;
|
|
||||||
manager-driven use-walk scenario (turn node → walk → demote-to-walk near target
|
|
||||||
→ arrival → MoveToComplete fires once); reentrancy live test (jump mid-moveto
|
|
||||||
cancels it — TS-36's predicted failure); outbound golden-byte parity for a
|
|
||||||
scripted approach (MTS/AP bytes vs pre-cutover capture — the moveto issues
|
|
||||||
non-autonomous motions, so outbound autonomous traffic must NOT change).
|
|
||||||
**ONE user visual pass** (with R2/R3's if still pending): use a door at range
|
|
||||||
(walk-up + arrival + door opens once), use while facing away (visible turn
|
|
||||||
first), NPC chase legs (turn cycles during corrections, walk-pace close-in),
|
|
||||||
TurnTo emote-target if reachable.
|
|
||||||
Fixture source: **pre-cutover traces + golden-byte + V2 harness**. Deps: V4.
|
|
||||||
|
|
||||||
**V6 — register sweep + roadmap + digest (docs/cleanup only).**
|
|
||||||
Verify retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5). Verify added:
|
|
||||||
TargetTracker AP row, PlanFromVelocity AP row (M16), P1 echo AD row (if pinned),
|
|
||||||
MoveToNode-rename note under AD-34's wording, StickTo/quantum no-op seam rows
|
|
||||||
(→R5). Re-anchor: AD-25 (:1212 shifts after the controller deletion), AD-27,
|
|
||||||
AP-23, AP-24, TS-21, AP-30 line numbers. Roadmap stage table (R4 shipped);
|
|
||||||
milestones check; memory digest note (animation deep-dive: MoveToManager gap
|
|
||||||
CLOSED; the "three approximations" pattern retired).
|
|
||||||
Deps: V5.
|
|
||||||
|
|
||||||
Parallelization: V0∥nothing; V1→V2 sequential; V3∥V2 (after V1). V4→V5
|
|
||||||
sequential (remotes prove the manager before the local cutover). V4 and V5 both
|
|
||||||
touch GameWindow — single-agent each.
|
|
||||||
|
|
||||||
**DECISION — B.6 auto-walk dies in R4 (V5), not R5.** Grounds: the plan of
|
|
||||||
record's R3 keep-list marked it "REPLACED in R4 by MoveToManager"; the mandate
|
|
||||||
is delete-in-stage; everything DriveServerAutoWalk does is a subset of the
|
|
||||||
verbatim manager (turn-first = TurnToHeading node; 30° walk-while-turning band =
|
|
||||||
retail's 20° aux band; arrival = the distance predicate; user cancel = the
|
|
||||||
cancel_moveto interrupt chain); keeping it alongside a real manager would mean
|
|
||||||
two writers on the same motion channels — the exact double-motion hazard TS-36
|
|
||||||
warns about. What survives R5-ward is only the two seams the manager can't fill
|
|
||||||
yet: StickTo (R5 StickyManager) and the full TargetManager (P4 row).
|
|
||||||
|
|
||||||
**DECISION — per-tick driver placement pre-R6: the legacy drivers' own slots.**
|
|
||||||
`MoveTo.UseTime()` runs (a) for the player: in `PlayerMovementController.Update`
|
|
||||||
at the exact point `DriveServerAutoWalk` runs today (:896, before the
|
|
||||||
input-driven motion block); (b) for remotes: in the GameWindow per-remote tick
|
|
||||||
block where `RemoteMoveToDriver.Drive` runs today (:9594). Justification:
|
|
||||||
least-invasive — both slots already sit at the "after inbound wire, before/with
|
|
||||||
physics integrate" altitude the legacy approximations were tuned for, so the
|
|
||||||
cutover changes WHAT steers, not WHEN, and every behavior diff in the visual
|
|
||||||
pass is attributable to the manager itself rather than to a reordering. Retail's
|
|
||||||
true slot (`UpdateObjectInternal`: … transition sweep → MovementManager.UseTime →
|
|
||||||
PositionManager.UseTime, plan module map) is R6's deliverable for ALL managers
|
|
||||||
at once; R3 set the precedent (r3-port-plan §4 rule 2: "tick placement
|
|
||||||
provisional until R6"). The provisional placement gets a one-line note in both
|
|
||||||
call sites, not a register row (ordering-within-tick is R6's audited scope).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. WIRING CONTRACT — MoveToManager ↔ the R3 MotionInterpreter (+ GameWindow routing)
|
|
||||||
|
|
||||||
Retail chain being stood in for (R5 inserts MovementManager as a null-guarded
|
|
||||||
relay WITHOUT behavior change — same rule as R3 §4's MotionDone note):
|
|
||||||
|
|
||||||
```
|
|
||||||
retail: unpack_movement / CPhysicsObj entry points
|
|
||||||
└─ MovementManager::PerformMovement (1-5 → CMotionInterp, 6-9 → MoveToManager)
|
|
||||||
└─ MoveToManager ── _DoMotion/_StopMotion ──► CMotionInterp
|
|
||||||
(adjust_motion → DoInterpretedMotion/StopInterpretedMotion)
|
|
||||||
R4: GameWindow OnLiveMotionUpdated (mt switch — the pre-existing call-site dispatch)
|
|
||||||
├─ mt 0 → funnel (unchanged; P1 pin governs the local-player echo)
|
|
||||||
├─ mt 1-5 → interp paths (unchanged)
|
|
||||||
└─ mt 6/7/8/9 → entity.MoveTo.PerformMovement(MovementStruct) [+ MyRunRate write]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Construction & binding (per entity, at the same site as the R3 sink bind):**
|
|
||||||
- Remote: where `RemoteMotion.Motion` + the MotionTableManager sink are wired
|
|
||||||
(GameWindow entity creation), construct `MoveTo = new MoveToManager(...)` with:
|
|
||||||
- `interp` = `remoteMot.Motion` (the `_DoMotion` target — adjust_motion +
|
|
||||||
DoInterpretedMotion/StopInterpretedMotion, NOTHING else; MoveToManager never
|
|
||||||
calls DoMotion/set_hold_run/raw-state APIs — decomp §7b),
|
|
||||||
- `stopCompletely` = `() => Motion.StopCompletely()` (retail routes
|
|
||||||
CPhysicsObj::StopCompletely → MovementStruct type 5 → interp; the direct
|
|
||||||
call is the same body pre-R5),
|
|
||||||
- body accessors: position+cell, `GetHeading`/`SetHeading(deg, send)` (P5
|
|
||||||
convention; `send` flags the outbound heading snap — remotes: no-op send),
|
|
||||||
own radius/height (setup shape), Contact (`PhysicsObj.TransientState`),
|
|
||||||
`IsInterpolating` = InterpolationManager queue-non-empty,
|
|
||||||
- target seams: `set_target/clear_target` → the P4 TargetTracker;
|
|
||||||
`get/set_target_quantum` → tracker-recorded value (accepted, non-throttling);
|
|
||||||
- `unstick` → `Motion.UnstickFromObject` (existing no-op seam, R5);
|
|
||||||
`stickTo(tlid, radius, height)` → NEW no-op Action seam (R5 StickyManager;
|
|
||||||
register row);
|
|
||||||
- `MoveToComplete(WeenieError)` → remote: nothing (retail-faithful);
|
|
||||||
local player: the AD-27 Use-resend subscription (fires ONLY on `None`).
|
|
||||||
- Local player: `PlayerMovementController` constructs/exposes `MoveTo` bound to
|
|
||||||
its own `Motion`; GameWindow routes local mt 6-9 to it.
|
|
||||||
|
|
||||||
**The cancel chain (TS-36 retires here):**
|
|
||||||
```
|
|
||||||
user input edge / jump() / StopCompletely
|
|
||||||
└─ MotionInterpreter — params.CancelMoveTo bit / hardcoded sites (:921/:998/:1062/:1833)
|
|
||||||
└─ InterruptCurrentMovement?.Invoke() [R3 seam, was no-op]
|
|
||||||
└─ R4 bind: entity.MoveTo.CancelMoveTo(WeenieError.ActionCancelled)
|
|
||||||
└─ drain nodes → CleanUp (stops current+aux via _StopMotion,
|
|
||||||
clear_target, InitializeLocalVariables → movement_type=Invalid)
|
|
||||||
→ StopCompletely
|
|
||||||
```
|
|
||||||
Reentrancy invariant (MUST be tested, V2): the tail `StopCompletely` re-enters
|
|
||||||
`InterruptCurrentMovement` → `CancelMoveTo`, which NO-OPS because CleanUp already
|
|
||||||
reset `movement_type` to Invalid BEFORE the stop (retail ordering §7e; ACE note
|
|
||||||
r4-ace §3). Do not reorder CleanUp/StopCompletely.
|
|
||||||
|
|
||||||
**HandleUpdateTarget feed (P4):** TargetTracker (App) watches the entity table;
|
|
||||||
delivery → `entity.MoveTo.HandleUpdateTarget(TargetInfo)`; context 0 only
|
|
||||||
(retail CPhysicsObj::HandleUpdateTarget @280794 gates on context_id == 0).
|
|
||||||
Target despawn → `status = ExitWorld` (manager cancels 0x37/0x38 itself).
|
|
||||||
|
|
||||||
**Per-tick + ground events:** `MoveTo.UseTime()` at the two legacy slots
|
|
||||||
(decision above). Every existing `Motion.HitGround()` call site adds
|
|
||||||
`MoveTo.HitGround()` AFTER it (retail §2d order: minterp then moveto).
|
|
||||||
LeaveGround/ReportExhaustion: NO moveto call — COMDAT no-ops (§2e), do not
|
|
||||||
invent.
|
|
||||||
|
|
||||||
**GameWindow mt 6-9 routing detail (V4/V5):**
|
|
||||||
1. MotionSequenceGate first (unchanged).
|
|
||||||
2. `Motion.MyRunRate = MoveToRunRate` (unpack @300603/@300660).
|
|
||||||
3. mt 6: resolve `TargetGuid` in the entity table → top-level parent id (retail
|
|
||||||
resolves `parent ?: target`, §9a — acdream: container/parent link if the
|
|
||||||
entity model has one, else the guid itself) + setup radius/height →
|
|
||||||
`MovementStruct{MoveToObject, ObjectId, TopLevelId, Radius, Height,
|
|
||||||
Params=FromWire(...)}`. Unresolvable → `MovementStruct{MoveToPosition,
|
|
||||||
Pos=OriginToWorld(wire origin)}` (the §2f degrade — NOT an error).
|
|
||||||
4. mt 7: MoveToPosition(OriginToWorld(origin), FromWire params).
|
|
||||||
5. mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading =
|
|
||||||
wire_heading, TurnToHeading (§2f fallback).
|
|
||||||
6. mt 9: TurnToHeading(FromWire 3-field params).
|
|
||||||
7. mt 0 for the local player: per the P1 pin (echo suppression rule or wire
|
|
||||||
discriminator); for remotes: unchanged funnel + (retail head shape) an
|
|
||||||
interrupt — remotes' UM streams from ACE already interleave moveto and
|
|
||||||
interpreted UMs, and V4 must apply the SAME P1 answer to remotes chasing
|
|
||||||
under ACE's re-emit (a fresh mt-6 re-emit cancels+restarts via
|
|
||||||
PerformMovement regardless, so remotes are insensitive to the pin either way).
|
|
||||||
|
|
||||||
**What the manager dispatches (animation side — zero new work):**
|
|
||||||
`_DoMotion(0x45000005 WalkForward, localParams{holdKey})` → adjust_motion
|
|
||||||
promotes to RunForward×runRate under HoldKey_Run → DoInterpretedMotion → the
|
|
||||||
R2 sink → MotionTableManager → GetObjectSequence — the identical path a wire
|
|
||||||
RunForward takes today. `PlanMoveToStart`'s seeding job ceases to exist rather
|
|
||||||
than being replaced.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
|
|
||||||
|
|
||||||
- **`MoveToManager::LeaveGround` / `ReportExhaustion` — COMDAT-folded no-ops**
|
|
||||||
in this build (decomp §2e); `CMotionInterp::HandleEnterWorld` likewise. No
|
|
||||||
members, no seams, no behavior.
|
|
||||||
- **`fail_progress_count` is write-only in RETAIL too** (§8: 6 sites, zero
|
|
||||||
reads). NO give-up-after-N mechanism exists — the only aborts are
|
|
||||||
fail_distance (0x3D) and target status (0x37/0x38). Do not invent a stall
|
|
||||||
timeout to replace AD-9's timer; deleting the timer is the point.
|
|
||||||
- **`CancelMoveTo`'s WeenieError arg is NEVER READ in the body** (§7c) — every
|
|
||||||
call site's code (8/0x36/0x37/0x38/0x3D) is dead in this build. Keep the
|
|
||||||
parameter (R5 parity + logging), wire no behavior to its value.
|
|
||||||
- **`CleanUpAndCallWeenie` contains NO weenie call in this build** (§7e — the
|
|
||||||
name is vestigial; body ≡ CleanUp + StopCompletely). acdream's
|
|
||||||
`MoveToComplete` seam is a documented CLIENT addition standing in for ACE's
|
|
||||||
server-side `OnMoveComplete` — do not present it as retail.
|
|
||||||
- **No listener/observer machinery on MoveToManager or MovementManager**
|
|
||||||
(grep-negative, §10). `MoveToComplete` is the one seam, added consciously.
|
|
||||||
- **`CPhysicsObj::cancel_moveto` does not exist** — the cancel entry is
|
|
||||||
`interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` does not
|
|
||||||
exist** — position moves enter via unpack case 7 / direct manager call only.
|
|
||||||
- **Do NOT copy the ACE-isms** (all RETAIL-VERIFIED in r4-ace §4): A1
|
|
||||||
stale-member UseFinalHeading read (read the ARGUMENT in MoveToPosition), A2
|
|
||||||
InitializeLocalVars zeroing DistanceToObject (retail zeroes the FLAGS word),
|
|
||||||
A3 missing FLT_MAX resets, A4 missing BeginNextNode in TurnToHeading, A6
|
|
||||||
set_heading snap in the aligned branch ("custom: sync for server ticrate"),
|
|
||||||
A7 `AlwaysTurn`, A8 `inRange` arrival block ("custom for low monster update
|
|
||||||
rate"), A11 WeenieObj NPE, A12 UseTime gate negation, A13 CanCharge-true
|
|
||||||
default, A14 threshold 1.0, A15 dropped CanCharge fast-path.
|
|
||||||
- **Do NOT "fix" the retail quirks**: TurnToObject's desired_heading write is
|
|
||||||
clobbered before any read (final heading = face-the-object; ACE matches);
|
|
||||||
BeginTurnToHeading stores the remaining DIFF into previous_heading; the
|
|
||||||
double adjust_motion (_DoMotion + DoInterpretedMotion-internal); PerformMovement
|
|
||||||
returning 0 unconditionally (errors surface via CancelMoveTo, not the return).
|
|
||||||
- **HandleTurnToHeading's `set_heading(node.Heading, true)` snap IS retail**
|
|
||||||
(0052a146) — the only heading snap in the whole family. Everything else
|
|
||||||
rotates via dispatched turn motions.
|
|
||||||
- **BN artifact ledger applies** (decomp §11): the chase-arrival and turn-pick
|
|
||||||
branch senses in the raw are `test ah,0x41` inversions — the adjudicated
|
|
||||||
readings (chase arrives at `dist ≤ distance_to_object`; diff ≥ 180 →
|
|
||||||
TurnLeft) are pinned; do not re-derive from the literal pseudo-C. The
|
|
||||||
`RemoteMoveToDriver.cs:53-57` "ACE swaps the predicates" doc claim is WRONG
|
|
||||||
(superseded in-file at :186-199) — fixed in V0.
|
|
||||||
- **TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo
|
|
||||||
bodies were NOT extracted** — call shapes only (§9f/§9g). Do not invent
|
|
||||||
internals beyond the P4 minimal adapter; R5 owns the ports.
|
|
||||||
- **The older Ghidra chunk `chunk_00520000.c` is from a DIFFERENT build** for
|
|
||||||
this address region (§10) — function boundaries don't align; unusable for
|
|
||||||
adjudication. Use the named raw + Ghidra MCP on `patchmem.gpr` only.
|
|
||||||
- **MovementManager itself is R5** — R4 must not grow a premature facade; the
|
|
||||||
type dispatch stays at the existing GameWindow/controller call sites, and the
|
|
||||||
two direct binds (interrupt→CancelMoveTo, HitGround dual-call) are exactly
|
|
||||||
the relays R5 will absorb.
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
# R4 live-verify session handoff — fresh-session entry point (2026-07-03, evening)
|
|
||||||
|
|
||||||
Successor to `2026-07-03-phase-r-session-handoff.md` (whose postscripts
|
|
||||||
summarize the same arc — this doc is the current entry point). Worktree
|
|
||||||
`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree CLEAN
|
|
||||||
at `3c866f95`. Full suite green: **3,963** (+15 since the R4 handoff).
|
|
||||||
|
|
||||||
## State
|
|
||||||
|
|
||||||
**R4 is SHIPPED and user-verified live** (V0–V6 + five root-cause fixes the
|
|
||||||
live pass exposed). Confirmed working by the user's eyes: local use-walk
|
|
||||||
(turn-first, walk/run by distance, arrival, door opens once), door open +
|
|
||||||
door SWING animation, remote (retail-client) close-range moveto walking,
|
|
||||||
and remote run-distance movetos at correct animation pace AND speed (#160).
|
|
||||||
|
|
||||||
## The five live fixes (trail, newest first — each is a real mechanism, no adaptations added)
|
|
||||||
|
|
||||||
| Commit | Root cause fixed |
|
|
||||||
|---|---|
|
|
||||||
| `41006e79` | **#160 slow-motion remote runs**: remote interps had NO weenie → retail's `apply_run_to_command` rate chain (`weenie ? (InqRunRate() ?: my_run_rate) : 1.0`, raw 305062-305076) took the degenerate 1.0 branch; the wire's `MoveToRunRate` (stored in `MyRunRate` by the mt-6/7 unpack, M13) was never consumed. Fix: `RemoteWeenie` (Core) — retail's per-object ACCWeenieObject stand-in; `InqRunRate` FAILS → `my_run_rate` fallback; `IsThePlayer`=false ends the "null weenie counts as the player" A3 reading. |
|
|
||||||
| `350fb5e3` | **Door-swing snap** (register TS-40): CMotionInterp's detached-object link-strip guard (`physics_obj->cell == 0`, raw @305627) was proxied by `CellPosition.ObjCellId==0`, seeded only by the local player's `SnapToCell` → every REMOTE body read "detached" and every dispatched transition link was stripped same-tick. Fix: explicit `PhysicsBody.InWorld` set by SnapToCell + RemoteMotion construction. |
|
|
||||||
| `006cf659` | **Remote-player glide (partial)** + **door UMs dropped**: (a) remote PLAYERS' MoveToManagers were never ticked (the V4 UseTime slot was NPC-only) — extracted `TickRemoteMoveTo`, now also in the grounded player-remote (L.3 M2) pipeline; (b) UP-less entities (doors) never got a RemoteMotion → since the S2b funnel cutover their UM motions were parsed and dropped — rm now created on first UM, with a `LastServerPosTime>0` gate keeping UP-less bodies OUT of the dead-reckoning tick. |
|
|
||||||
| `24569fd2` | **Moveto stall #2**: login `SetPosition` ran before the DefaultSink bind in `EnterPlayerModeNow` → its StopCompletely A9 node had no completion partner → ONE immortal pending_motions node → wait-for-anims gate never opened. Fix: bind block moved above SetPosition; `LoginQueue_DrainsToEmpty_UnderProductionFeed` pins the invariant. |
|
|
||||||
| `c2dc1a88` | **Moveto stall #1 (the big one)**: the R3 port MISREAD `StopCompletely_Internal` (0x0050ead0) as a velocity zero — it is `CPartArray::StopCompletelyInternal` (0x00518890) = `MotionTableManager::PerformMovement(type 5)`, the ANIMATION-side stop whose UNCONDITIONALLY-queued entry is the matched pop for the A9 pending_motions node. Fix: `IInterpretedMotionSink.StopCompletely()` → the R2 manager's already-ported type-5 op. Also: retail's per-tick `CheckForCompletedMotions` slot (CPartArray::HandleMovement 0x00517d60) added to the controller tick, and the P1 autonomous-STORE family completed (unpack store 00509730, input-edge stores DoMotion@00510030/StopMotion@005100e0, section-2 stops per-frame stamping, speculative install stores false). |
|
|
||||||
|
|
||||||
## The queue model (the load-bearing mental model — do not relearn)
|
|
||||||
|
|
||||||
`pending_motions` (interp) pops HEAD-ANY on each `MotionDone`; completions
|
|
||||||
come from the manager's `pending_animations` entries. **Every enqueue must
|
|
||||||
have a completion partner** (dispatch → manager entry). One orphan =
|
|
||||||
permanent ≥1 backlog (later completions just relabel it) = `MotionsPending`
|
|
||||||
true forever = `BeginTurnToHeading`'s verbatim wait-for-anims gate wedges
|
|
||||||
every moveto. Orphan producers found: StopCompletely-before-sink-bind and
|
|
||||||
the misread StopCompletely_Internal. The `[autowalk-gate]` probe prints the
|
|
||||||
whole gate state — it is how both wedges were pinned.
|
|
||||||
|
|
||||||
## OPEN — next session picks up here (full trails in docs/ISSUES.md)
|
|
||||||
|
|
||||||
1. ~~**#161 — remote jump landing stuck in the falling pose**~~ **CLOSED
|
|
||||||
2026-07-03 `b1cf0102`, user live-verified.** The prime suspect was
|
|
||||||
right; the mechanism was a BN decomp artifact: retail REWRITES the
|
|
||||||
apply pass's params word (raw 305778 smeared store, mask 0x37ff →
|
|
||||||
`ModifyInterpretedState=false`; ACE MotionInterp.cs:444-449 confirms),
|
|
||||||
so retail PRESERVES interpreted fwd through the fall and HitGround
|
|
||||||
re-dispatches it (the landing-link exit). Also: both landing blocks
|
|
||||||
cleared Gravity BEFORE `Motion.HitGround()` (whose verbatim state&0x400
|
|
||||||
gate then no-opped — fixed order, AP-81); K-fix17's SetCycle DID
|
|
||||||
execute but re-set the clobbered Falling command — both copies deleted.
|
|
||||||
NOTE: "HitGround raw @305949" below was a mislabel (that line is inside
|
|
||||||
`move_to_interpreted_state`; real HitGround = 0x00528ac0 →
|
|
||||||
`apply_current_movement(0,0)`). Full trail: ISSUES Recently-closed #161.
|
|
||||||
2. ~~**#162 — glide-class adjudication**~~ **CLOSED 2026-07-03, no
|
|
||||||
adaptation.** User A/B: retail does NOT glide (new-fact branch) — and
|
|
||||||
post-#161 acdream doesn't either (user-verified walk/run-by-distance +
|
|
||||||
clean mid-chain key takeover). Mechanism re-audit: head-interrupt +
|
|
||||||
CancelMoveTo ports are retail-verbatim (retail's observer ALSO loses
|
|
||||||
the armed moveto on the first reflection); ACE's mt-0 reflections carry
|
|
||||||
the mover's real locomotion, so the legs stay correct after any cancel.
|
|
||||||
The old glide was killed by the R4-V5 stack + #161's apply-pass params
|
|
||||||
fix. Full evidence: ISSUES Recently-closed #162.
|
|
||||||
3. ~~**#163 — strip the TEMPORARY diagnostics**~~ **DONE 2026-07-03**
|
|
||||||
(`[autowalk-gate]` + clock, `[autowalk-feed]`, and the short-lived
|
|
||||||
`[MOVETO-CANCEL]` probe; the durable ProbeAutoWalk family stays).
|
|
||||||
4. ~~Combined R2+R3+R4 visual-pass remainder~~ **PASSED 2026-07-03,
|
|
||||||
user-verified** (jump/ledge momentum, run-in-circles blend, stop
|
|
||||||
settle, retail-observer view of +Acdream — all four "looks good").
|
|
||||||
Two observations filed from the pass: **#165** (remote movers get
|
|
||||||
"swallowed" into walls a bit instead of stopping flush — remote-DR
|
|
||||||
collision, capture with PROBE_RESOLVE first) and **#166**
|
|
||||||
(slope-landing glide+bounce polish — the AD-25 + AP-7 + TS-4
|
|
||||||
composite, post-R6 by user's call). NEXT SESSION ENTERS AT **R5**
|
|
||||||
(MovementManager facade + StickyManager + ConstraintManager + full
|
|
||||||
TargetManager — retires TS-39 + AP-79's adapter).
|
|
||||||
|
|
||||||
## Session gotchas worth keeping
|
|
||||||
|
|
||||||
- **Evidence-first**: two plausible-but-wrong theories (a per-tick
|
|
||||||
apply_current_movement "pump" — retail's apply callers are EVENT-driven
|
|
||||||
only; and "ACE chain broadcasts mt-0 locomotion" — it doesn't) were
|
|
||||||
killed by the raw + wire captures. The `[autowalk-gate]` probe and the
|
|
||||||
UM raw hex dumps (autonomy byte at offset +14) were the decisive tools.
|
|
||||||
- **Retail object-model completeness matters**: two of the five fixes were
|
|
||||||
"retail gives every placed object X and we only gave it to the player"
|
|
||||||
(a weenie; a cell/InWorld). When a dispatch path behaves differently for
|
|
||||||
remotes, check what retail's CPhysicsObj carries that our RemoteMotion
|
|
||||||
lacks BEFORE suspecting the ported logic.
|
|
||||||
- The launch recipe + graceful-close rules are unchanged (CLAUDE.md); the
|
|
||||||
client locks builds — ask the user to close it, never kill.
|
|
||||||
- Register: TS-40 added; TS-33 extended (orientation-diff gap); AP-79
|
|
||||||
covers both tracker instances; TS-36/AD-26 retired earlier today.
|
|
||||||
|
|
||||||
## Key files this session touched
|
|
||||||
|
|
||||||
`MotionInterpreter.cs` (StopCompletely sink dispatch, InWorld guards),
|
|
||||||
`MoveToManager.cs` (MoveToComplete widened to BeginNextNode completion),
|
|
||||||
`MotionTableDispatchSink.cs` + `InterpretedMotionFunnel.cs`
|
|
||||||
(StopCompletely sink op), `PhysicsBody.cs` (InWorld), `RemoteWeenie.cs`
|
|
||||||
(new), `PlayerMovementController.cs` (UseTime slot + per-tick Check +
|
|
||||||
edge autonomy stores + temp probe), `GameWindow.cs` (P1 gate + store,
|
|
||||||
RouteServerMoveTo, TickRemoteMoveTo, rm-on-UM, EnterPlayerModeNow bind
|
|
||||||
order, player tracker feed), tests: `PlayerMoveToCutoverTests`,
|
|
||||||
`MoveToManagerCompletionSeamTests`, `InWorldLinkGuardTests`,
|
|
||||||
`RemoteWeenieRunRateTests`, `W6EdgeDrivenMovementTests` (apply-pass form).
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
# R5 entry handoff — fresh-session entry point (2026-07-03, night)
|
|
||||||
|
|
||||||
Successor to `2026-07-03-r4-verify-session-handoff.md` (now fully closed
|
|
||||||
out — its items 1-4 are all struck through with outcomes). Worktree
|
|
||||||
`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree
|
|
||||||
CLEAN at `304327b0`. Full suite green: **3,964**.
|
|
||||||
|
|
||||||
## State — the R2-R4 arc is DONE
|
|
||||||
|
|
||||||
R4 shipped + user-verified; the whole follow-up queue closed **today,
|
|
||||||
all user-verified live**:
|
|
||||||
|
|
||||||
| Item | Outcome |
|
|
||||||
|---|---|
|
|
||||||
| #161 landing pose | FIXED `b1cf0102` — retail's apply pass runs with `ModifyInterpretedState=false` (the BN decomp SMEARS the params bitfield store into the mush at raw 305778: `(word & 0x37ff) \| cancelMoveTo<<15 \| disableJump<<17`; ACE MotionInterp.cs:444-449 confirms). Fwd is PRESERVED through a fall; HitGround re-dispatches it → landing link. Plus: HitGround must run BEFORE the DR gravity-bit clear (its verbatim `state&0x400` gate — AP-81); K-fix17 SetCycle blocks deleted; `copy_movement_from` now copies `current_style` (raw 0051e757); ALL apply_current_movement caller polarities fixed (raw arg3 = DisableJumpDuringLink → `(N,0)` = allowJump TRUE). Lesson: [[feedback-bn-decomp-field-names]] (2nd artifact class). |
|
|
||||||
| #162 glide class | CLOSED, **no adaptation**. User A/B: retail does NOT glide — and post-#161 acdream matches (walk/run-by-distance movetos + clean mid-chain key takeover). Head-interrupt + CancelMoveTo re-audited retail-verbatim; ACE's mt-0 reflections carry the mover's REAL locomotion, so cancels never strand the legs. Evidence: ISSUES Recently-closed #162 + the launch-162 capture analysis. |
|
|
||||||
| #163 temp diags | STRIPPED `5ebe2be3` ([autowalk-gate]+clock, [autowalk-feed], [MOVETO-CANCEL]). Durable ProbeAutoWalk family kept. |
|
|
||||||
| R2-R4 visual pass | **PASSED** (jump/ledge momentum, run-in-circles, stop settle, retail-observer view). Filed #165 (remote wall-swallow) + #166 (slope-landing sled — the AD-25+AP-7+TS-4 composite, post-R6 per user). |
|
|
||||||
|
|
||||||
## NEXT: R5 — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
|
|
||||||
|
|
||||||
**Retires: TS-39** (MoveToManager `StickTo`/`Unstick` are unbound no-op
|
|
||||||
seams — sticky movetos complete-and-stop instead of sticking; retail
|
|
||||||
hands off to `PositionManager::StickTo(tlid, radius, height)`, and every
|
|
||||||
`PerformMovement` head calls `unstick_from_object` →
|
|
||||||
`PositionManager::UnStick`) **and AP-79** (the P4 TargetTracker minimal
|
|
||||||
adapter — GameWindow feeds `HandleUpdateTarget` from the entity table;
|
|
||||||
retail's TargetManager is a full voyeur-subscription system with
|
|
||||||
per-target quantums).
|
|
||||||
|
|
||||||
### Retail function inventory (grep'd, addresses verified)
|
|
||||||
|
|
||||||
- **MovementManager** (facade, mostly thin): 00524000 MakeMoveToManager ·
|
|
||||||
00524020 SetWeenieObject · 005240d0 PerformMovement · 00524170
|
|
||||||
move_to_interpreted_state · 005241b0 CancelMoveTo · 005241c0
|
|
||||||
EnterDefaultState · 00524260 IsMovingTo · 00524280 motions_pending ·
|
|
||||||
005242d0 MotionDone · 005242f0 UseTime · 00524300 HitGround · 00524320
|
|
||||||
LeaveGround · 00524340/00524350 HandleEnter/ExitWorld · 00524360
|
|
||||||
ReportExhaustion · 00524440 unpack_movement · 00524790
|
|
||||||
HandleUpdateTarget (routes TargetInfo → MoveToManager). Much of this
|
|
||||||
facade's BEHAVIOR is already ported piecemeal (funnel, RouteServerMoveTo,
|
|
||||||
TickRemoteMoveTo, the UseTime/CheckForCompletedMotions slots) — R5's job
|
|
||||||
is the STRUCTURE: one owner object per entity replacing the GameWindow
|
|
||||||
wiring sprawl, without changing dispatch behavior pinned by the
|
|
||||||
183-case + funnel + moveto suites.
|
|
||||||
- **StickyManager**: 00555400 UnStick · 00555430 adjust_offset · 00555610
|
|
||||||
UseTime · 005556e0 SetPhysicsObject · 00555710 StickTo · 00555780
|
|
||||||
HandleUpdateTarget.
|
|
||||||
- **ConstraintManager**: 00556090 SetPhysicsObject · 005560c0 UnConstrain ·
|
|
||||||
005560d0 IsFullyConstrained · 00556180 adjust_offset · 00556240
|
|
||||||
ConstrainTo. (`jump_is_allowed` already calls an `IsFullyConstrained`
|
|
||||||
stub — find it before re-porting.)
|
|
||||||
- **PositionManager** (owns the two): CPhysicsObj::MakePositionManager
|
|
||||||
00510210 (also sets transient bit 0x80 + update_time). `stick_to_object`
|
|
||||||
/ `unstick_from_object` on CPhysicsObj are the entry seams (today
|
|
||||||
Action seams on MotionInterpreter, wired per-entity in GameWindow).
|
|
||||||
- **TargetManager**: 0051a370 ctor · 0051a4a0 SetTargetQuantum · 0051a4f0
|
|
||||||
SendVoyeurUpdate · 0051a5e0 GetInterpolatedPosition · 0051a650
|
|
||||||
CheckAndUpdateVoyeur · 0051a6f0 NotifyVoyeurOfEvent · 0051a7e0
|
|
||||||
ClearTarget · 0051a830 AddVoyeur · 0051a930 ReceiveUpdate · 0051aa90
|
|
||||||
HandleTargetting · 0051ac30 SetTarget · 0051ad90 RemoveVoyeur.
|
|
||||||
AP-79's seams (`_setTarget`/`_clearTarget`/quantum get/set +
|
|
||||||
GameWindow's tracker blocks for remotes AND the `_playerMoveToTarget*`
|
|
||||||
player twin) are the integration points to replace.
|
|
||||||
|
|
||||||
### Already ported — do NOT re-port
|
|
||||||
|
|
||||||
MoveToManager (R4, full incl. the queue model), MotionInterpreter (R3
|
|
||||||
full; #161 corrected the apply-pass params), MotionTableManager +
|
|
||||||
GetObjectSequence (R2), CSequence (R1), RemoteWeenie + PhysicsBody.InWorld
|
|
||||||
(R4-V5), DefaultSink binding order (bind BEFORE SetPosition — the
|
|
||||||
`LoginQueue_DrainsToEmpty_UnderProductionFeed` invariant).
|
|
||||||
|
|
||||||
### R5-adjacent verification items (found reading unpack_movement this session)
|
|
||||||
|
|
||||||
1. **Head stance-change dispatch**: raw @00524502-0052452c — before the
|
|
||||||
type switch, for ALL movement types: `if (InqStyle() !=
|
|
||||||
command_ids[stanceIdx]) CMotionInterp::DoMotion(style, defaults)` (BN
|
|
||||||
mislabels InqStyle as `CBaseFilter::GetPinVersion`). Verify our UM
|
|
||||||
path does this for types 6-9 (mt-0 handles style inside the funnel).
|
|
||||||
2. **mt-0 header flags**: 0x100 → read sticky guid → `stick_to_object`
|
|
||||||
(raw @00524589); 0x200 → `standing_longjump` store (raw @0052458e).
|
|
||||||
`UpdateMotion.cs` PARSES both (cited §2f) — verify they're APPLIED
|
|
||||||
(sticky needs TS-39's machinery = this phase; longjump flag should
|
|
||||||
reach `MotionInterpreter.StandingLongJump`).
|
|
||||||
3. **#164** (filed): action-replay dispatches drop the per-action
|
|
||||||
Autonomous bit (raw 305982 sets params bit 0x1000 → AddAction). Fix
|
|
||||||
while touching the action path.
|
|
||||||
|
|
||||||
## The load-bearing lessons (compressed)
|
|
||||||
|
|
||||||
- **The queue model** (unchanged from R4): every `pending_motions`
|
|
||||||
enqueue needs a manager completion partner; one orphan = permanent
|
|
||||||
MotionsPending wedge.
|
|
||||||
- **BN smeared bitfield stores**: a fresh local struct + later "mush"
|
|
||||||
expression = decode the mush BEFORE porting "ctor defaults". ACE's C#
|
|
||||||
port of the same function is the fastest cross-check. Cost of missing
|
|
||||||
it: #161 + the wrong W6 entry-cache theory + a wrong pinned test.
|
|
||||||
- **Tests can pin bug values**: `AirborneBody_NoCycleDispatches_...`
|
|
||||||
asserted the CLOBBERED fwd (0x40000015) with a comment admitting it was
|
|
||||||
untested. When flipping behavior, audit what the old assertions PINNED.
|
|
||||||
|
|
||||||
## Session gotchas
|
|
||||||
|
|
||||||
- Launch recipe unchanged (CLAUDE.md). Client LOCKS builds — ask the user
|
|
||||||
to close it, never kill. Graceful close clears the ACE session in ~5 s.
|
|
||||||
- PowerShell `Tee-Object` writes UTF-16 → grep the background task's
|
|
||||||
plain-text output file instead of the tee'd log.
|
|
||||||
- ACDREAM_DUMP_MOTION=1 + ACDREAM_REMOTE_VEL_DIAG=1 give the full UM/seq
|
|
||||||
evidence chain ([UM_RAW] hex incl. autonomy byte, [FWD_WIRE],
|
|
||||||
[SEQSTATE], [MOTIONDONE]); #165's first step is ACDREAM_PROBE_RESOLVE
|
|
||||||
at a wall.
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
# R5 seam recon — current acdream integration points MovementManager/PositionManager/TargetManager will replace
|
|
||||||
|
|
||||||
Repo root: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
|
|
||||||
Read-only recon. All line numbers verified against files as of this session (2026-07-03).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. MoveToManager seams — `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (1605 lines)
|
|
||||||
|
|
||||||
### Ctor-required seam delegates (lines 78-138, ctor 184-222)
|
|
||||||
All `Action`/`Func` fields, all REQUIRED (non-nullable, thrown in ctor if null):
|
|
||||||
- `_stopCompletely` (L78) — retail `CPhysicsObj::StopCompletely`
|
|
||||||
- `_getPosition` (L83) — retail `physics_obj->m_position`
|
|
||||||
- `_getHeading` (L87) — retail `CPhysicsObj::get_heading`
|
|
||||||
- `_setHeading` (L94) — retail `CPhysicsObj::set_heading(heading, send)` — the ONE heading snap, fired only from `HandleTurnToHeading` (L1168)
|
|
||||||
- `_getOwnRadius` (L99), `_getOwnHeight` (L103) — mover's own cylinder dims
|
|
||||||
- `_contact` (L107) — retail `transient_state & 1` (CONTACT bit) — `UseTime`'s tick gate
|
|
||||||
- `_isInterpolating` (L112) — retail `CPhysicsObj::IsInterpolating` → PositionManager (R5 body doesn't exist yet)
|
|
||||||
- `_getVelocity` (L116) — feeds Phase-3 TargetManager quantum retune
|
|
||||||
- `_getSelfId` (L120) — self-target detection
|
|
||||||
- `_setTarget` (L126) — retail `CPhysicsObj::set_target` → **TargetManager::SetTarget (R5, call-shape only)**
|
|
||||||
- `_clearTarget` (L130) — retail `CPhysicsObj::clear_target` → **TargetManager::ClearTarget (R5, call-shape only)**
|
|
||||||
- `_getTargetQuantum` (L134) / `_setTargetQuantum` (L138) — **TargetManager quantum (R5, call-shape only)**
|
|
||||||
- `_curTime` (L175, optional param, defaults to a per-call 1/30s stub)
|
|
||||||
|
|
||||||
### AP-79 target seams (setTarget/clearTarget)
|
|
||||||
- `_setTarget(0, TopLevelObjectId, 0.5f, 0.0)` called from `MoveToObject` (L471) and `TurnToObject` (L592) — always context 0, radius 0.5, quantum 0.0.
|
|
||||||
- `_clearTarget()` called from `CleanUp` (L1499) when `TopLevelObjectId != 0 && MovementTypeState != Invalid`.
|
|
||||||
- Consumer of the callback: `HandleUpdateTarget(TargetInfo info)` (L1229-1283) — the P4 TargetTracker feed point. Ignores updates for any `info.ObjectId != TopLevelObjectId` (L1237). Two paths: deferred-start (`Initialized==false`, first callback) vs retarget-while-running.
|
|
||||||
- **Today's producer of `TargetInfo` is NOT a real TargetManager** — it's `GameWindow.TickRemoteMoveTo` (App-layer, §2c below) polling `_entitiesByServerGuid` each tick and manually diffing against `TrackedTargetRadius`.
|
|
||||||
|
|
||||||
### TS-39 sticky seams (StickTo/Unstick)
|
|
||||||
- `public Action? Unstick { get; set; }` (L145) — retail `CPhysicsObj::unstick_from_object` → **PositionManager::UnStick (R5 body)**. Called unconditionally at the head of `PerformMovement` (L414). Optional — null = silent no-op.
|
|
||||||
- `public Action<uint,float,float>? StickTo { get; set; }` (L151) — retail `PositionManager::StickTo(object_id, radius, height)` (**R5 StickyManager body**). Called from `BeginNextNode`'s sticky arrival handoff (L708) with pre-CleanUp values `(tlid, radius, height)`. Optional — null = silent no-op.
|
|
||||||
- **Today, NEITHER `Unstick` nor `StickTo` is bound to anything** in GameWindow.cs — grep of `EnsureRemoteMotionBindings` and `EnterPlayerModeNow` shows no `rm.MoveTo.StickTo = ...` or `.Unstick = ...` assignment anywhere. Both are permanently null (silent no-op) in production today. (Separately, `MotionInterpreter.UnstickFromObject` — a DIFFERENT, unrelated seam on the interp, not the MoveToManager — IS bound in some contexts; see §3.)
|
|
||||||
|
|
||||||
### setHeading seam
|
|
||||||
- `_setHeading: Action<float,bool>` — bound per-manager at construction (remote: `mtBody.Orientation = MoveToMath.SetHeading(...)`, GameWindow.cs:4277-4278; player: `pcMoveTo.Yaw = MoveToMath.YawFromHeading(h)`, GameWindow.cs:12996-12997). The `bool send` (network-echo flag) param is **UNCONSUMED** on both sides — register row TS-33 (GameWindow.cs:12983-12988 comment): a stationary heading snap never reaches the wire.
|
|
||||||
|
|
||||||
### Public surface (entry points + drivers)
|
|
||||||
- `PerformMovement(MovementStruct mvs)` (L411) — the type-6..9 dispatch entry, called from `RouteServerMoveTo` (GameWindow.cs, §5below)
|
|
||||||
- `MoveToObject` / `MoveToPosition` / `TurnToObject` / `TurnToHeading` (L450, 501, 560, 613)
|
|
||||||
- `UseTime()` (L953) — THE TICK GATE: `!_contact()` / no pending node / (object-move `Initialized` gate) → dispatches `HandleMoveToPosition` / `HandleTurnToHeading`. Called from `GameWindow.TickRemoteMoveTo` (L4504) for remotes; not shown called for the player in the same function (player pump is via `_playerController`/`OnUpdateFrame` — see §2c).
|
|
||||||
- `HandleUpdateTarget(TargetInfo info)` (L1229) — AP-79 feed point (see above)
|
|
||||||
- `HitGround()` (L1292) — no-op if `MovementTypeState==Invalid`, else `BeginNextNode()`. Called from GameWindow at L5426 (remote landing) and L10070 (another remote landing site — see §2d).
|
|
||||||
- `CancelMoveTo(WeenieError error)` (L1461) — reentrancy-guarded drain+cleanup+stop; the `error` param is NEVER read in retail's body (kept for parity/logging only)
|
|
||||||
- `IsMovingTo()` (L397), `Destroy()` (L387), `CleanUp()` (L1485), `CleanUpAndCallWeenie()` (L1516)
|
|
||||||
- `MoveToComplete` (L168) — **CLIENT ADDITION, not retail** — fires `WeenieError.None` on natural completion only (never on cancel). Bound in `EnterPlayerModeNow` (GameWindow.cs:13024-13030) to re-fire the deferred AD-27 Use/PickUp action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. GameWindow wiring — `src/AcDream.App/Rendering/GameWindow.cs` (13,587 lines)
|
|
||||||
|
|
||||||
### 2a. `EnsureRemoteMotionBindings` (GameWindow.cs:4234-4308)
|
|
||||||
Binds, once per `RemoteMotion` (guarded by `rm.Sink is not null` early-return, L4239):
|
|
||||||
- `rm.Sink = new MotionTableDispatchSink(ae.Sequencer)` with `TurnApplied`/`TurnStopped` callbacks (L4243-4255) feeding `rmForSink.ObservedOmega`
|
|
||||||
- `rm.Motion.DefaultSink = rm.Sink` (L4256)
|
|
||||||
- `rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations` (L4257)
|
|
||||||
- `rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState()` (L4258)
|
|
||||||
- `rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions` (L4261) — R3-W5 per-op zero-tick flush
|
|
||||||
- `rm.MoveTo = new MoveToManager(...)` (L4270-4300) — full seam wiring: `stopCompletely`, `getPosition` (world-space, `rmT.CellId` + `mtBody.Position/Orientation`), `getHeading`/`setHeading` via `MoveToMath`, `getOwnRadius`/`getOwnHeight` **both hardcoded `0f`** (comment: "pin P4 note — setup cylsphere radius lands with R5's TargetManager port"), `contact: () => mtBody.OnWalkable`, `isInterpolating: () => rmT.Interp.IsActive`, `getVelocity`, `getSelfId: () => serverGuid`, `setTarget`/`clearTarget` writing `rmT.TrackedTargetGuid/Radius/FedOnce` (L4285-4291), `getTargetQuantum`/`setTargetQuantum` on `rmT.TargetQuantum`, real-clock `curTime` (L4294-4300, R4-V5 fix — was per-call stub before)
|
|
||||||
- `rm.Motion.InterruptCurrentMovement = () => rmT.MoveTo?.CancelMoveTo(WeenieError.ActionCancelled)` (L4304-4306) — TS-36, retail's `interrupt_current_movement → MovementManager::CancelMoveTo(0x36)` chain
|
|
||||||
- **NOT bound here**: `rm.MoveTo.StickTo`, `rm.MoveTo.Unstick`, `rm.MoveTo.MoveToComplete` — all left null for remotes.
|
|
||||||
|
|
||||||
### 2b. `EnterPlayerModeNow` (GameWindow.cs:12960-13145+)
|
|
||||||
Player-side construction, mirrors 2a with player-specific deltas (documented at L12970-12988):
|
|
||||||
- (a) heading via `pcMoveTo.Yaw` bridge, not a quaternion write directly
|
|
||||||
- (b) `contact: () => pcMoveTo.BodyInContact` — real Contact transient bit, not OnWalkable proxy
|
|
||||||
- (c) `isInterpolating: () => false` — player has no InterpolationManager
|
|
||||||
- `playerMoveTo = new MoveToManager(...)` (L12990-13013)
|
|
||||||
- `playerMoveTo.MoveToComplete = err => { ...; if (err==None) OnAutoWalkArrivedSendDeferredAction(); }` (L13024-13030) — the ONLY MoveToComplete binding in the codebase (AD-27 reanchor)
|
|
||||||
- `_playerController.MoveTo = playerMoveTo` (L13031)
|
|
||||||
- `_playerController.Motion.InterruptCurrentMovement = () => playerMoveTo.CancelMoveTo(WeenieError.ActionCancelled)` (L13039-13045) — TS-36 player side
|
|
||||||
- `_playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions` (L13145-13146), inside the `if (_animatedEntities.TryGetValue(...))` sequencer-bind block (L13135+) — the R4-V5 "moveto-stall fix #2" comment (L13126-13134) documents this MUST run before the initial `SetPosition` or the sequencer's pending_motions node orphans.
|
|
||||||
- **NOT bound**: `playerMoveTo.StickTo`, `playerMoveTo.Unstick`.
|
|
||||||
- Player MoveToManager tick pump: **not inside `EnterPlayerModeNow`** — search shows no direct `playerMoveTo.UseTime()` call in that function; it's driven per-frame from `OnUpdateFrame` via `_playerMoveToTarget*` fields (see §2c) and the `RouteServerMoveTo` call at L4772 (§2e).
|
|
||||||
|
|
||||||
### 2c. AP-79 tracker block + player pre-Update feed
|
|
||||||
- **Remote-side tracker**: `TickRemoteMoveTo(RemoteMotion rm)` (GameWindow.cs:4469-4505). Feeds `HandleUpdateTarget(Ok)` from `_entitiesByServerGuid` lookup when the tracked target moved beyond `rm.TrackedTargetRadius` (voyeur-radius diff, L4479-4491), or `ExitWorld` if the guid vanished from the live table (L4493-4502); then unconditionally calls `mtm.UseTime()` (L4504).
|
|
||||||
- **Player-side fields**: `_playerMoveToTargetGuid` / `_playerMoveToTargetRadius` / `_playerMoveToTargetFedOnce` / `_playerMoveToTargetQuantum` are declared and seeded in `EnterPlayerModeNow` (L13006-13016) via the ctor's `setTarget`/`clearTarget`/`getTargetQuantum`/`setTargetQuantum` closures, but **the actual per-tick "feed HandleUpdateTarget + call UseTime()" loop for the player was NOT found inside `EnterPlayerModeNow`** — grep of `TickRemoteMoveTo`-equivalent player logic did not surface a distinct function; it likely lives in `OnUpdateFrame` (13,587-line file — not fully read this pass). **Flag for R5 authors: locate/confirm the player pump site before assuming parity with `TickRemoteMoveTo`.**
|
|
||||||
|
|
||||||
### 2d. HitGround / LeaveGround / MotionDone / UseTime / CheckForCompletedMotions call sites
|
|
||||||
- `rm.Motion.LeaveGround()` — GameWindow.cs:5100 (remote ground departure, retail `LeaveGround` 0x00528b00)
|
|
||||||
- `rmState.Motion.HitGround()` + `rmState.MoveTo?.HitGround()` — GameWindow.cs:5425-5426 (remote landing; comment cites #161, retail `MovementManager::HitGround`)
|
|
||||||
- `rm.Motion.HitGround()` + `rm.MoveTo?.HitGround()` — GameWindow.cs:10062, 10070 (a SECOND remote-landing site, same pattern — the file has two independent landing branches)
|
|
||||||
- `ae.Sequencer.MotionDoneTarget = (m, ok) => { ...; interp?.MotionDone(m, ok); }` — GameWindow.cs:10142-10160 (R3-W2, bound once per sequencer; retail `CPhysicsObj::MotionDone` 0x0050fdb0 chain)
|
|
||||||
- `ae.Sequencer.Manager.UseTime()` — GameWindow.cs:10194 (this is the ANIMATION sequencer's own UseTime, not MoveToManager's — do not conflate)
|
|
||||||
- `mtm.UseTime()` — GameWindow.cs:4504 inside `TickRemoteMoveTo` (the MoveToManager tick, per remote, per frame)
|
|
||||||
- `rm.Motion.CheckForCompletedMotions = ...` — GameWindow.cs:4261 (bind site, in `EnsureRemoteMotionBindings`)
|
|
||||||
|
|
||||||
### 2e. RouteServerMoveTo (the type-6..9 funnel entry — see §5 also)
|
|
||||||
`RouteServerMoveTo(MoveToManager mgr, MotionInterpreter interp, uint cellId, EntityMotionUpdate update)` — GameWindow.cs:4361-4457. Two call sites:
|
|
||||||
- Player: GameWindow.cs:4772 — `RouteServerMoveTo(playerMoveTo, _playerController.Motion, _playerController.CellId, update)`, inside the `update.Guid == _playerServerGuid` branch (L4716), gated behind `_playerController is not null` (L4753) and preceded by `_playerController.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4768-4769) — the retail unpack_movement head (@300566).
|
|
||||||
- Remote: GameWindow.cs:4835 — `RouteServerMoveTo(moveMgr, remoteMot.Motion, remoteMot.CellId, update)`, inside the `else` (non-player) branch (L4784), also preceded by `remoteMot.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4828-4829).
|
|
||||||
|
|
||||||
### 2f. RemoteMotion class — GameWindow.cs:411-510+ (nested `internal sealed class RemoteMotion`, NOT a separate file)
|
|
||||||
Manager-related fields:
|
|
||||||
- `PhysicsBody Body`, `MotionInterpreter Motion` (L413-414)
|
|
||||||
- `MotionTableDispatchSink? Sink` (L419)
|
|
||||||
- `MoveToManager? MoveTo` (L436) — R4-V4
|
|
||||||
- AP-79 tracker state: `TrackedTargetGuid`, `TrackedTargetRadius`, `TrackedTargetLastFedPos`, `TrackedTargetFedOnce`, `TargetQuantum` (L442-446)
|
|
||||||
- Legacy/parallel `RemoteMoveToDriver`-oriented fields still present: `ServerMoveToActive`, `HasMoveToDestination`, `MoveToDestinationWorld`, `MoveToMinDistance`, `MoveToDistanceToObject`, `MoveToMoveTowards`, `LastMoveToPacketTime` (L448-503) — comments reference `AcDream.Core.Physics.RemoteMoveToDriver`, which per the R4 handoff docs was **retired** in the R4 "three approximations" cleanup (per MEMORY.md: "RemoteMoveToDriver/PlanMoveToStart/B.6-auto-walk all deleted"). These fields may now be dead weight predating that retirement — worth confirming during R5 cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. MotionInterpreter sticky/action seams — `src/AcDream.Core/Physics/MotionInterpreter.cs` (3,298 lines)
|
|
||||||
|
|
||||||
- `StickTo`/`Unstick`/`stick` grep: **NO `StickTo` seam exists on `MotionInterpreter`** — only `UnstickFromObject` (L779: `public Action? UnstickFromObject { get; set; }`, doc L771-807), the R3-W2 no-op seam standing in for retail `CPhysicsObj::unstick_from_object`. This is a DIFFERENT seam from `MoveToManager.Unstick`/`.StickTo` (§1) — MotionInterpreter's copy is called at L2329 and L2375 inside its own dispatch/cancel paths, unrelated to the MoveToManager sticky handoff.
|
|
||||||
- `StandingLongJump`: field `public bool StandingLongJump;` (L747). Arms ONLY at `ChargeJump` (L25 comment, L1970 `StandingLongJump = true;`). Cleared at L2023 and L2511 (`StandingLongJump = false;`). Consumed in `ApplyInterpretedMovement`'s three-way branch (L2947, quoted below) and in `DoInterpretedMotion`'s verbatim retail body pseudocode (L2995-2997, `label_528440` goto).
|
|
||||||
|
|
||||||
### The #164 action-dispatch loop — `ApplyInterpretedMovement` (MotionInterpreter.cs:2920-2981)
|
|
||||||
```csharp
|
|
||||||
public void ApplyInterpretedMovement(
|
|
||||||
uint currentStyle, IInterpretedMotionSink? sink,
|
|
||||||
bool cancelMoveTo = false, bool allowJump = false)
|
|
||||||
{
|
|
||||||
if (PhysicsObj is null) return;
|
|
||||||
|
|
||||||
// Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449):
|
|
||||||
// Speed is re-set per axis below, everything else rides the pass.
|
|
||||||
var p = new MovementParameters
|
|
||||||
{
|
|
||||||
SetHoldKey = false,
|
|
||||||
ModifyInterpretedState = false,
|
|
||||||
CancelMoveTo = cancelMoveTo,
|
|
||||||
DisableJumpDuringLink = !allowJump,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
|
|
||||||
MyRunRate = InterpretedState.ForwardSpeed;
|
|
||||||
|
|
||||||
p.Speed = 1.0f;
|
|
||||||
DoInterpretedMotion(currentStyle, p, sink);
|
|
||||||
|
|
||||||
if (!contact_allows_move(InterpretedState.ForwardCommand))
|
|
||||||
{
|
|
||||||
p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000
|
|
||||||
DoInterpretedMotion(MotionCommand.Falling, p, sink);
|
|
||||||
}
|
|
||||||
else if (StandingLongJump)
|
|
||||||
{
|
|
||||||
p.Speed = 1.0f;
|
|
||||||
DoInterpretedMotion(MotionCommand.Ready, p, sink);
|
|
||||||
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
p.Speed = InterpretedState.ForwardSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink);
|
|
||||||
if (InterpretedState.SideStepCommand == 0)
|
|
||||||
{
|
|
||||||
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
p.Speed = InterpretedState.SideStepSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InterpretedState.TurnCommand != 0)
|
|
||||||
{
|
|
||||||
p.Speed = InterpretedState.TurnSpeed;
|
|
||||||
DoInterpretedMotion(InterpretedState.TurnCommand, p, sink);
|
|
||||||
return; // retail early return — no idle-stop this call
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tail: unconditional StopInterpretedMotion(TurnRight, params).
|
|
||||||
StopInterpretedMotion(MotionCommand.TurnRight, p, sink);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**Correction to the task framing**: the dispatched `MovementParameters p` is built ONCE at the top with `Autonomous` implicitly `false` (default, never set) and **explicitly** sets `SetHoldKey=false`, `ModifyInterpretedState=false`, `CancelMoveTo=cancelMoveTo`, `DisableJumpDuringLink=!allowJump` — only `p.Speed` is mutated per-branch (not re-constructed per dispatch as `new MovementParameters { Speed }`). The #161 doc comment (L2892-2918) explicitly discusses this exact bitfield and calls `ModifyInterpretedState=false` "load-bearing." The `Autonomous` bit is never read or written anywhere in this method — confirms the framing that the loop drops/never-carries the Autonomous bit through to per-dispatch `MovementParameters`.
|
|
||||||
|
|
||||||
This is called from `MoveToInterpretedState` (L2820-2866, the funnel entry) at L2843: `ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);` — always `cancelMoveTo: true` for inbound wire state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. UpdateMotion wire parsing — `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (479 lines)
|
|
||||||
|
|
||||||
### mt-0 header flag parsing
|
|
||||||
- **MotionFlags byte** (wire term "header" in decomp comments) parsed at L161: `byte motionFlags = body[pos]; pos += 1;` — comment (L148-149) documents `0x1 = StickToObject, 0x2 = StandingLongJump` (ACE `MotionFlags` enum names). **This is NOT the same as the "0x100"/"0x200" framing in the task** — those are decomp-doc shorthand for the SAME bits at a different byte-shift (`header & 0x100` in the decomp refers to bit 0 of this byte after the 8-bit `motionFlags` is logically at bit-position 8 of the larger unpack_movement header dword). The actual C# test is `(motionFlags & 0x1) != 0` for sticky (L290) — there is no separate literal `0x100`/`0x200` mask in this file; grep for those literals returns nothing in UpdateMotion.cs.
|
|
||||||
- **0x1 (StickToObject) parse site**: L279-295, comment block cites ACE `MovementInvalid.Write` + decomp §2f case 0 @0052455d. Code:
|
|
||||||
```csharp
|
|
||||||
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
|
|
||||||
{
|
|
||||||
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
||||||
pos += 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Only fires for `movementType == 0` (InterpretedMotionState branch, L188). The trailing u32 is read AFTER the InterpretedMotionState body + Commands list (matches ACE's write order).
|
|
||||||
- **0x2 (StandingLongJump) — NOT consumed**: comment L153-160 explicitly states "DOCUMENTED here but NOT consumed — retail's `unpack_movement` case 0 writes it onto `motion_interpreter->standing_longjump` (§2f @0052458e), an R5 unpack_movement item (this parser has no MotionInterpreter reference to write it onto...)". No code reads `motionFlags & 0x2` anywhere in the file.
|
|
||||||
|
|
||||||
### Downstream consumers (grep of whole src tree)
|
|
||||||
- `StickyObjectGuid` (the parsed field, carried on `CreateObject.ServerMotionState`, L317-326 return + `CreateObject.cs:265`): grep across `src/` shows it is **assigned into the record and returned, but never read by any production consumer** — the only non-definition hits are in `tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs` (asserting the parsed value, L662/683/716). No site in `GameWindow.cs`, `MotionInterpreter.cs`, or `MoveToManager.cs` reads `.StickyObjectGuid` off `ServerMotionState`.
|
|
||||||
- `motionFlags & 0x2` / `MotionFlags.StandingLongJump`: **zero matches anywhere in the repo** outside the UpdateMotion.cs documentation comment itself.
|
|
||||||
- **Conclusion for R5**: both the sticky-guid trailer and the standing-longjump wire bit are parsed-and-shelved. R5's PositionManager::StickTo (per the UpdateMotion.cs comment, "R5's PositionManager::StickTo body is the eventual consumer") and MovementManager (for standing-longjump) are the intended consumers — neither exists yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. UM dispatch path for movement types 6-9 — funnel + head-stance question
|
|
||||||
|
|
||||||
- **Funnel/router**: `RouteServerMoveTo` (GameWindow.cs:4361-4457, quoted in part in §2e) is the ONE function that inspects `update.MotionState.IsServerControlledMoveTo`/`IsServerControlledTurnTo` + `MovementType` (6/7 → MoveToObject/MoveToPosition via `path`; 8/9 → TurnToObject/TurnToHeading via `turnPath`) and calls `mgr.PerformMovement(ms)`. Both the player call site (GameWindow.cs:4772) and remote call site (GameWindow.cs:4835) funnel through this SAME function — confirmed shared body, not duplicated per the R4-V5 extraction comment (L4459-4468).
|
|
||||||
- **Does any site do a head stance-change dispatch (`InqStyle != wire style -> DoMotion(style)`) BEFORE the type-6..9 switch?** **NO separate dispatch exists.** For the remote path, the stance/style value folds directly into `InboundInterpretedState.CurrentStyle` (GameWindow.cs:4867: `CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du`), which is built and consumed only in the `else` branch AFTER `RouteServerMoveTo` returns `false` (i.e., only for mt-0 / non-moveto events) — see GameWindow.cs:4834-4839 (`RouteServerMoveTo` call, early-`return` on true) followed by the `ims` construction at L4865-4874. So: for a wire event that IS a type-6..9 moveto, the style/stance field is **never separately dispatched** at this layer — `MoveToManager.PerformMovement` doesn't take or apply a style at all (it only sets `MovementTypeState` and queues nodes; no `DoMotion(style)` call anywhere in `PerformMovement`/`MoveToObject`/`MoveToPosition`/`TurnToObject`/`TurnToHeading`, confirmed by reading all of §1's L411-637). The style-on-change dispatch is explicitly flagged as unported: `RetailObserverTraceConformanceTests.cs:33` documents an EXCLUSION — "motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) style call for wire style index 0) — outside move_to_interpreted_state; **S3 wires the unpack-level style-on-change**" (not yet done). This is a concrete gap for R5/MovementManager: retail's real `unpack_movement` head does an `InqStyle`-vs-wire-style compare and issues a style `DoMotion` unconditionally (independent of movement type 0 vs 6-9), and acdream currently only applies style via the mt-0 `InboundInterpretedState.CurrentStyle` path — mt 6-9 events carry no style application at all today.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. PhysicsBody — `src/AcDream.Core/Physics/PhysicsBody.cs` (576 lines)
|
|
||||||
|
|
||||||
- **IsFullyConstrained (TS-35, doc calls it TS-35 but file comment says "R3-W3 stub")**: `public bool IsFullyConstrained { get; set; }` (L263), doc block L251-262. Retail `CPhysicsObj::IsFullyConstrained` (0x0050f730), read by `CMotionInterp::jump_is_allowed` (`if (IsFullyConstrained(physics_obj) != 0) return 0x47`). Comment: "acdream has no equivalent constraint-tracking yet... stubbed false (never fires) — a real port needs the per-cell shadow-list contact accounting the physics digest tracks." **Always false in production** (settable property, but nothing in the grep'd files sets it true).
|
|
||||||
- **TransientState bits available** (`TransientStateFlags`, L62-70, `[Flags] enum`):
|
|
||||||
- `Contact = 0x1` (bit 0) — touching any surface
|
|
||||||
- `OnWalkable = 0x2` (bit 1) — standing on walkable surface
|
|
||||||
- `Sliding = 0x4` (bit 2) — carry sliding normal into next transition
|
|
||||||
- `Active = 0x80` (bit 7) — needs per-frame update
|
|
||||||
- Convenience getters: `OnWalkable` (L286), `IsActive` (L287), `InContact` (L288) properties on `PhysicsBody`.
|
|
||||||
- **PositionManager**: **does not exist as a class anywhere in the repo.** Grep for `PositionManager` across `src/` returns zero hits outside doc comments referencing it as a future R5 port target (e.g. MoveToManager.cs:24, L109-112 doc, UpdateMotion.cs comment). `IsInterpolating` (the seam MoveToManager consumes) is currently satisfied by ad-hoc booleans: `rmT.Interp.IsActive` for remotes (GameWindow.cs:4282) and a hardcoded `false` for the player (GameWindow.cs:13001) — neither is a real PositionManager.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Test files (locations, not full case enumeration)
|
|
||||||
|
|
||||||
**MoveToManager suite** — `tests/AcDream.Core.Tests/Physics/Motion/`:
|
|
||||||
- `MoveToManagerTestHarness.cs` — shared harness/fixture
|
|
||||||
- `MoveToManagerLifecycleTests.cs`
|
|
||||||
- `MoveToManagerNodePlanTests.cs`
|
|
||||||
- `MoveToManagerBeginMoveForwardTests.cs`
|
|
||||||
- `MoveToManagerAuxTurnTests.cs`
|
|
||||||
- `MoveToManagerArrivalAndProgressTests.cs`
|
|
||||||
- `MoveToManagerTurnToHeadingTests.cs`
|
|
||||||
- `MoveToManagerHandleUpdateTargetTests.cs`
|
|
||||||
- `MoveToManagerStickyAndCancelTests.cs`
|
|
||||||
- `MoveToManagerUseTimeGateTests.cs`
|
|
||||||
- `MoveToManagerEndToEndTableDriveTests.cs`
|
|
||||||
- `MoveToManagerCompletionSeamTests.cs`
|
|
||||||
- `MovementTypeWideningTests.cs`
|
|
||||||
- `MoveToMathCylinderDistanceTests.cs`
|
|
||||||
- `MotionTableDispatchSinkTests.cs`
|
|
||||||
- `InterpretedMotionStateActionFifoTests.cs`
|
|
||||||
|
|
||||||
**Funnel / 183-case conformance suite**:
|
|
||||||
- `tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs` (35+) — the live-retail-observer-cdb-trace conformance harness ("183/183" cited in MEMORY.md refers to this file's case count). Contains `LoginQueue_DrainsToEmpty_UnderProductionFeed` at **line 279** (`[Fact]`).
|
|
||||||
- `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs`
|
|
||||||
- `tests/AcDream.Core.Tests/Physics/Motion/` (see above — dispatch-adjacent)
|
|
||||||
|
|
||||||
**DispatchInterpreted / MotionInterpreter core suite** — `tests/AcDream.Core.Tests/Physics/`:
|
|
||||||
- `MotionInterpreterTests.cs`
|
|
||||||
- `MotionInterpreterDoMotionFamilyTests.cs`
|
|
||||||
- `MotionInterpreterGroundLifecycleTests.cs`
|
|
||||||
- `MotionInterpreterJumpFamilyTests.cs`
|
|
||||||
- `MotionInterpreterPendingMotionsTests.cs`
|
|
||||||
- `ServerControlledLocomotionTests.cs`
|
|
||||||
- `RemoteWeenieRunRateTests.cs`
|
|
||||||
- `InWorldLinkGuardTests.cs`
|
|
||||||
- `MotionNormalizationTests.cs`
|
|
||||||
- `MotionVelocityPipelineTests.cs`
|
|
||||||
- `AnimationSequencerCutoverTraceTests.cs`
|
|
||||||
- `WeenieErrorCodeTableTests.cs`
|
|
||||||
|
|
||||||
**Player moveto cutover**:
|
|
||||||
- `tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary answers to the task's specific questions
|
|
||||||
|
|
||||||
**Q4 (UpdateMotion sticky/longjump)**: 0x1 (StickToObject) trailer IS parsed (`UpdateMotion.cs:279-295`, code at 290-293) into `stickyObjectGuid` → `ServerMotionState.StickyObjectGuid`; 0x2 (StandingLongJump) is documented but the bit is never even read. **Neither has a production consumer** — grep-wide, `StickyObjectGuid` is read only by unit tests; `MotionFlags.StandingLongJump`/`0x2` has zero non-comment hits anywhere.
|
|
||||||
|
|
||||||
**Q5 (type 6-9 head-stance dispatch)**: No such dispatch exists. `RouteServerMoveTo` (GameWindow.cs:4361) is the shared funnel for both player and remote; it never touches style/stance. `MoveToManager.PerformMovement`/its four entry points never call `DoMotion(style)`. Style application only happens on the mt-0 path via `InboundInterpretedState.CurrentStyle` folded at GameWindow.cs:4867. `RetailObserverTraceConformanceTests.cs:33`'s exclusion comment explicitly flags the unpack-level style-on-change dispatch as **not yet wired** ("S3 wires the unpack-level style-on-change") — this is a known, documented gap, not an oversight this recon discovered fresh.
|
|
||||||
|
|
||||||
**Q6 (PhysicsBody / PositionManager)**: `IsFullyConstrained` is a settable bool stub, always false in practice (PhysicsBody.cs:263). `TransientStateFlags` has `Contact`/`OnWalkable`/`Sliding`/`Active` (PhysicsBody.cs:63-70). No `PositionManager` class exists anywhere in the repo — `IsInterpolating` is faked per-caller (`Interp.IsActive` for remotes, hardcoded `false` for the player).
|
|
||||||
|
|
||||||
**File path of the written map**: `C:/Users/erikn/AppData/Local/Temp/claude/C--Users-erikn-source-repos-acdream--claude-worktrees-vigorous-joliot-f0c3ad/dd264e44-6468-4b4b-8d17-a8b3b15991c9/scratchpad/r5/acdream-seams.md`
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
# ACE cross-reference: MovementManager / PositionManager / StickyManager / ConstraintManager / TargetManager
|
|
||||||
|
|
||||||
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics/Managers/`
|
|
||||||
All five files read in full (MovementManager.cs 216 lines, PositionManager.cs 132 lines,
|
|
||||||
StickyManager.cs 136 lines, ConstraintManager.cs 80 lines, TargetManager.cs 182 lines).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. MovementManager.cs (namespace `ACE.Server.Physics.Animation`)
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
| Field | Type | Initial value |
|
|
||||||
|---|---|---|
|
|
||||||
| `MotionInterpreter` | `MotionInterp` | null (set in ctor or lazily) |
|
|
||||||
| `MoveToManager` | `MoveToManager` | null (set in ctor or lazily) |
|
|
||||||
| `PhysicsObj` | `PhysicsObj` | null (set in ctor) |
|
|
||||||
| `WeenieObj` | `WeenieObject` | null (set in ctor) |
|
|
||||||
|
|
||||||
No timers, no quanta — this class is a pure facade/dispatcher with no owned state beyond the two sub-managers + back-refs. Confirms it's a **facade**, not a state machine itself.
|
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- `MovementManager()` — parameterless, all-null (default-constructed / deserialization shape).
|
|
||||||
- `MovementManager(PhysicsObj obj, WeenieObject wobj)` (L18-25) — sets `PhysicsObj`/`WeenieObj`, eagerly constructs BOTH `MotionInterpreter = new MotionInterp(obj, wobj)` and `MoveToManager = new MoveToManager(obj, wobj)`. Note: this differs from the lazy-init pattern used everywhere else in the file (see below) — the 2-arg ctor is the only path that eagerly builds both children.
|
|
||||||
|
|
||||||
### Methods (signature + mechanical summary)
|
|
||||||
|
|
||||||
- `CancelMoveTo(WeenieError error)` (L27-31) — null-guards `MoveToManager`, forwards to `MoveToManager.CancelMoveTo(error)`.
|
|
||||||
- `static Create(PhysicsObj obj, WeenieObject wobj)` (L33-36) — factory, `return new MovementManager(obj, wobj)`.
|
|
||||||
- `EnterDefaultState()` (L38-46) — early-returns if `PhysicsObj == null`. **Lazy-inits** `MotionInterpreter` via `MotionInterp.Create(PhysicsObj, WeenieObj)` if null, then calls `MotionInterpreter.enter_default_state()`. This is the canonical lazy-init pattern reused in 5 other methods below.
|
|
||||||
- `HandleEnterWorld()` (L48-52) — **entirely commented out** (`//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter)`). Dead stub in ACE — no-op.
|
|
||||||
- `HandleExitWorld()` (L54-58) — null-guards, forwards to `MotionInterpreter.HandleExitWorld()`. Does NOT touch `MoveToManager` (no `MoveToManager.HandleExitWorld` call exists in this class at all).
|
|
||||||
- `HandleUpdateTarget(TargetInfo targetInfo)` (L60-64) — null-guards `MoveToManager`, forwards to `MoveToManager.HandleUpdateTarget(targetInfo)`. **Only route into MoveToManager for target updates** — MotionInterpreter is not involved.
|
|
||||||
- `HitGround()` (L66-73) — forwards to BOTH `MotionInterpreter.HitGround()` AND `MoveToManager.HitGround()`, each separately null-guarded. Order: MotionInterpreter first, then MoveToManager.
|
|
||||||
- `InqInterpretedMotionState()` (L75-84) — lazy-init pattern (create + `enter_default_state()` if `PhysicsObj != null`), returns `MotionInterpreter.InterpretedState`.
|
|
||||||
- `InqRawMotionState()` (L86-95) — identical lazy-init pattern, returns `MotionInterpreter.RawState`.
|
|
||||||
- `IsMovingTo()` (L97-102) — `MoveToManager == null` → `false`, else `MoveToManager.is_moving_to()`.
|
|
||||||
- `LeaveGround()` (L104-110) — null-guarded forward to `MotionInterpreter.LeaveGround()` only. Comment `// NoticeHandler::RecvNotice_PrevSpellSection` — dead/no-op reference, no MoveToManager call (asymmetric vs `HitGround`).
|
|
||||||
- `MakeMoveToManager()` (L112-116) — if `MoveToManager == null`, `MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj)`.
|
|
||||||
- `MotionDone(uint motion, bool success)` (L118-122) — null-guarded forward to `MotionInterpreter.MotionDone(success)`. **Note: the `motion` parameter is accepted but never used/passed through** — only `success` reaches `MotionInterp.MotionDone`.
|
|
||||||
- `PerformMovement(MovementStruct mvs)` (L124-157) — dispatch hub:
|
|
||||||
- `PhysicsObj.set_active(true)` unconditionally first.
|
|
||||||
- `switch (mvs.Type)`:
|
|
||||||
- `RawCommand`, `InterpretedCommand`, `StopRawCommand`, `StopInterpretedCommand`, `StopCompletely` → lazy-init `MotionInterpreter` (create + enter_default_state), then `return MotionInterpreter.PerformMovement(mvs)`.
|
|
||||||
- `MoveToObject`, `MoveToPosition`, `TurnToObject`, `TurnToHeading` → lazy-init `MoveToManager` (create only, no default-state analog), then `return MoveToManager.PerformMovement(mvs)`.
|
|
||||||
- `default` → `return WeenieError.GeneralMovementFailure`.
|
|
||||||
- This is the **dispatch order** requested: motion-type enum branches to exactly one of the two subsystems, never both.
|
|
||||||
- `ReportExhaustion()` (L159-165) — null-guarded forward to `MotionInterpreter.ReportExhaustion()`. Comment `// NoticeHandler::RecvNotice_PrevSpellSelection` again (dead).
|
|
||||||
- `SetWeenieObject(WeenieObject wobj)` (L167-174) — sets `WeenieObj = wobj`, then propagates to both children if non-null: `MotionInterpreter.SetWeenieObject(wobj)`, `MoveToManager.SetWeenieObject(wobj)`.
|
|
||||||
- `UseTime()` (L176-179) — **only forwards to `MoveToManager.UseTime()`**. Does NOT call anything on `MotionInterpreter`. This is the per-tick pump entry for movement — MotionInterp presumably gets its ticks from elsewhere (not in this file).
|
|
||||||
- `get_minterp()` (L181-190) — lazy-init identical to `InqInterpretedMotionState`, returns `MotionInterpreter` itself (not a sub-state).
|
|
||||||
- `motions_pending()` (L195-200) — `MotionInterpreter == null` → `false`, else `MotionInterpreter.motions_pending()`. Doc comment recommends `PhysicsObj.IsAnimating` instead for perf (L192-194).
|
|
||||||
- `move_to_interpreted_state(InterpretedMotionState state)` (L202-211) — lazy-init pattern, then `MotionInterpreter.move_to_interpreted_state(state)`.
|
|
||||||
- `unpack_movement(object addr, uint size)` (L213) — **empty body**. Client-only wire-unpack stub; server doesn't need to unpack a movement buffer since it authors movement itself. This is the clearest ACE-adaptation marker in the file (see divergences).
|
|
||||||
|
|
||||||
### Divergences / ACE adaptations
|
|
||||||
1. **`unpack_movement` is a no-op** (L213) — retail client presumably deserializes a raw movement buffer here (used for network movement replay/interpolation); ACE server doesn't need this path since it's the authoritative source, not a consumer, of movement state. No comment explaining it, just an empty `{ }` body — a clear "removed client-only branch."
|
|
||||||
2. **`HandleEnterWorld` is fully commented out** (L48-52) — the notice-handler call for previous spell selection was presumably meaningful in the retail client's UI/notice pipeline; ACE has no client-side notice UI so it's dead.
|
|
||||||
3. `unused `motion` parameter in `MotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side.
|
|
||||||
4. Uses `ACE.Entity.Enum.WeenieError` (ACE server type) as the return type for `PerformMovement`/`CancelMoveTo` — this is ACE's message-to-client error enum, not something retail's internal MovementManager would return (server-specific plumbing for GameActionFailure-style responses).
|
|
||||||
5. No `Dispose`/serialization glue for save-to-DB; the two nested managers are always live objects, not lazily persisted — normal for a live-object emulator.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. PositionManager.cs (namespace `ACE.Server.Physics.Animation`)
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
| Field | Type | Initial value |
|
|
||||||
|---|---|---|
|
|
||||||
| `InterpolationManager` | `InterpolationManager` | null |
|
|
||||||
| `StickyManager` | `StickyManager` | null |
|
|
||||||
| `ConstraintManager` | `ConstraintManager` | null |
|
|
||||||
| `PhysicsObj` | `PhysicsObj` | null (set via ctor/`SetPhysicsObject`) |
|
|
||||||
|
|
||||||
All three sub-managers are lazily created on first real use (`MakeStickyManager`, `ConstrainTo`, `InterpolateTo`) — none are eagerly constructed in the ctor, unlike `MovementManager`'s eager 2-arg ctor.
|
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- `PositionManager()` — parameterless/default.
|
|
||||||
- `PositionManager(PhysicsObj obj)` (L15-18) — calls `SetPhysicsObject(obj)`.
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- **`AdjustOffset(AFrame frame, double quantum)`** (L20-28) — **the requested composition method.** Null-guards each of the three sub-managers independently and calls, **in this exact order**:
|
|
||||||
1. `InterpolationManager.adjust_offset(frame, quantum)`
|
|
||||||
2. `StickyManager.adjust_offset(frame, quantum)`
|
|
||||||
3. `ConstraintManager.adjust_offset(frame, quantum)`
|
|
||||||
|
|
||||||
All three mutate the SAME `frame` (an `AFrame`, passed by reference since it's a class/struct with mutable `Origin`) sequentially — each sub-adjustment composes on top of whatever the previous one wrote into `frame.Origin`/heading. This is a **strict, hard-coded pipeline order**: interpolation offset first, then sticky-follow offset, then constraint clamp — not a generic list of "adjusters." Constraint is explicitly LAST because `ConstraintManager.adjust_offset` scales/clamps `offset.Origin` based on what's already accumulated (it reads `offset.Origin.Length()` at the end to update `ConstraintPosOffset` — see ConstraintManager notes), i.e., it operates on the composed net displacement from both prior systems, not an independent contribution.
|
|
||||||
|
|
||||||
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L30-36) — lazy-inits `ConstraintManager` via `ConstraintManager.Create(PhysicsObj)` if null, forwards args to `ConstraintManager.ConstrainTo(...)`.
|
|
||||||
- `static Create(PhysicsObj physicsObj)` (L38-41) — factory.
|
|
||||||
- `GetStickyObjectID()` (L43-47) — `StickyManager == null` → `0`, else `StickyManager.TargetID`.
|
|
||||||
- `HandleUpdateTarget(TargetInfo targetInfo)` (L49-53) — null-guarded forward to `StickyManager.HandleUpdateTarget(targetInfo)` only (Constraint/Interpolation don't participate in target updates).
|
|
||||||
- `InterpolateTo(Position position, bool keepHeading)` (L55-61) — lazy-inits `InterpolationManager` via `InterpolationManager.Create(PhysicsObj)`, forwards to `InterpolationManager.InterpolateTo(position, keepHeading)`.
|
|
||||||
- `IsFullyConstrained()` (L63-69) — `ConstraintManager == null` → `false`, else `ConstraintManager.IsFullyConstrained()`.
|
|
||||||
- `IsInterpolating()` (L71-74) — `InterpolationManager != null && InterpolationManager.IsInterpolating()`.
|
|
||||||
- `MakeStickyManager()` (L76-80) — if `StickyManager == null`, `StickyManager = StickyManager.Create(PhysicsObj)`.
|
|
||||||
- `SetPhysicsObject(PhysicsObj obj)` (L82-91) — sets `PhysicsObj = obj`, propagates to all three sub-managers if non-null (each gets its own `SetPhysicsObject(obj)` call).
|
|
||||||
- `StickTo(uint objectID, float radius, float height)` (L93-99) — if `StickyManager == null`, calls `MakeStickyManager()` (note: goes through the helper, unlike `ConstrainTo`/`InterpolateTo` which inline their own lazy-init), then `StickyManager.StickTo(objectID, radius, height)`.
|
|
||||||
- `StopInterpolating()` (L101-105) — null-guarded forward.
|
|
||||||
- `Unconstrain()` (L107-111) — null-guarded forward to `ConstraintManager.Unconstrain()`.
|
|
||||||
- `Unstick()` (L113-117) — null-guarded forward, but calls `StickyManager.HandleExitWorld()` (NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally calls `ClearTarget()`).
|
|
||||||
- `UseTime()` (L119-129) — per-tick pump: forwards to all three sub-managers' `UseTime()` if non-null, in order Interpolation → Sticky → Constraint (same order as `AdjustOffset`).
|
|
||||||
|
|
||||||
### Divergences / ACE adaptations
|
|
||||||
- None visually flagged with comments — this class is pure composition/delegation, symmetric with how a client would implement it. No server-only branches visible. The `StickTo` method routing through `MakeStickyManager()` rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence.
|
|
||||||
- `HandleUpdateTarget` only routing to `StickyManager` (not `ConstraintManager` or `InterpolationManager`) matches the design: constraint following is by static target position/radius set once via `ConstrainTo`, not from live target updates; only sticky-follow needs live target position updates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. StickyManager.cs (namespace `ACE.Server.Physics.Animation`)
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
| Field | Type | Initial value |
|
|
||||||
|---|---|---|
|
|
||||||
| `TargetID` | `uint` | 0 |
|
|
||||||
| `TargetRadius` | `float` | 0 |
|
|
||||||
| `TargetPosition` | `Position` | null |
|
|
||||||
| `PhysicsObj` | `PhysicsObj` | null |
|
|
||||||
| `Initialized` | `bool` | false |
|
|
||||||
| `StickyTimeoutTime` | `double` | 0 |
|
|
||||||
| `StickyRadius` | `const float` | **0.3f** (L20) |
|
|
||||||
| `StickyTime` | `const float` | **1.0f** (L22) |
|
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- `StickyManager()` — default.
|
|
||||||
- `StickyManager(PhysicsObj obj)` (L28-31) — calls `SetPhysicsObject(obj)`.
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- `ClearTarget()` (L33-42) — early-return if `TargetID == 0`. Else: `TargetID = 0`, `Initialized = false`, then `PhysicsObj.clear_target()` and `PhysicsObj.cancel_moveto()`.
|
|
||||||
- `static Create(PhysicsObj obj)` (L44-47) — factory.
|
|
||||||
- `HandleExitWorld()` (L49-52) — calls `ClearTarget()`.
|
|
||||||
- `HandleUpdateTarget(TargetInfo targetInfo)` (L54-66) — guards `targetInfo.ObjectID != TargetID` → return (ignore stale/foreign updates). If `targetInfo.Status == TargetStatus.OK` → `Initialized = true`, `TargetPosition = targetInfo.TargetPosition`. Else if `TargetID != 0` → `ClearTarget()` (i.e., any non-OK status for our current target clears it).
|
|
||||||
- `SetPhysicsObject(PhysicsObj obj)` (L68-71) — trivial setter.
|
|
||||||
- **`StickTo(uint objectID, float targetRadius, float targetHeight)`** (L73-83) — if already targeting something (`TargetID != 0`), first `ClearTarget()`. Then:
|
|
||||||
- `TargetID = objectID`
|
|
||||||
- `Initialized = false`
|
|
||||||
- `TargetRadius = targetRadius`
|
|
||||||
- `StickyTimeoutTime = PhysicsTimer.CurrentTime + StickyTime` (i.e., `now + 1.0f`)
|
|
||||||
- `PhysicsObj.set_target(0, objectID, 0.5f, 0.5f)` — registers with the target-tracking system using **hard-coded radius=0.5f, quantum=0.5f** regardless of the `targetRadius`/`targetHeight` params passed in. **`targetHeight` parameter is accepted but never used anywhere in this method or class.**
|
|
||||||
- `UseTime()` (L85-89) — if `PhysicsTimer.CurrentTime > StickyTimeoutTime` → `ClearTarget()`. This is the sticky-target watchdog: if no target update refreshes `Initialized`/position before the 1-second timeout, drop the stick. (Note: nothing in this file resets `StickyTimeoutTime` on `HandleUpdateTarget` — it's set once in `StickTo` and never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a fresh `StickTo` call. This looks intentional given no other write-site exists.)
|
|
||||||
|
|
||||||
- **`adjust_offset(AFrame offset, double quantum)`** (L91-133) — **the requested sticky-position math.**
|
|
||||||
1. Guard: `PhysicsObj == null || TargetID == 0 || !Initialized` → return (no-op if not ready).
|
|
||||||
2. `target = PhysicsObj.GetObjectA(TargetID)` — resolve live object if in scope; `targetPosition = target == null ? TargetPosition : target.Position` (falls back to last-known cached `TargetPosition` if target object isn't locally resolvable — e.g., out of landblock range).
|
|
||||||
3. **Offset vector (world → local, flattened to XY):**
|
|
||||||
```
|
|
||||||
offset.Origin = PhysicsObj.Position.GetOffset(targetPosition);
|
|
||||||
offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin);
|
|
||||||
offset.Origin.Z = 0.0f;
|
|
||||||
```
|
|
||||||
i.e., compute the world-space vector from self to target, rotate it into the object's own local frame, then zero the vertical component — sticky-follow only steers horizontally.
|
|
||||||
4. **Distance computation:**
|
|
||||||
```
|
|
||||||
var radius = PhysicsObj.GetRadius();
|
|
||||||
var dist = Position.CylinderDistanceNoZ(radius, PhysicsObj.Position, TargetRadius, targetPosition) - StickyRadius;
|
|
||||||
```
|
|
||||||
`CylinderDistanceNoZ` = surface-to-surface horizontal distance between two cylinders (self radius vs `TargetRadius`), then subtract the **0.3f StickyRadius** constant — this yields how far past the "stick zone" (0.3m gap) the follower currently is; can be negative if already inside the desired gap.
|
|
||||||
5. **Normalize direction** (with small-vector guard):
|
|
||||||
```
|
|
||||||
if (Vec.NormalizeCheckSmall(ref offset.Origin))
|
|
||||||
offset.Origin = Vector3.Zero;
|
|
||||||
```
|
|
||||||
`NormalizeCheckSmall` normalizes in place and returns true if the vector was too small to normalize meaningfully (near-zero) — in that case zero it out entirely (don't chase jitter at near-zero range).
|
|
||||||
6. **Speed selection:**
|
|
||||||
```
|
|
||||||
var speed = 0.0f;
|
|
||||||
var minterp = PhysicsObj.get_minterp();
|
|
||||||
if (minterp != null)
|
|
||||||
speed = minterp.get_max_speed() * 5.0f;
|
|
||||||
|
|
||||||
if (speed < PhysicsGlobals.EPSILON)
|
|
||||||
speed = 15.0f;
|
|
||||||
```
|
|
||||||
Sticky-follow speed is **5× the object's own max movement speed** (so the follow catches up faster than normal walk/run speed would allow), falling back to a **hard-coded 15.0f** if no motion interpreter is available or computed speed is ~0.
|
|
||||||
7. **Delta-clamp to distance:**
|
|
||||||
```
|
|
||||||
var delta = speed * (float)quantum;
|
|
||||||
if (delta >= Math.Abs(dist))
|
|
||||||
delta = dist;
|
|
||||||
offset.Origin *= delta;
|
|
||||||
```
|
|
||||||
Standard "don't overshoot" clamp: proposed per-quantum step is `speed * quantum`; if that step would travel farther than the remaining `dist` (in absolute value), snap the step to exactly `dist` instead (this can produce a *negative* delta scaling — meaning the offset direction gets inverted/scaled backward — when `dist` is negative, i.e., when already past the 0.3m sticky radius and needing to back off).
|
|
||||||
8. **Heading alignment:**
|
|
||||||
```
|
|
||||||
var curHeading = PhysicsObj.Position.Frame.get_heading();
|
|
||||||
var targetHeading = PhysicsObj.Position.heading(targetPosition);
|
|
||||||
var heading = targetHeading - curHeading;
|
|
||||||
if (Math.Abs(heading) < PhysicsGlobals.EPSILON) heading = 0.0f;
|
|
||||||
if (heading < -PhysicsGlobals.EPSILON) heading += 360.0f;
|
|
||||||
offset.set_heading(heading);
|
|
||||||
```
|
|
||||||
Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrapping `+360` (note: this wrap only triggers for deltas below `-EPSILON`, not a full `[-180,180]` normalize — deltas in e.g. `(-360, -epsilon)` all get `+360` added once, which is only a correct wrap if `heading` is already constrained to `(-360, 360)` by the subtraction of two `[0,360)` headings, which it is). Result is written into `offset.set_heading(heading)` — i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent with `offset` being a per-tick delta-frame consumed elsewhere, likely integrated by the caller).
|
|
||||||
- Dead commented-out diagnostic: `//Console.WriteLine($"StickyManager.AdjustOffset(...)")` (L131).
|
|
||||||
|
|
||||||
### Divergences / ACE adaptations
|
|
||||||
1. **`targetHeight` parameter of `StickTo` is entirely unused** — accepted into the signature (matches the client API surface presumably) but never read. Could be a client-only positional-height computation retail uses that ACE's server-authoritative model doesn't need (server just re-derives Z from the physics/terrain resolve, not from a fixed offset height).
|
|
||||||
2. **`set_target(0, objectID, 0.5f, 0.5f)` hard-codes context=0, radius=0.5f, quantum=0.5f** — the `targetRadius` argument passed into `StickTo` is stored in `TargetRadius` for use in `adjust_offset`'s distance math, but is NOT what's passed to `set_target`'s tracking-radius parameter; that's a separate fixed 0.5f. This looks like two distinct radii serving different purposes (voyeur/update-triggering radius vs. desired-follow-gap radius) rather than a bug, but it's worth flagging as a spot to verify against the retail decomp — ACE naming makes them look conflatable.
|
|
||||||
3. No explicit "ACE custom" comments in this file — the divergence is purely inferred from unused-parameter/hardcoded-constant patterns, not documented in-line.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ConstraintManager.cs (namespace `ACE.Server.Physics.Animation`)
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
| Field | Type | Initial value |
|
|
||||||
|---|---|---|
|
|
||||||
| `PhysicsObj` | `PhysicsObj` | null |
|
|
||||||
| `IsConstrained` | `bool` | false |
|
|
||||||
| `ConstraintPosOffset` | `float` | 0 |
|
|
||||||
| `ConstraintPos` | `Position` | null |
|
|
||||||
| `ConstraintDistanceStart` | `float` | 0 |
|
|
||||||
| `ConstraintDistanceMax` | `float` | 0 |
|
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- `ConstraintManager()` — default.
|
|
||||||
- `ConstraintManager(PhysicsObj obj)` (L17-20) — calls `SetPhysicsObject(obj)`.
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- `static Create(PhysicsObj obj)` (L22-25) — factory.
|
|
||||||
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L27-35) — sets `IsConstrained = true`; `ConstraintPos = new Position(position)` (deep copy); `ConstraintDistanceStart = startDistance`; `ConstraintDistanceMax = maxDistance`; **`ConstraintPosOffset = position.Distance(PhysicsObj.Position)`** — i.e., initializes the tracked offset to the CURRENT straight-line distance between the constraint anchor and the object's live position at the moment constraining begins (not zero).
|
|
||||||
- `IsFullyConstrained()` (L37-40) — `return ConstraintDistanceMax * 0.9f < ConstraintPosOffset;` — **"fully constrained" means the object's tracked offset has exceeded 90% of the max allowed distance.** This is a soft/early trigger, not requiring the offset to hit 100% of max.
|
|
||||||
- `SetPhysicsObject(PhysicsObj obj)` (L42-50) — if `PhysicsObj != null` (i.e., there was a previous object), reset `IsConstrained = false` and `ConstraintPosOffset = 0.0f` BEFORE reassigning `PhysicsObj = obj`. Net effect: constraint state is cleared whenever the manager is rebound to a (possibly different, possibly the same) physics object — except on the very first bind where `PhysicsObj` starts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway).
|
|
||||||
- `Unconstrain()` (L52-55) — `IsConstrained = false` only (does NOT reset `ConstraintPosOffset`, `ConstraintPos`, `ConstraintDistanceStart/Max` — those linger stale until the next `ConstrainTo` call).
|
|
||||||
- `UseTime()` (L57-60) — **empty body**, comment `// empty`. No time-based ticking logic at all in this manager (unlike Sticky's timeout-watchdog).
|
|
||||||
|
|
||||||
- **`adjust_offset(AFrame offset, double quantum)`** (L62-77) — **the requested constraint spring math.**
|
|
||||||
1. Guard: `PhysicsObj == null || !IsConstrained` → return.
|
|
||||||
2. **Contact-gated branch:**
|
|
||||||
```
|
|
||||||
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
|
|
||||||
{
|
|
||||||
if (ConstraintPosOffset < ConstraintDistanceMax)
|
|
||||||
{
|
|
||||||
if (ConstraintPosOffset > ConstraintDistanceStart)
|
|
||||||
offset.Origin *= (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
offset.Origin = Vector3.Zero;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
The scaling logic **only runs when the object is in ground/surface contact** (`TransientStateFlags.Contact`) — while airborne, this whole inner block is skipped and `offset.Origin` passes through completely unmodified from whatever `StickyManager`/`InterpolationManager` already wrote into it (constraint has no effect while not in contact).
|
|
||||||
|
|
||||||
When in contact:
|
|
||||||
- If current offset (`ConstraintPosOffset`, tracked from the PREVIOUS tick's final value — see step 3) is **≥ ConstraintDistanceMax** → hard-clamp: `offset.Origin = Vector3.Zero` (object cannot move further this tick at all — fully pinned).
|
|
||||||
- Else if offset is **< ConstraintDistanceMax**:
|
|
||||||
- If offset is **also > ConstraintDistanceStart** (i.e., in the "braking zone" between start and max) → scale the incoming `offset.Origin` (the proposed displacement already computed by prior pipeline stages) by the **linear falloff factor**:
|
|
||||||
```
|
|
||||||
(ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart)
|
|
||||||
```
|
|
||||||
This ranges from ~1.0 (when `ConstraintPosOffset` is just past `ConstraintDistanceStart`) down toward 0.0 (as `ConstraintPosOffset` approaches `ConstraintDistanceMax`) — a **linear spring/brake taper** that progressively resists outward motion as the object nears its max leash distance.
|
|
||||||
- If offset is **≤ ConstraintDistanceStart** (still well within the free-movement zone) → no scaling at all, `offset.Origin` passes through unchanged.
|
|
||||||
3. **State update (runs unconditionally, both when in contact and when airborne):**
|
|
||||||
```
|
|
||||||
ConstraintPosOffset = offset.Origin.Length();
|
|
||||||
```
|
|
||||||
**This line is outside the `if (Contact)` block** — meaning `ConstraintPosOffset` is recomputed EVERY call from the magnitude of the (possibly just-scaled) `offset.Origin`, not from the actual distance to `ConstraintPos`/anchor. This is notable: the tracked "offset" is a proxy — the length of the per-tick displacement vector — not a running total distance from the constraint anchor; it's being used as a same-tick feedback value that the NEXT call's contact-branch will compare against Start/Max. Given `AdjustOffset`'s pipeline order (Interpolation → Sticky → Constraint), this length is measuring the magnitude of the net per-tick offset produced by all prior systems, right before constraint clamps it — an odd quantity to call "ConstraintPosOffset" (it looks more like "last tick's step distance" than "distance from anchor"), but that's exactly what the code does — flag this if the acdream port needs to match it precisely; it's easy to misread as "distance from ConstraintPos."
|
|
||||||
|
|
||||||
### Divergences / ACE adaptations
|
|
||||||
- No explicit ACE-only comments; the whole file reads as a fairly literal, compact port. The main subtlety (not a divergence, but a correctness trap) is the `ConstraintPosOffset = offset.Origin.Length()` semantics noted above — this should be triple-checked against retail decomp before the acdream port trusts the "distance from constraint anchor" mental model that the field name suggests. `ConstraintPos` (the actual anchor position, L11) is stored but **never read anywhere in this file** after being set in `ConstrainTo` — it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere using `ConstraintPos`, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read of `PositionManager`/`ConstraintManager.ConstraintPos` — none found in this file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. TargetManager.cs (namespace `ACE.Server.Physics.Combat`)
|
|
||||||
|
|
||||||
Note: this one lives in the `Combat` namespace, not `Animation`, unlike the other four.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
| Field | Type | Initial value |
|
|
||||||
|---|---|---|
|
|
||||||
| `PhysicsObj` | `PhysicsObj` | null |
|
|
||||||
| `TargetInfo` | `TargetInfo` | null |
|
|
||||||
| `VoyeurTable` | `Dictionary<uint, TargettedVoyeurInfo>` | null (lazily created in `AddVoyeur`) |
|
|
||||||
| `LastUpdateTime` | `double` | 0 |
|
|
||||||
|
|
||||||
### Constructors
|
|
||||||
- `TargetManager()` — default.
|
|
||||||
- `TargetManager(PhysicsObj physObj)` (L18-21) — sets `PhysicsObj = physObj` directly (no `SetPhysicsObject` helper here, unlike the other four classes).
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
- `SetTarget(uint contextID, uint objectID, float radius, double quantum)` (L23-42) — null-guard `PhysicsObj`. Calls `ClearTarget()` first (always clears any existing target before setting a new one — no early-return-if-same-target check). If `objectID == 0` (clear/cancel request): builds a `TargetInfo` with `Status = TargetStatus.TimedOut` and calls `PhysicsObj.HandleUpdateTarget(failedTargetInfo)` directly, then returns (does NOT set `TargetInfo` field — leaves it null, i.e., "targeting nothing" is represented by `TargetInfo == null`). Otherwise: `TargetInfo = new TargetInfo(contextID, objectID, radius, quantum)`; resolves `target = PhysicsObj.GetObjectA(objectID)`; if resolvable, calls `target.add_voyeur(PhysicsObj.ID, radius, quantum)` — i.e., registers itself as a voyeur ON the target object (bidirectional relationship: I track it, and it tracks me watching it).
|
|
||||||
- `SetTargetQuantum(double quantum)` (L44-54) — null-guards `PhysicsObj`/`TargetInfo`. Updates `TargetInfo.Quantum = quantum`, resolves the target object, and if found, re-registers via `targetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)` (refreshes the voyeur registration on the target with the new quantum, keeping the stored `Radius`).
|
|
||||||
|
|
||||||
- **`HandleTargetting()`** (L56-79) — **the requested quantum-scheduling dispatcher** (note British-double-t spelling matches the ACE source literally). Guards:
|
|
||||||
1. `PhysicsObj == null` → return.
|
|
||||||
2. **Throttle: `PhysicsTimer.CurrentTime - LastUpdateTime < 0.5f` → return.** This whole method only actually runs its body once every **0.5 seconds of wall clock**, regardless of how often it's called (looks like it's called every physics tick from elsewhere, and self-throttles).
|
|
||||||
3. If `TargetInfo != null && TargetInfo.TargetPosition == null` → return (target info exists but has no resolved position yet — commented-out diagnostic `//Console.WriteLine("...null position")` at L64).
|
|
||||||
4. **Timeout check:** if `TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime` → mark `TargetInfo.Status = TargetStatus.TimedOut` and call `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` (passes a COPY, not the live reference — comment `// ref?` at L71 suggests the ACE porter was unsure whether retail passes by value or reference here). **10-second target-info staleness timeout**, separate from `StickyManager`'s 1-second timeout — these are two independently-tuned quanta for two different subsystems.
|
|
||||||
5. **Voyeur sweep:** if `VoyeurTable != null`, iterate `VoyeurTable.Values.ToList()` (materializes a copy of the values to iterate safely against in-loop mutation) and call `CheckAndUpdateVoyeur(voyeur)` for each.
|
|
||||||
6. Finally, `LastUpdateTime = PhysicsTimer.CurrentTime` — resets the 0.5s throttle window.
|
|
||||||
|
|
||||||
- `CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)` (L81-89) — **the requested per-voyeur quantum check.** `newPos = GetInterpolatedPosition(voyeur.Quantum)`; if non-null and `newPos.Distance(voyeur.LastSentPosition) > voyeur.Radius` → `SendVoyeurUpdate(voyeur, newPos, TargetStatus.OK)`. I.e., only pushes an update to a voyeur if the tracked object has moved farther than that voyeur's registered `Radius` threshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast.
|
|
||||||
- `GetInterpolatedPosition(double quantum)` (L91-98) — null-guards `PhysicsObj`. `pos = new Position(PhysicsObj.Position)` (deep copy); `pos.Frame.Origin += PhysicsObj.get_velocity() * (float)quantum` — **dead-reckoning extrapolation**: current position plus velocity times the requested lookahead quantum. This is the same "quantum" concept used throughout — it's a forward-prediction time horizon, not a physics tick delta.
|
|
||||||
- `ClearTarget()` (L100-111) — if `TargetInfo == null` → return. Else resolves the CURRENT target object and calls `targetObj.remove_voyeur(PhysicsObj.ID)` (un-registers self as a voyeur on the old target) — note this call is unconditional on `targetObj != null` check (guarded) but NOT unconditional on whether `TargetInfo.ObjectID` was actually valid; then sets `TargetInfo = null` (redundant inner null-check `if (TargetInfo != null)` immediately after already having checked `TargetInfo == null` at entry — harmless dead conditional, definitely true at that point).
|
|
||||||
- `NotifyVoyeurOfEvent(TargetStatus status)` (L113-119) — null-guards `PhysicsObj`/`VoyeurTable`. Broadcasts `SendVoyeurUpdate(voyeur, PhysicsObj.Position, status)` to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlike `CheckAndUpdateVoyeur`) — used for discrete events (e.g., death, teleport) rather than routine movement polling.
|
|
||||||
|
|
||||||
- **`ReceiveUpdate(TargetInfo update)`** (L121-136) — **the requested inbound-update handler** (the other half of the "ReceiveUpdate/CheckAndUpdateVoyeur" pair requested). Guards: `PhysicsObj == null || TargetInfo == null || TargetInfo.ObjectID != update.ObjectID` → return (ignore updates for a target we're not currently tracking, or if we have no target at all).
|
|
||||||
- `TargetInfo = new TargetInfo(update)` (copy-construct from the incoming update — comment `// ref?` again at L125, same porter uncertainty).
|
|
||||||
- `TargetInfo.LastUpdateTime = PhysicsTimer.CurrentTime` — stamps receipt time (used later by the 10-second-timeout check in `HandleTargetting`).
|
|
||||||
- **Heading computation:**
|
|
||||||
```
|
|
||||||
TargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition);
|
|
||||||
if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading))
|
|
||||||
TargetInfo.InterpolatedHeading = Vector3.UnitZ;
|
|
||||||
```
|
|
||||||
Computes the direction vector from self to the target's (already-interpolated-by-sender) position, normalizes it in place, and if the vector was too small to normalize (near-coincident positions), falls back to `Vector3.UnitZ` (a fixed "up" vector as a degenerate-case default — presumably any non-zero placeholder direction is acceptable when the target is essentially on top of the observer).
|
|
||||||
- `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` — forwards a COPY of the now-updated `TargetInfo` to the owning `PhysicsObj` (which presumably routes it onward to `PositionManager.HandleUpdateTarget` → `StickyManager.HandleUpdateTarget`, wiring back to file #3 above).
|
|
||||||
- If `update.Status == TargetStatus.ExitWorld` → `ClearTarget()` (target left the world; stop tracking it entirely, in addition to whatever `HandleUpdateTarget` propagation already did).
|
|
||||||
|
|
||||||
- `AddVoyeur(uint objectID, float radius, double quantum)` (L138-157) — if `VoyeurTable != null`, try to find an existing entry for `objectID`; if found, just update its `Radius`/`Quantum` in place and return early (no duplicate entries, no re-send of an initial update on refresh). Else (table doesn't exist yet), `VoyeurTable = new Dictionary<...>()`. Creates a new `TargettedVoyeurInfo(objectID, radius, quantum)`, adds it to the table, and **immediately** calls `SendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)` — new voyeurs get an immediate initial position push (not gated by the distance threshold that `CheckAndUpdateVoyeur` applies for routine updates).
|
|
||||||
- `SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)` (L159-172) — sets `voyeur.LastSentPosition = new Position(pos)` (deep copy, records what was just sent for future delta-threshold comparisons — this is what `CheckAndUpdateVoyeur` compares `newPos.Distance(...)` against). Builds an outbound `TargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)` (contextID hard-coded to 0), sets `TargetPosition = PhysicsObj.Position` (current authoritative position), `InterpolatedPosition = new Position(pos)` (the possibly-extrapolated position that triggered/represents this update), `Velocity = PhysicsObj.get_velocity()`, `Status = status`. Resolves the voyeur's own physics object (`GetObjectA(voyeur.ObjectID)`) and if found, calls `voyeurObj.receive_target_update(info)` — this is the reverse-direction hop that presumably lands back in the voyeur's own `TargetManager.ReceiveUpdate`.
|
|
||||||
- `RemoveVoyeur(uint objectID)` (L174-179) — `VoyeurTable == null` → `false`, else `VoyeurTable.Remove(objectID)` (dictionary's own bool-return removal).
|
|
||||||
|
|
||||||
### Divergences / ACE adaptations
|
|
||||||
1. **Two independent "// ref?" comments** (L71, L125) mark spots where the ACE porter was unsure whether retail passes `TargetInfo` by reference or performs a defensive copy — ACE chose copy-construct (`new TargetInfo(...)`) in both cases as the conservative/safe choice for a multi-threaded server. This is the clearest **explicit uncertainty marker** in the whole set of five files — flag as a spot acdream should verify against the retail decomp directly rather than trust ACE's guess, since ACE itself flagged its own uncertainty.
|
|
||||||
2. **The voyeur pattern is inherently server-authoritative plumbing** — `SendVoyeurUpdate`/`receive_target_update`/bidirectional `add_voyeur`/`remove_voyeur` registration between two `PhysicsObj`s is exactly the kind of "who-is-watching-whom" bookkeeping a server needs to decide what to push to which client-controlled object, whereas a retail single-player-perspective client would only need to track ITS OWN target, not maintain a reverse table of watchers. This entire voyeur/broadcast half of the class is very plausibly ACE-server infrastructure sitting alongside a more literally-ported "my own target" half (`SetTarget`/`ReceiveUpdate`/`HandleTargetting`'s timeout logic). No comment marks this explicitly, but structurally it's the load-bearing "adaptation" in this file — acdream (a client) likely needs ONLY the target-tracking half (`SetTarget`, `ReceiveUpdate`, the 10s timeout, `GetInterpolatedPosition`), not the voyeur/broadcast half, UNLESS acdream's server-facing code also needs to originate voyeur registrations (unlikely for a pure client).
|
|
||||||
3. `HandleTargetting`'s two independently-tuned timing constants — **0.5f throttle** (L60) and **10.0f staleness timeout** (L68) — are worth citing precisely if porting; these are likely real retail-tuned quanta, not ACE inventions, but should be cross-checked against `docs/research/named-retail/` before treating as ground truth given the file's own admitted uncertainty elsewhere.
|
|
||||||
4. Namespace: this class lives under `ACE.Server.Physics.Combat`, while all four other classes here live under `ACE.Server.Physics.Animation` — matches retail's likely split (targeting/voyeur being combat/perception machinery vs. movement/position being animation machinery), not an ACE-specific reorganization, but worth preserving that namespace distinction in acdream's own port structure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-class composition notes (for the R5 MovementManager/PositionManager facade work)
|
|
||||||
|
|
||||||
- **Dispatch chain confirmed:** `MovementManager.PerformMovement` branches by `MovementStruct.Type` into either `MotionInterp.PerformMovement` (raw/interpreted commands) or `MoveToManager.PerformMovement` (MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file.
|
|
||||||
- **`MovementManager.UseTime()` only pumps `MoveToManager`** — `MotionInterp` presumably ticks via a different call site (not shown in these 5 files) — do not assume `MovementManager.UseTime` is the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained to `MoveToManager`-adjacent code, and this file confirms `MoveToManager.UseTime()` is exactly one call, unconditioned, from `MovementManager.UseTime()`.
|
|
||||||
- **`PositionManager.AdjustOffset` and `PositionManager.UseTime` share the identical fixed pipeline order**: Interpolation → Sticky → Constraint. Any acdream port of `PositionManager` MUST preserve this order — `ConstraintManager.adjust_offset`'s scaling operates on the ALREADY-composed `offset.Origin` written by the two prior stages (confirmed by reading `ConstraintManager.adjust_offset` itself: it treats incoming `offset.Origin` as pre-populated and only clamps/scales it, never zeroes-then-rebuilds it).
|
|
||||||
- **Quantum is used with (at least) three distinct meanings across these files** — worth flagging for the R5 doc's "quantum model" section:
|
|
||||||
1. `AdjustOffset(AFrame frame, double quantum)` — a per-tick **time delta** (seconds since last adjustment), used identically by all three sub-adjusters as a `speed * quantum = distance` integration step.
|
|
||||||
2. `TargetInfo.Quantum` / `TargettedVoyeurInfo.Quantum` / `SetTarget(..., double quantum)` — a **lookahead/extrapolation horizon** fed into `GetInterpolatedPosition(quantum)` for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta).
|
|
||||||
3. Implicit "throttle interval" constants (`0.5f` in `HandleTargetting`, `1.0f` `StickyTime`, `10.0f` staleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table.
|
|
||||||
|
|
@ -1,679 +0,0 @@
|
||||||
# ConstraintManager — retail decomp extract
|
|
||||||
|
|
||||||
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named).
|
|
||||||
Struct source: `docs/research/named-retail/acclient.h`.
|
|
||||||
|
|
||||||
**Address correction:** the task listed `CPhysicsObj::IsFullyConstrained` at `0x0050f730`.
|
|
||||||
The actual address in the corpus is **`0x0050ec60`**. Verified by grepping the definition
|
|
||||||
line (`276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)`)
|
|
||||||
and cross-checked against its caller in `CMotionInterp::jump_is_allowed` at `0x005282fd`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## struct ConstraintManager (acclient.h, comment `/* 3467 */`)
|
|
||||||
|
|
||||||
```c
|
|
||||||
/* 3467 */
|
|
||||||
struct __cppobj ConstraintManager
|
|
||||||
{
|
|
||||||
CPhysicsObj *physics_obj;
|
|
||||||
int is_constrained;
|
|
||||||
float constraint_pos_offset;
|
|
||||||
Position constraint_pos;
|
|
||||||
float constraint_distance_start;
|
|
||||||
float constraint_distance_max;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## struct PositionManager (acclient.h, comment `/* 3468 */`) — the owning object
|
|
||||||
|
|
||||||
```c
|
|
||||||
/* 3468 */
|
|
||||||
struct __cppobj PositionManager
|
|
||||||
{
|
|
||||||
InterpolationManager *interpolation_manager;
|
|
||||||
StickyManager *sticky_manager;
|
|
||||||
ConstraintManager *constraint_manager;
|
|
||||||
CPhysicsObj *physics_obj;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Note the field order in `PositionManager` (`interpolation_manager`, `sticky_manager`,
|
|
||||||
`constraint_manager`, `physics_obj`) matches the `PositionManager::Create` allocation
|
|
||||||
writes to offsets `0x0`, `0x4`, `0x8`, `0xc` respectively (see below) and the
|
|
||||||
`PositionManager::Destroy` teardown order (`interpolation_manager` → `sticky_manager` →
|
|
||||||
`constraint_manager`).
|
|
||||||
|
|
||||||
`ConstraintManager` field layout maps onto `ConstraintManager::Create`'s raw offset
|
|
||||||
writes: `physics_obj`=`0x0`, `is_constrained`=`0x4`, `constraint_pos_offset`=`0x8` — wait,
|
|
||||||
per the decompiled writes below the offsets are actually `0x0`/`0x4`/`0x8`/`0xc`
|
|
||||||
(`vtable` field of `Position constraint_pos` at `0xc`)/`0x10`.../`0x48` is
|
|
||||||
`constraint_distance_start`, `0x4c` is `constraint_distance_max`. The compiler emits a
|
|
||||||
`Position` (which itself embeds a `Frame` with its own vtable-looking sentinel field —
|
|
||||||
see NOTE in `~ConstraintManager` below) between `constraint_pos_offset` and
|
|
||||||
`constraint_distance_start`, consistent with the struct's declared member order.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ConstraintManager::SetPhysicsObject — `0x00556090`
|
|
||||||
|
|
||||||
```c
|
|
||||||
00556090 void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2)
|
|
||||||
|
|
||||||
00556090 {
|
|
||||||
00556096 if (this->physics_obj == 0)
|
|
||||||
00556096 {
|
|
||||||
005560ad this->physics_obj = arg2;
|
|
||||||
005560af return;
|
|
||||||
00556096 }
|
|
||||||
00556096
|
|
||||||
00556098 this->physics_obj = 0;
|
|
||||||
0055609a this->is_constrained = 0;
|
|
||||||
0055609d this->constraint_pos_offset = 0f;
|
|
||||||
005560a4 this->physics_obj = arg2;
|
|
||||||
00556090 }
|
|
||||||
```
|
|
||||||
|
|
||||||
## ConstraintManager::UnConstrain — `0x005560c0`
|
|
||||||
|
|
||||||
```c
|
|
||||||
005560c0 void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this)
|
|
||||||
|
|
||||||
005560c0 {
|
|
||||||
005560c0 this->is_constrained = 0;
|
|
||||||
005560c0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
## ConstraintManager::IsFullyConstrained — `0x005560d0`
|
|
||||||
|
|
||||||
```c
|
|
||||||
005560d0 int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this)
|
|
||||||
|
|
||||||
005560d0 {
|
|
||||||
005560d0 long double x87_r7 = ((long double)this->constraint_pos_offset);
|
|
||||||
005560d6 long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002));
|
|
||||||
005560dc (x87_r6_1 - x87_r7);
|
|
||||||
005560de int32_t eax;
|
|
||||||
005560de eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0))));
|
|
||||||
005560e0 bool p = /* bool p = unimplemented {test ah, 0x5} */;
|
|
||||||
005560e0
|
|
||||||
005560e3 if (p)
|
|
||||||
005560ed return 0;
|
|
||||||
005560ed
|
|
||||||
005560ea return 1;
|
|
||||||
005560d0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
NOTE (garbled bitfield mush / x87 flags mush): the `eax = (...)` line is Binary Ninja's
|
|
||||||
attempt to render the x87 `FCOMI`/`FSTSW`+`SAHF`-style compare-and-test-flags sequence as
|
|
||||||
bit-packed pseudocode. It is computing `constraint_distance_max * 0.9 <=> constraint_pos_offset`
|
|
||||||
and then `test ah, 0x5` checks the ZF/CF-equivalent bits packed into `ah` after `fnstsw ax`.
|
|
||||||
The semantic read: `p` is true when `(constraint_distance_max * 0.9) < constraint_pos_offset`
|
|
||||||
OR the compare was unordered (NaN) — i.e. `test ah,5` tests bits 0 (C0/"below") and 2
|
|
||||||
(C3/"equal") of the FPU status word as loaded into AH, the classic x87 `jbe`-equivalent
|
|
||||||
pattern. So: **`IsFullyConstrained` returns `false` (0) if `constraint_pos_offset >=
|
|
||||||
0.9 * constraint_distance_max` (or unordered), else returns `true` (1)**. In plain terms:
|
|
||||||
the object counts as "fully constrained" while it is still within 90% of the max leash
|
|
||||||
distance; once it has drifted past 90% of that distance it is no longer "fully" constrained
|
|
||||||
(this is the gate `CMotionInterp::jump_is_allowed` reads to block jump attempts while
|
|
||||||
straining at the very end of a constraint leash).
|
|
||||||
|
|
||||||
## ConstraintManager::~ConstraintManager — `0x005560f0`
|
|
||||||
|
|
||||||
```c
|
|
||||||
005560f0 void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this)
|
|
||||||
|
|
||||||
005560f0 {
|
|
||||||
005560f2 this->is_constrained = 0;
|
|
||||||
005560f5 this->constraint_pos_offset = 0f;
|
|
||||||
005560f8 this->physics_obj = 0;
|
|
||||||
005560fa this->constraint_pos.vtable = 0x79285c;
|
|
||||||
005560f0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
NOTE: `this->constraint_pos.vtable = 0x79285c` — `constraint_pos` is a `Position` field
|
|
||||||
(struct member, not a pointer), so this is Binary Ninja's rendering of the embedded
|
|
||||||
`Frame`'s vtable-pointer slot being reset to its static vtable address as part of the
|
|
||||||
`Position`/`Frame` subobject's implicit destructor inlining. Not a real "vtable swap";
|
|
||||||
just the compiler zeroing/resetting the embedded Frame's identity field during teardown.
|
|
||||||
|
|
||||||
## ConstraintManager::Create — `0x00556110` (factory)
|
|
||||||
|
|
||||||
```c
|
|
||||||
00556110 class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1)
|
|
||||||
|
|
||||||
00556110 {
|
|
||||||
00556114 void* result = operator new(0x5c);
|
|
||||||
00556114
|
|
||||||
00556122 if (result == 0)
|
|
||||||
00556177 return 0;
|
|
||||||
00556177
|
|
||||||
00556124 *(uint32_t*)result = 0;
|
|
||||||
00556126 *(uint32_t*)((char*)result + 4) = 0;
|
|
||||||
00556129 *(uint32_t*)((char*)result + 8) = 0;
|
|
||||||
0055612f *(uint32_t*)((char*)result + 0xc) = 0x796910;
|
|
||||||
00556136 *(uint32_t*)((char*)result + 0x10) = 0;
|
|
||||||
00556139 *(uint32_t*)((char*)result + 0x14) = 0x3f800000;
|
|
||||||
0055613f *(uint32_t*)((char*)result + 0x18) = 0;
|
|
||||||
00556142 *(uint32_t*)((char*)result + 0x1c) = 0;
|
|
||||||
00556145 *(uint32_t*)((char*)result + 0x20) = 0;
|
|
||||||
00556148 *(uint32_t*)((char*)result + 0x48) = 0;
|
|
||||||
0055614b *(uint32_t*)((char*)result + 0x4c) = 0;
|
|
||||||
0055614e *(uint32_t*)((char*)result + 0x50) = 0;
|
|
||||||
00556151 Frame::cache(((char*)result + 0x14));
|
|
||||||
00556156 *(uint32_t*)((char*)result + 0x54) = 0;
|
|
||||||
00556159 *(uint32_t*)((char*)result + 0x58) = 0;
|
|
||||||
00556159
|
|
||||||
0055615e if (*(uint32_t*)result != 0)
|
|
||||||
0055615e {
|
|
||||||
00556160 *(uint32_t*)((char*)result + 4) = 0;
|
|
||||||
00556163 *(uint32_t*)((char*)result + 8) = 0;
|
|
||||||
00556166 *(uint32_t*)result = 0;
|
|
||||||
0055615e }
|
|
||||||
0055615e
|
|
||||||
0055616c *(uint32_t*)result = arg1;
|
|
||||||
00556172 return result;
|
|
||||||
00556110 }
|
|
||||||
```
|
|
||||||
|
|
||||||
NOTE: allocation is `0x5c` (92) bytes — `sizeof(ConstraintManager)`. Field-offset mapping
|
|
||||||
against the struct decl: `+0x0`=`physics_obj`, `+0x4`=`is_constrained`,
|
|
||||||
`+0x8`=`constraint_pos_offset`, `+0xc..0x50`=`constraint_pos` (embedded `Position`, whose
|
|
||||||
own `objcell_id`/`frame` subfields explain the `0x796910` vtable-like constant at `+0xc`
|
|
||||||
and the `Frame::cache` call seeding the rotation quaternion identity `w=1.0`
|
|
||||||
i.e. `0x3f800000` at `+0x14`), `+0x48`=`constraint_distance_start`,
|
|
||||||
`+0x4c`=`constraint_distance_max`. The trailing `+0x54`/`+0x58` zeroing is past the
|
|
||||||
declared struct fields in the header excerpt we have — likely padding/alignment or a
|
|
||||||
field the header comment block truncated; not load-bearing for the port (all consumed
|
|
||||||
fields — `physics_obj`, `is_constrained`, `constraint_pos_offset`, `constraint_pos`,
|
|
||||||
`constraint_distance_start`, `constraint_distance_max` — are accounted for).
|
|
||||||
|
|
||||||
The odd `if (*(uint32_t*)result != 0) { zero everything }` right after construction reads
|
|
||||||
as dead/defensive code from an inlined check that can't actually trigger here (the fields
|
|
||||||
were all just zeroed above) — flagging as NOTE, not altering.
|
|
||||||
|
|
||||||
## ConstraintManager::adjust_offset — `0x00556180`
|
|
||||||
|
|
||||||
```c
|
|
||||||
00556180 void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3)
|
|
||||||
|
|
||||||
00556180 {
|
|
||||||
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
|
|
||||||
00556186
|
|
||||||
0055618a if (physics_obj != 0)
|
|
||||||
0055618a {
|
|
||||||
00556190 int32_t is_constrained = this->is_constrained;
|
|
||||||
00556190
|
|
||||||
00556195 if (is_constrained != 0)
|
|
||||||
00556195 {
|
|
||||||
005561a7 if ((physics_obj->transient_state & 1) != 0)
|
|
||||||
005561a7 {
|
|
||||||
005561a9 long double x87_r7_1 = ((long double)this->constraint_pos_offset);
|
|
||||||
005561ac long double temp1_1 = ((long double)this->constraint_distance_max);
|
|
||||||
005561ac (x87_r7_1 - temp1_1);
|
|
||||||
005561af physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0))));
|
|
||||||
005561af
|
|
||||||
005561b4 if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0)
|
|
||||||
005561b4 {
|
|
||||||
005561e7 long double x87_r7_2 = ((long double)this->constraint_pos_offset);
|
|
||||||
005561ea long double temp2_1 = ((long double)this->constraint_distance_start);
|
|
||||||
005561ea (x87_r7_2 - temp2_1);
|
|
||||||
005561ed physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0))));
|
|
||||||
005561ef bool p_1 = /* bool p_1 = unimplemented {test ah, 0x41} */;
|
|
||||||
005561ef
|
|
||||||
005561f2 if (p_1)
|
|
||||||
005561f2 {
|
|
||||||
005561f7 int32_t is_constrained_1 = is_constrained;
|
|
||||||
00556209 Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start)))));
|
|
||||||
005561f2 }
|
|
||||||
005561b4 }
|
|
||||||
005561b4 else
|
|
||||||
005561b4 {
|
|
||||||
005561c2 arg2->m_fOrigin.x = 0;
|
|
||||||
005561c2 arg2->m_fOrigin.y = 0f;
|
|
||||||
005561c2 arg2->m_fOrigin.z = 0f;
|
|
||||||
005561b4 }
|
|
||||||
005561a7 }
|
|
||||||
005561a7
|
|
||||||
0055620e arg2->m_fOrigin;
|
|
||||||
00556211 arg2->m_fOrigin;
|
|
||||||
00556233 this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset)));
|
|
||||||
00556195 }
|
|
||||||
0055618a }
|
|
||||||
00556180 }
|
|
||||||
```
|
|
||||||
|
|
||||||
NOTE (garbled bitfield mush + BN variable-reuse artifact): `physics_obj` gets *reused* as
|
|
||||||
a scratch int32 to hold the packed x87 comparison-flags value at `005561af` and again at
|
|
||||||
`005561ed` — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the
|
|
||||||
`CPhysicsObj*` pointer. The `this->physics_obj` local captured at `00556186` is what's
|
|
||||||
actually read at `005561a7` (`physics_obj->transient_state`) and `005561b4`
|
|
||||||
(`*(uint8_t*)((char*)physics_obj)[1] & 1` — this is checking a byte of `transient_state`
|
|
||||||
again, offset `+1`, i.e. a second flag byte inside the same bitfield/word). Do not port
|
|
||||||
the reused `physics_obj` int32 as if it becomes a different physics object; it's the same
|
|
||||||
pointer, just overwritten as dead-value scratch space by the decompiler's register
|
|
||||||
allocator view.
|
|
||||||
|
|
||||||
`bool p_1 = /* unimplemented {test ah, 0x41} */` is the same x87-flags-in-AH pattern as
|
|
||||||
`IsFullyConstrained` above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on
|
|
||||||
encoding) — the classic `jbe`-vs-`jae` variant. Given the surrounding compare
|
|
||||||
(`constraint_pos_offset < constraint_distance_start` or equal), and that the guarded body
|
|
||||||
computes a **lerp fraction** `(constraint_distance_max - constraint_pos_offset) /
|
|
||||||
(constraint_distance_max - constraint_distance_start)` applied to `arg2->m_fOrigin` via
|
|
||||||
`*=`, the semantic read is: **when the object has NOT yet reached (or has just reached)
|
|
||||||
`constraint_distance_start`, scale the frame's offset delta by how far through the
|
|
||||||
start→max leash band the object currently sits** (a ramp/taper multiplier — presumably
|
|
||||||
smoothly reducing how much of the requested frame delta gets applied as the leash
|
|
||||||
tightens). `test ah,0x41` semantically reads as "less-than-or-unordered-or-equal"
|
|
||||||
(`p_1` true when NOT clearly greater), so the ramp only applies while still inside the
|
|
||||||
band; once past `constraint_distance_start` typical port intent should skip the scale
|
|
||||||
(leave `arg2->m_fOrigin` alone) — consistent with the `else` branch two levels up which
|
|
||||||
zeroes `m_fOrigin` outright when `transient_state`'s second flag bit is clear.
|
|
||||||
|
|
||||||
Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check
|
|
||||||
if the port's leash-taper feel diverges from retail), the function's shape is:
|
|
||||||
1. No-op if no `physics_obj` or not `is_constrained`.
|
|
||||||
2. If `transient_state & 1` (some "active"/"in contact" style flag):
|
|
||||||
- If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta
|
|
||||||
`arg2->m_fOrigin` by a lerp fraction based on `(max - pos_offset) / (max - start)`,
|
|
||||||
gated by a comparison of `pos_offset` vs `constraint_distance_start`.
|
|
||||||
- Else: zero the incoming frame delta entirely (fully clamp movement).
|
|
||||||
3. Unconditionally (after the above), accumulate: `constraint_pos_offset +=
|
|
||||||
arg2->m_fOrigin.x` (note: only the `.x` component is added — `arg2->m_fOrigin` is read
|
|
||||||
twice at `0055620e`/`00556211` with no visible effect, likely a debug/no-op dead read
|
|
||||||
from the decompiler, or hints there's a per-component variant BN collapsed; only the
|
|
||||||
final `.x`-add survived as an observable store).
|
|
||||||
|
|
||||||
## ConstraintManager::ConstrainTo — `0x00556240`
|
|
||||||
|
|
||||||
```c
|
|
||||||
00556240 void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4)
|
|
||||||
|
|
||||||
00556240 {
|
|
||||||
00556248 this->is_constrained = 1;
|
|
||||||
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
|
|
||||||
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
|
|
||||||
00556271 this->constraint_distance_start = arg3;
|
|
||||||
00556274 this->constraint_distance_max = arg4;
|
|
||||||
0055627c this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position));
|
|
||||||
00556240 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Straightforward: pins the leash anchor (`constraint_pos` = copy of `arg2`'s cell+frame),
|
|
||||||
sets `is_constrained = true`, sets the start/max leash-band radii from `arg3`/`arg4`, and
|
|
||||||
initializes `constraint_pos_offset` to the CURRENT distance from the anchor to the
|
|
||||||
physics object's live position (`Position::distance(arg2, &physics_obj->m_position)`) —
|
|
||||||
i.e. the leash starts already "extended" to wherever the object presently is relative to
|
|
||||||
the constraint anchor, not to zero.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PositionManager-level seams (the actual public API — ConstraintManager is
|
|
||||||
## lazily-created and private underneath)
|
|
||||||
|
|
||||||
`ConstraintManager` is never touched directly from outside `PositionManager`.
|
|
||||||
`PositionManager` lazily creates it on first `ConstrainTo` call and forwards through it.
|
|
||||||
|
|
||||||
```c
|
|
||||||
00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3)
|
|
||||||
00555190 {
|
|
||||||
00555191 int32_t ebx = arg3;
|
|
||||||
0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager;
|
|
||||||
005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4];
|
|
||||||
005551a2
|
|
||||||
005551a6 if (interpolation_manager != 0)
|
|
||||||
005551a6 {
|
|
||||||
005551a8 int32_t var_14_1 = edi;
|
|
||||||
005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx);
|
|
||||||
005551a6 }
|
|
||||||
005551a6
|
|
||||||
005551b0 class StickyManager* sticky_manager = this->sticky_manager;
|
|
||||||
005551b0
|
|
||||||
005551b5 if (sticky_manager != 0)
|
|
||||||
005551b5 {
|
|
||||||
005551b7 int32_t var_14_2 = edi;
|
|
||||||
005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx);
|
|
||||||
005551b5 }
|
|
||||||
005551b5
|
|
||||||
005551bf class ConstraintManager* constraint_manager = this->constraint_manager;
|
|
||||||
005551bf
|
|
||||||
005551c4 if (constraint_manager != 0)
|
|
||||||
005551c4 {
|
|
||||||
005551c6 int32_t var_14_3 = edi;
|
|
||||||
005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx);
|
|
||||||
005551c4 }
|
|
||||||
00555190 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Chains ALL THREE sub-managers' `adjust_offset` in a fixed order:
|
|
||||||
`InterpolationManager` → `StickyManager` → `ConstraintManager`, each optional (only
|
|
||||||
called if that sub-manager exists). This is the per-frame(?) offset-adjustment
|
|
||||||
dispatcher `PositionManager` uses to let interpolation/sticky/constraint all have a say
|
|
||||||
in shaping a `Frame` delta before it's applied.
|
|
||||||
|
|
||||||
```c
|
|
||||||
00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4)
|
|
||||||
00555280 {
|
|
||||||
00555288 if (this->constraint_manager == 0)
|
|
||||||
00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj);
|
|
||||||
00555296
|
|
||||||
00555299 class ConstraintManager* constraint_manager = this->constraint_manager;
|
|
||||||
00555299
|
|
||||||
0055529f if (constraint_manager == 0)
|
|
||||||
005552a6 return;
|
|
||||||
005552a6
|
|
||||||
005552a1 /* tailcall */
|
|
||||||
005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4);
|
|
||||||
00555280 }
|
|
||||||
|
|
||||||
005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this)
|
|
||||||
005552b0 {
|
|
||||||
005552b0 class ConstraintManager* constraint_manager = this->constraint_manager;
|
|
||||||
005552b0
|
|
||||||
005552b5 if (constraint_manager == 0)
|
|
||||||
005552bc return;
|
|
||||||
005552bc
|
|
||||||
005552b7 /* tailcall */
|
|
||||||
005552b7 return ConstraintManager::UnConstrain(constraint_manager);
|
|
||||||
005552b0 }
|
|
||||||
|
|
||||||
005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this)
|
|
||||||
005552c0 {
|
|
||||||
005552c0 class ConstraintManager* constraint_manager = this->constraint_manager;
|
|
||||||
005552c0
|
|
||||||
005552c5 if (constraint_manager == 0)
|
|
||||||
005552ce return 0;
|
|
||||||
005552ce
|
|
||||||
005552c7 /* tailcall */
|
|
||||||
005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager);
|
|
||||||
005552c0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
`PositionManager::Create` (`0x005552d0`) wires a freshly-allocated `PositionManager`'s
|
|
||||||
`physics_obj` back-pointer into any already-non-null sub-managers (only relevant for
|
|
||||||
copy/re-init paths since a fresh `PositionManager` starts with all-null sub-managers):
|
|
||||||
|
|
||||||
```c
|
|
||||||
005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)
|
|
||||||
005552d0 {
|
|
||||||
005552d3 void* result = operator new(0x10);
|
|
||||||
...
|
|
||||||
0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8);
|
|
||||||
0055531d
|
|
||||||
00555322 if (ecx_2 != 0)
|
|
||||||
00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1);
|
|
||||||
00555325
|
|
||||||
0055532e return result;
|
|
||||||
005552d0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
`PositionManager::Destroy` (`0x00555340`) tears down and `delete`s all three
|
|
||||||
sub-managers, `ConstraintManager` last:
|
|
||||||
|
|
||||||
```c
|
|
||||||
00555340 void __fastcall PositionManager::Destroy(class PositionManager* this)
|
|
||||||
00555340 {
|
|
||||||
...
|
|
||||||
00555377 class ConstraintManager* constraint_manager = this->constraint_manager;
|
|
||||||
0055537c this->sticky_manager = nullptr;
|
|
||||||
0055537c
|
|
||||||
00555383 if (constraint_manager != 0)
|
|
||||||
00555383 {
|
|
||||||
00555387 ConstraintManager::~ConstraintManager(constraint_manager);
|
|
||||||
0055538d operator delete(constraint_manager);
|
|
||||||
00555383 }
|
|
||||||
00555383
|
|
||||||
00555396 this->constraint_manager = nullptr;
|
|
||||||
00555340 }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CPhysicsObj-level seams (public API callers actually use)
|
|
||||||
|
|
||||||
```c
|
|
||||||
0050ec10 void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this)
|
|
||||||
0050ec10 {
|
|
||||||
0050ec16 if (this == CPhysicsObj::player_object)
|
|
||||||
0050ec16 {
|
|
||||||
0050ec18 this->m_position;
|
|
||||||
0050ec2d return;
|
|
||||||
0050ec16 }
|
|
||||||
0050ec16
|
|
||||||
0050ec35 this->m_position;
|
|
||||||
0050ec10 }
|
|
||||||
|
|
||||||
0050ebc0 void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this)
|
|
||||||
0050ebc0 {
|
|
||||||
0050ebc6 if (this == CPhysicsObj::player_object)
|
|
||||||
0050ebc6 {
|
|
||||||
0050ebc8 this->m_position;
|
|
||||||
0050ebdd return;
|
|
||||||
0050ebc6 }
|
|
||||||
0050ebc6
|
|
||||||
0050ebe5 this->m_position;
|
|
||||||
0050ebc0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
NOTE (BN decompilation artifact / x87 return-value elision): both functions have `void`
|
|
||||||
signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're
|
|
||||||
called as `ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2)` immediately followed by
|
|
||||||
`(float)st0_6` casts at the call sites — an x87 FPU return value living in `st0` that
|
|
||||||
Binary Ninja failed to attach to the declared return type). Body-wise all we get is
|
|
||||||
`this->m_position;` as a bare expression on both the player-branch and fallthrough
|
|
||||||
paths — BN elided the actual field read/constant selection (this is likely
|
|
||||||
`this->m_position.something` or a per-type constant lookup that got collapsed to a
|
|
||||||
dead-looking statement). **This function needs a live cdb read of `st0` after the call,
|
|
||||||
or a Ghidra re-decompile with a corrected float-return signature, to recover the actual
|
|
||||||
values.** Given the call-site pattern (constraining the player and other movers to a
|
|
||||||
"home" position after teleport/movement-timeout in `SmartBox::HandleReceivedPosition`),
|
|
||||||
the two functions almost certainly return small fixed-radius constants (a "start easing"
|
|
||||||
radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case
|
|
||||||
(hence the `this == CPhysicsObj::player_object` branch in both). Do not guess the literal
|
|
||||||
values — flag as an open research item before porting numeric constants.
|
|
||||||
|
|
||||||
```c
|
|
||||||
00510520 void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4)
|
|
||||||
00510520 {
|
|
||||||
00510523 CPhysicsObj::MakePositionManager(this);
|
|
||||||
00510528 class PositionManager* position_manager = this->position_manager;
|
|
||||||
00510528
|
|
||||||
00510531 if (position_manager == 0)
|
|
||||||
00510538 return;
|
|
||||||
00510538
|
|
||||||
00510533 /* tailcall */
|
|
||||||
00510533 return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4);
|
|
||||||
00510520 }
|
|
||||||
```
|
|
||||||
|
|
||||||
`CPhysicsObj::ConstrainTo` lazily ensures a `PositionManager` exists
|
|
||||||
(`MakePositionManager`) then forwards. This is the entry point external code calls.
|
|
||||||
|
|
||||||
```c
|
|
||||||
0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this)
|
|
||||||
0050ec60 {
|
|
||||||
0050ec60 class PositionManager* position_manager = this->position_manager;
|
|
||||||
0050ec60
|
|
||||||
0050ec68 if (position_manager == 0)
|
|
||||||
0050ec71 return 0;
|
|
||||||
0050ec71
|
|
||||||
0050ec6a /* tailcall */
|
|
||||||
0050ec6a return PositionManager::IsFullyConstrained(position_manager);
|
|
||||||
0050ec60 }
|
|
||||||
```
|
|
||||||
|
|
||||||
(Address correction noted at top of doc: this is `0x0050ec60`, not the task-supplied
|
|
||||||
`0x0050f730`.)
|
|
||||||
|
|
||||||
There is no separate `CPhysicsObj::UnConstrain` — callers go straight to
|
|
||||||
`PositionManager::UnConstrain(this->position_manager)` (see caller list below); only
|
|
||||||
`ConstrainTo` and `IsFullyConstrained` got a `CPhysicsObj`-level convenience wrapper.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CALLERS — when does retail actually constrain an object?
|
|
||||||
|
|
||||||
### 1. `SmartBox::HandleReceivedPosition` (`0x00453fd0`) — THE constrain call site
|
|
||||||
|
|
||||||
All three live `CPhysicsObj::ConstrainTo` calls in the entire corpus are inside this one
|
|
||||||
function, at three different branches of its position-update-reconciliation logic:
|
|
||||||
|
|
||||||
**Branch A — non-player mover, after a successful move/teleport resolve (`0x00454254`):**
|
|
||||||
```c
|
|
||||||
0045414d if (arg2 != this->player)
|
|
||||||
0045414d {
|
|
||||||
00454254 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0)
|
|
||||||
00454254 {
|
|
||||||
00454258 int32_t ecx_26;
|
|
||||||
00454258 ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2);
|
|
||||||
0045425d int32_t var_68_14 = ecx_26;
|
|
||||||
00454263 ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2);
|
|
||||||
00454268 int32_t var_6c_9 = ecx_28;
|
|
||||||
00454272 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6));
|
|
||||||
00454254 }
|
|
||||||
00454254
|
|
||||||
00454254 return;
|
|
||||||
0045414d }
|
|
||||||
```
|
|
||||||
For a non-player object (`arg2`), once `MoveOrTeleport` succeeds, it is constrained
|
|
||||||
**to its own current position** (`&arg2->m_position` as the anchor) with start/max radii
|
|
||||||
from `GetStartConstraintDistance`/`GetMaxConstraintDistance`.
|
|
||||||
|
|
||||||
**Branch B — player, on a fresh TELEPORT timestamp event (`0x0045415f`):**
|
|
||||||
```c
|
|
||||||
0045415f if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0)
|
|
||||||
0045415f {
|
|
||||||
00454168 SmartBox::TeleportPlayer(this, &var_48);
|
|
||||||
0045416f ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2);
|
|
||||||
0045417a ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2);
|
|
||||||
0045418a CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1));
|
|
||||||
0045418f class CPhysicsObj* player_2 = this->player;
|
|
||||||
0045419c int32_t var_54 = 0; // zero vector
|
|
||||||
004541b4 CPhysicsObj::set_velocity(player_2, &var_54, 1);
|
|
||||||
004541c0 return;
|
|
||||||
0045415f }
|
|
||||||
```
|
|
||||||
On a server teleport of the local player, `SmartBox::TeleportPlayer` snaps position, then
|
|
||||||
the player is constrained **to the newly-received server position** (`&var_48`, the
|
|
||||||
decoded incoming `Position`), and velocity is zeroed.
|
|
||||||
|
|
||||||
**Branch C — player, fallthrough / non-teleport received-position path (`0x004541c9`):**
|
|
||||||
```c
|
|
||||||
004541c9 ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player);
|
|
||||||
004541d8 ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player);
|
|
||||||
004541ec CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4));
|
|
||||||
004541f1 class CommandInterpreter* cmdinterp_1 = this->cmdinterp;
|
|
||||||
0045420a if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0))
|
|
||||||
0045420a {
|
|
||||||
... CPhysicsObj::InterpolateTo(arg2, &var_48, ...);
|
|
||||||
```
|
|
||||||
Every OTHER received server position update for the local player (not a teleport-flagged
|
|
||||||
event) ALSO constrains the player to the received position (`&var_48`), and then —
|
|
||||||
depending on `UsePositionFromServer`/autonomy settings — may additionally kick off
|
|
||||||
`InterpolateTo`. So the leash gets re-anchored on essentially every server position
|
|
||||||
correction, whether or not interpolation is used to visually smooth toward it.
|
|
||||||
|
|
||||||
**Summary for Branch A/B/C:** retail constrains an object to a `Position` (self or
|
|
||||||
server-received) with a start/max leash-band pair **every time `SmartBox` processes an
|
|
||||||
inbound position update for that object** — this is the "rubber-band" leash mechanism
|
|
||||||
that keeps the client's locally-simulated position from drifting too far from the
|
|
||||||
server-authoritative position between updates. It's re-armed (re-`ConstrainTo`'d) on
|
|
||||||
every inbound position packet, not set once.
|
|
||||||
|
|
||||||
### 2. `CPhysicsObj::teleport_hook` (`0x00514ed0`) — THE unconstrain call site
|
|
||||||
|
|
||||||
```c
|
|
||||||
00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
|
|
||||||
00514ed0 {
|
|
||||||
00514ed3 class MovementManager* movement_manager = this->movement_manager;
|
|
||||||
00514edb if (movement_manager != 0)
|
|
||||||
00514edb MovementManager::CancelMoveTo(movement_manager, edx);
|
|
||||||
00514ee4 class PositionManager* position_manager = this->position_manager;
|
|
||||||
00514eec if (position_manager != 0)
|
|
||||||
00514eee PositionManager::UnStick(position_manager);
|
|
||||||
00514ef3 class PositionManager* position_manager_1 = this->position_manager;
|
|
||||||
00514efb if (position_manager_1 != 0)
|
|
||||||
00514efd PositionManager::StopInterpolating(position_manager_1);
|
|
||||||
00514f02 class PositionManager* position_manager_2 = this->position_manager;
|
|
||||||
00514f0a if (position_manager_2 != 0)
|
|
||||||
00514f0c PositionManager::UnConstrain(position_manager_2);
|
|
||||||
00514f11 class TargetManager* target_manager = this->target_manager;
|
|
||||||
00514f19 if (target_manager != 0)
|
|
||||||
00514f19 {
|
|
||||||
00514f1b TargetManager::ClearTarget(target_manager);
|
|
||||||
00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus);
|
|
||||||
00514f19 }
|
|
||||||
00514f31 CPhysicsObj::report_collision_end(this, 1);
|
|
||||||
00514ed0 }
|
|
||||||
```
|
|
||||||
The ONLY `UnConstrain` call in the corpus. `teleport_hook` is a general "this object just
|
|
||||||
got relocated in a way that invalidates all continuity state" cleanup: it cancels any
|
|
||||||
active `MoveTo`, un-sticks (StickyManager), stops interpolation, **un-constrains**, clears
|
|
||||||
target tracking, and ends collision reporting. So the leash is torn down whenever an
|
|
||||||
object teleports (any teleport, not just the player's) — makes sense, since a teleport by
|
|
||||||
definition means "the position just legitimately jumped," so the anti-drift leash from
|
|
||||||
the PREVIOUS anchor must be dropped rather than fight the teleport.
|
|
||||||
|
|
||||||
### 3. `CMotionInterp::jump_is_allowed` (`0x005282b0`) — THE read call site
|
|
||||||
|
|
||||||
```c
|
|
||||||
005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3)
|
|
||||||
005282b0 {
|
|
||||||
005282b8 if (this->physics_obj != 0)
|
|
||||||
005282b8 {
|
|
||||||
...
|
|
||||||
005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
|
|
||||||
00528305 return 0x47;
|
|
||||||
00528305
|
|
||||||
00528308 class LListData* head_ = this->pending_motions.head_;
|
|
||||||
...
|
|
||||||
```
|
|
||||||
`jump_is_allowed` reads `IsFullyConstrained` and, if true, immediately returns error code
|
|
||||||
`0x47` (rejecting the jump attempt) before even checking pending motions / jump-charge
|
|
||||||
state. This is the ONLY read-site of `IsFullyConstrained` in the corpus. Ties back to the
|
|
||||||
mechanical read of `ConstraintManager::IsFullyConstrained` above: while the object is
|
|
||||||
still within 90% of its max leash distance from the constraint anchor, it counts as
|
|
||||||
"fully constrained" and jumping is blocked outright — i.e. **you cannot jump while the
|
|
||||||
client's simulated position is being actively rubber-banded back toward a server-received
|
|
||||||
position inside the tight leash band.** Only once you've drifted past 90% of the leash
|
|
||||||
(or the leash has been dropped via `UnConstrain`/teleport) does the jump-blocking gate
|
|
||||||
open.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CPhysicsObj-level "constrain" seam grep (exhaustive)
|
|
||||||
|
|
||||||
Full result of `grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained("` across the
|
|
||||||
corpus — every call site, no filtering:
|
|
||||||
|
|
||||||
```
|
|
||||||
93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...) [SmartBox::HandleReceivedPosition, Branch A]
|
|
||||||
93024 CPhysicsObj::ConstrainTo(arg2, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch B]
|
|
||||||
93041 CPhysicsObj::ConstrainTo(this->player, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch C]
|
|
||||||
276520 CPhysicsObj::IsFullyConstrained (definition)
|
|
||||||
276529 -> PositionManager::IsFullyConstrained (tailcall)
|
|
||||||
278353 CPhysicsObj::ConstrainTo (definition)
|
|
||||||
278363 -> PositionManager::ConstrainTo (tailcall)
|
|
||||||
283140 PositionManager::UnConstrain(position_manager_2) [CPhysicsObj::teleport_hook]
|
|
||||||
305524 CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0 [CMotionInterp::jump_is_allowed]
|
|
||||||
352186 PositionManager::ConstrainTo (definition)
|
|
||||||
352198 -> ConstraintManager::ConstrainTo (tailcall)
|
|
||||||
352203 PositionManager::UnConstrain (definition)
|
|
||||||
352212 -> ConstraintManager::UnConstrain (tailcall)
|
|
||||||
352217 PositionManager::IsFullyConstrained (definition)
|
|
||||||
352226 -> ConstraintManager::IsFullyConstrained (tailcall)
|
|
||||||
353405 ConstraintManager::UnConstrain (definition)
|
|
||||||
353413 ConstraintManager::IsFullyConstrained (definition)
|
|
||||||
353528 ConstraintManager::ConstrainTo (definition)
|
|
||||||
```
|
|
||||||
|
|
||||||
No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain
|
|
||||||
mechanism is used EXCLUSIVELY by:
|
|
||||||
- `SmartBox::HandleReceivedPosition` to arm/re-arm the leash on inbound position updates
|
|
||||||
(3 branches: self-anchor for remote movers, server-anchor for player teleport, server-
|
|
||||||
anchor for player non-teleport updates), and
|
|
||||||
- `CPhysicsObj::teleport_hook` to disarm it on any teleport, and
|
|
||||||
- `CMotionInterp::jump_is_allowed` to read it as a jump-blocking gate.
|
|
||||||
|
|
||||||
This is a narrow, special-purpose "server position rubber-band leash," NOT a general
|
|
||||||
physics constraint/joint system.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,338 +0,0 @@
|
||||||
# R5 port plan — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
|
|
||||||
|
|
||||||
Synthesis of the R5 recon (2026-07-03). Verbatim retail decomp lives in the
|
|
||||||
sibling files (`r5-*-decomp.md`); ACE cross-reference in `r5-ace-crossref.md`;
|
|
||||||
current acdream integration seams in `r5-acdream-seams.md`. This doc is the
|
|
||||||
**pseudocode + integration + sub-slice plan** (mandatory workflow step 3).
|
|
||||||
|
|
||||||
Retirement targets: **TS-39** (sticky no-op seams) and **AP-79** (the P4
|
|
||||||
TargetTracker adapter). ConstraintManager is ported for structural completeness
|
|
||||||
of PositionManager but its arming stays unported (see §Constraint scope) — **TS-35
|
|
||||||
stays open**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. The retail structure vs acdream's reality
|
|
||||||
|
|
||||||
**Retail** (`CPhysicsObj` owns three managers, all lazily created):
|
|
||||||
|
|
||||||
```
|
|
||||||
CPhysicsObj
|
|
||||||
├── movement_manager : MovementManager (facade → MotionInterp + MoveToManager)
|
|
||||||
├── position_manager : PositionManager (facade → Interpolation + Sticky + Constraint)
|
|
||||||
└── target_manager : TargetManager (voyeur subscription system)
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-tick, `CPhysicsObj::UpdateObjectInternal` (0x005156b0) fans out in THIS order
|
|
||||||
(decomp §positionmanager-sticky callers):
|
|
||||||
|
|
||||||
1. `DetectionManager::CheckDetection`
|
|
||||||
2. `TargetManager::HandleTargetting` ← voyeur tick (no separate UseTime)
|
|
||||||
3. `MovementManager::UseTime` → `MoveToManager::UseTime` only
|
|
||||||
4. `CPartArray::HandleMovement`
|
|
||||||
5. `PositionManager::UseTime` → Interp/Sticky/Constraint UseTime
|
|
||||||
|
|
||||||
And `UpdatePositionInternal` (0x00512c30) composes the frame delta:
|
|
||||||
`Frame::cache(&d)` → `CPartArray::Update(d)` (animation) →
|
|
||||||
`PositionManager::adjust_offset(&d, quantum)` (interp+sticky+constraint ADD into
|
|
||||||
the SAME delta `d`) → `Frame::combine(out, m_position.frame, d)` → transition
|
|
||||||
(collision) → commit. **Sticky/constraint steering composes with animation motion
|
|
||||||
in the same per-tick delta frame, before collision resolves it.**
|
|
||||||
|
|
||||||
**acdream** has no per-entity `CPhysicsObj`. The per-entity owner today is:
|
|
||||||
|
|
||||||
| Retail | acdream (remote) | acdream (player) |
|
|
||||||
|---|---|---|
|
|
||||||
| `CPhysicsObj` | `GameWindow.RemoteMotion` (nested class, GameWindow.cs:411) | `_playerController` (`PlayerMovementController`) + GameWindow fields |
|
|
||||||
| `movement_manager` | `rm.Motion` (MotionInterpreter) + `rm.MoveTo` (MoveToManager) | `_playerController.Motion` + `playerMoveTo` |
|
|
||||||
| `position_manager` | **absent** | **absent** |
|
|
||||||
| `target_manager` | **AP-79 adapter**: `rm.TrackedTarget*` fields + `TickRemoteMoveTo` | **AP-79 adapter**: `_playerMoveToTarget*` fields + `OnUpdateFrame` block |
|
|
||||||
| `UpdateObjectInternal` tick | `TickRemoteMoveTo(rm)` (GameWindow.cs:4469) | `OnUpdateFrame` block (GameWindow.cs:7994) + `_playerController.Update` |
|
|
||||||
|
|
||||||
R5 adds the two missing managers per-entity. The MovementManager facade (collapsing
|
|
||||||
`Motion`+`MoveTo` into one owner) is the optional capstone (§V4) — the retirement
|
|
||||||
targets don't require it, so it lands last and only if the arc has budget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Struct/field map (acclient.h → C#)
|
|
||||||
|
|
||||||
```
|
|
||||||
MovementManager (0x10): motion_interpreter, moveto_manager, physics_obj, weenie_obj
|
|
||||||
PositionManager (0x10): interpolation_manager, sticky_manager, constraint_manager, physics_obj
|
|
||||||
StickyManager (0x60): target_id:uint, target_radius:float, target_position:Position,
|
|
||||||
physics_obj, initialized:int, sticky_timeout_time:double
|
|
||||||
ConstraintManager(0x5c): physics_obj, is_constrained:int, constraint_pos_offset:float,
|
|
||||||
constraint_pos:Position, constraint_distance_start:float,
|
|
||||||
constraint_distance_max:float
|
|
||||||
TargetManager (0x18): physobj, target_info:TargetInfo*, voyeur_table:Hash<TargettedVoyeurInfo>,
|
|
||||||
last_update_time:double
|
|
||||||
TargetInfo (0xd0): context_id:uint, object_id:uint, radius:float, quantum:double,
|
|
||||||
target_position:Position, interpolated_position:Position,
|
|
||||||
interpolated_heading:Vec3, velocity:Vec3, status:TargetStatus,
|
|
||||||
last_update_time:double
|
|
||||||
TargettedVoyeurInfo(0x60): object_id:uint, quantum:double, radius:float,
|
|
||||||
last_sent_position:Position
|
|
||||||
```
|
|
||||||
|
|
||||||
acdream already has `TargetInfo` (4-field record) + `TargetStatus` enum in
|
|
||||||
`MoveToManager.cs` — R5 EXTENDS `TargetInfo` to the full 10-field shape (add
|
|
||||||
`ContextId`, `Radius`, `Quantum`, `InterpolatedHeading`, `Velocity`,
|
|
||||||
`LastUpdateTime`). The existing `MoveToManager.HandleUpdateTarget` only reads
|
|
||||||
`ObjectId`/`Status`/`InterpolatedPosition`, so the extension is additive-safe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The math — decoded (ACE is the oracle for the x87 mush)
|
|
||||||
|
|
||||||
All three `adjust_offset` bodies and both timeout gates were dense x87 mush in the
|
|
||||||
BN decomp. ACE's ports resolve them cleanly and were confirmed against the mush
|
|
||||||
structure (fld/fmul/fsqrt/fcomp shapes match). **Port ACE's structure; cite the
|
|
||||||
retail address.**
|
|
||||||
|
|
||||||
### 2a. StickyManager.adjust_offset (retail 0x00555430, ACE StickyManager.cs:91)
|
|
||||||
|
|
||||||
```
|
|
||||||
// Guard: no-op unless PhysicsObj && target_id != 0 && initialized
|
|
||||||
target = GetObjectA(target_id) // live resolve
|
|
||||||
targetPos = target != null ? target.Position : cached target_position // fallback to last-known
|
|
||||||
offset = self.Position.GetOffset(targetPos) // world vector self→target
|
|
||||||
offset = self.Position.GlobalToLocalVec(offset) // rotate into own frame
|
|
||||||
offset.Z = 0 // horizontal-only
|
|
||||||
|
|
||||||
radius = self.GetRadius()
|
|
||||||
dist = Position.CylinderDistanceNoZ(radius, self.Position, target_radius, targetPos)
|
|
||||||
- 0.3f // StickyRadius const (ACE StickyRadius=0.3f)
|
|
||||||
|
|
||||||
if NormalizeCheckSmall(ref offset): offset = Vector3.Zero // too close → don't chase jitter
|
|
||||||
|
|
||||||
speed = 0
|
|
||||||
minterp = self.get_minterp()
|
|
||||||
if minterp != null: speed = minterp.get_max_speed() * 5.0f // 5× own max speed (catch up)
|
|
||||||
if speed < EPSILON: speed = 15.0f // fallback
|
|
||||||
|
|
||||||
delta = speed * quantum
|
|
||||||
if delta >= abs(dist): delta = dist // don't overshoot (dist can be NEGATIVE)
|
|
||||||
offset.Origin *= delta
|
|
||||||
|
|
||||||
curHeading = self.Position.Frame.get_heading()
|
|
||||||
targetHeading = self.Position.heading(targetPos)
|
|
||||||
h = targetHeading - curHeading
|
|
||||||
if abs(h) < EPSILON: h = 0
|
|
||||||
if h < -EPSILON: h += 360
|
|
||||||
offset.set_heading(h) // per-tick RELATIVE turn toward target
|
|
||||||
```
|
|
||||||
|
|
||||||
Net: a speed-clamped horizontal steer + bounded turn toward the stuck target,
|
|
||||||
written into the shared per-tick delta frame. `StickyRadius=0.3f`, `StickyTime=1.0f`
|
|
||||||
are the two named constants.
|
|
||||||
|
|
||||||
### 2b. ConstraintManager.adjust_offset (retail 0x00556180, ACE ConstraintManager.cs:62)
|
|
||||||
|
|
||||||
```
|
|
||||||
// Guard: no-op unless PhysicsObj && is_constrained
|
|
||||||
if (self.TransientState & Contact): // ONLY clamps while grounded
|
|
||||||
if constraint_pos_offset < constraint_distance_max:
|
|
||||||
if constraint_pos_offset > constraint_distance_start:
|
|
||||||
offset.Origin *= (constraint_distance_max - constraint_pos_offset)
|
|
||||||
/ (constraint_distance_max - constraint_distance_start) // linear brake taper
|
|
||||||
else:
|
|
||||||
offset.Origin = Vector3.Zero // fully pinned
|
|
||||||
// UNCONDITIONAL (grounded or airborne):
|
|
||||||
constraint_pos_offset = offset.Origin.Length() // NB: length of the per-tick step, NOT distance-to-anchor
|
|
||||||
```
|
|
||||||
|
|
||||||
`ConstraintPos` (the anchor) is stored by ConstrainTo but never read by
|
|
||||||
adjust_offset — the taper uses `constraint_pos_offset` (last tick's step length)
|
|
||||||
vs start/max. See §ConstraintScope for why acdream never arms this.
|
|
||||||
|
|
||||||
### 2c. ConstraintManager.IsFullyConstrained (retail 0x005560d0, ACE:37)
|
|
||||||
|
|
||||||
```
|
|
||||||
return constraint_distance_max * 0.9f < constraint_pos_offset; // ≥90% of leash → "fully constrained"
|
|
||||||
```
|
|
||||||
|
|
||||||
Read by `jump_is_allowed`: if true → return 0x47 (block jump). Since acdream never
|
|
||||||
arms the constraint, this stays false → jump never blocked here (= TS-35 current
|
|
||||||
behavior).
|
|
||||||
|
|
||||||
### 2d. TargetManager quantum gates (retail 0x0051aa90 / 0x0051a650)
|
|
||||||
|
|
||||||
```
|
|
||||||
HandleTargetting(): // per-tick, self-throttled
|
|
||||||
if PhysicsTimer.CurrentTime - last_update_time < 0.5f: return // 0.5s throttle
|
|
||||||
if target_info != null && target_info.target_position == null: return
|
|
||||||
if target_info != null && target_info.status == Undefined
|
|
||||||
&& target_info.last_update_time + 10.0f < Timer.cur_time: // 10s staleness
|
|
||||||
target_info.status = TimedOut
|
|
||||||
PhysicsObj.HandleUpdateTarget(copy(target_info))
|
|
||||||
for voyeur in voyeur_table.Values.ToList():
|
|
||||||
CheckAndUpdateVoyeur(voyeur)
|
|
||||||
last_update_time = PhysicsTimer.CurrentTime
|
|
||||||
|
|
||||||
CheckAndUpdateVoyeur(voyeur):
|
|
||||||
newPos = GetInterpolatedPosition(voyeur.quantum) // self.pos + velocity*quantum (dead-reckon)
|
|
||||||
if newPos.Distance(voyeur.last_sent_position) > voyeur.radius: // send-on-significant-move
|
|
||||||
SendVoyeurUpdate(voyeur, newPos, Ok)
|
|
||||||
|
|
||||||
GetInterpolatedPosition(quantum):
|
|
||||||
pos = copy(self.Position); pos.Origin += self.get_velocity() * quantum; return pos
|
|
||||||
```
|
|
||||||
|
|
||||||
`0.5f` throttle, `10.0f` staleness — retail-tuned quanta (cross-checked ACE; verify
|
|
||||||
against named-retail before treating literally, per ACE's own `// ref?` caveats).
|
|
||||||
|
|
||||||
### 2e. The voyeur round-trip (peer-to-peer position notification)
|
|
||||||
|
|
||||||
```
|
|
||||||
Watcher W wants to track target T:
|
|
||||||
W.SetTarget(ctx=0, T.id, radius=0.5, quantum=q)
|
|
||||||
→ W.target_info = new TargetInfo(...)
|
|
||||||
→ T.add_voyeur(W.id, 0.5, q) // W subscribes ON T's voyeur_table
|
|
||||||
→ T immediately SendVoyeurUpdate(W-voyeur, T.pos, Ok) // initial snapshot
|
|
||||||
→ W.receive_target_update(info) → W.ReceiveUpdate(info)
|
|
||||||
→ W.target_info updated; W.HandleUpdateTarget(info)
|
|
||||||
→ W.movement_manager.HandleUpdateTarget (MoveToManager steers)
|
|
||||||
→ W.position_manager.HandleUpdateTarget (StickyManager follows)
|
|
||||||
|
|
||||||
Each tick, T.HandleTargetting → CheckAndUpdateVoyeur(W-voyeur):
|
|
||||||
if T drifted > radius since last sent → SendVoyeurUpdate → W.ReceiveUpdate → W.HandleUpdateTarget
|
|
||||||
|
|
||||||
W stops: W.ClearTarget → T.remove_voyeur(W.id)
|
|
||||||
T despawns: T.exit_world → NotifyVoyeurOfEvent(ExitWorld) → all watchers get ExitWorld
|
|
||||||
T teleports: T.teleport_hook → NotifyVoyeurOfEvent(Teleported)
|
|
||||||
```
|
|
||||||
|
|
||||||
This is exactly what the AP-79 adapter approximates (poll target pos, feed
|
|
||||||
HandleUpdateTarget when drift>radius). The voyeur port is a **faithful superset**:
|
|
||||||
same moveto behavior + correct sticky + timeout/exit/teleport events.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Two consumers of set_target (both radius 0.5)
|
|
||||||
|
|
||||||
| Caller | quantum | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `MoveToManager.MoveToObject/TurnToObject` | **0.0** | resend as fast as the 0.5s tick allows |
|
|
||||||
| `StickyManager.StickTo` | **0.5** | throttled resend |
|
|
||||||
|
|
||||||
Both go through the SAME `CPhysicsObj::set_target → TargetManager::SetTarget`, and
|
|
||||||
both receive updates through `CPhysicsObj::HandleUpdateTarget →
|
|
||||||
{MovementManager, PositionManager}::HandleUpdateTarget`. In acdream the
|
|
||||||
`_setTarget`/`_clearTarget` seams (already on MoveToManager) get repointed at the
|
|
||||||
real TargetManager; StickyManager gets its own seam to the same TargetManager.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. acdream integration seams (delegate contracts)
|
|
||||||
|
|
||||||
The Core classes stay engine-agnostic via ctor-injected seams (same convention as
|
|
||||||
MoveToManager). Per entity the App layer supplies:
|
|
||||||
|
|
||||||
- `getPosition : () → Position` (world-space, cell + frame)
|
|
||||||
- `getVelocity : () → Vector3`
|
|
||||||
- `getRadius : () → float`, `getHeight : () → float` (from setup cylsphere — **finally
|
|
||||||
needed here; today MoveToManager passes 0** — see the P4 note)
|
|
||||||
- `getMinterpMaxSpeed : () → float?` (null if no interp) — StickyManager speed
|
|
||||||
- `getContact : () → bool` (transient Contact bit) — ConstraintManager gate
|
|
||||||
- `getObjectA : uint guid → IPhysicsTargetHandle?` — cross-entity resolve (the guid→entity
|
|
||||||
seam; drives voyeur delivery + sticky live-target). Backed by GameWindow's
|
|
||||||
`_entitiesByServerGuid`.
|
|
||||||
- `handleUpdateTarget : TargetInfo → void` — fans to MoveToManager + PositionManager(Sticky)
|
|
||||||
- `clearTarget`/`interruptCurrentMovement` — teardown (already exist on MoveToManager/Motion)
|
|
||||||
- clock : `() → double` (real clock, as R4-V5 fixed)
|
|
||||||
|
|
||||||
`getObjectA` returns a small handle exposing the OTHER entity's
|
|
||||||
`receive_target_update(TargetInfo)` (→ its TargetManager.ReceiveUpdate) + its live
|
|
||||||
Position — enough for `SendVoyeurUpdate` and sticky's live-target resolve.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Sub-slice plan
|
|
||||||
|
|
||||||
### R5-V1 — Core classes + conformance tests (NO wiring; low risk)
|
|
||||||
New files under `src/AcDream.Core/Physics/Motion/` (Position/Sticky/Constraint) and
|
|
||||||
`src/AcDream.Core/Physics/Combat/` (TargetManager — retail namespace split):
|
|
||||||
- `PositionManager` (facade: adjust_offset chain interp→sticky→constraint, UseTime,
|
|
||||||
StickTo/Unstick/ConstrainTo/UnConstrain/IsFullyConstrained/HandleUpdateTarget).
|
|
||||||
- `StickyManager` (§2a math, StickTo/UnStick/UseTime timeout/HandleUpdateTarget/adjust_offset).
|
|
||||||
- `ConstraintManager` (§2b/§2c, ConstrainTo/UnConstrain/IsFullyConstrained/adjust_offset).
|
|
||||||
- `TargetManager` (§2d/§2e full voyeur system) + `TargettedVoyeurInfo` + extended `TargetInfo`.
|
|
||||||
- InterpolationManager: acdream already has a remote `Interp` (PhysicsBody interp) —
|
|
||||||
keep it; PositionManager's interp slot delegates to a thin adapter or is left null
|
|
||||||
for the player. (Interp is NOT an R5 retirement target; don't re-port it.)
|
|
||||||
- Seam interfaces + a test harness with a fake `getObjectA` wiring two managers to
|
|
||||||
each other. Golden values from ACE + the decoded math above. Tests:
|
|
||||||
sticky steer/clamp/heading, sticky 1s timeout, constraint taper + IsFullyConstrained
|
|
||||||
90% gate, voyeur subscribe/immediate-snapshot/distance-gate/10s-timeout/exit/teleport,
|
|
||||||
ReceiveUpdate match+heading-fallback, the full W↔T round-trip.
|
|
||||||
- `dotnet build`+`dotnet test` green. Commit. **No behavior change** (nothing wired).
|
|
||||||
|
|
||||||
### R5-V2 — Wire TargetManager per-entity; retire AP-79
|
|
||||||
- Add `TargetManager` to `RemoteMotion` + a player-side owner; construct beside the
|
|
||||||
MoveToManager binds. Repoint MoveToManager's `_setTarget`/`_clearTarget`/quantum
|
|
||||||
seams at `TargetManager.SetTarget`/`ClearTarget`/quantum.
|
|
||||||
- `getObjectA` backed by `_entitiesByServerGuid` (returns a handle to the target's
|
|
||||||
TargetManager). Per-tick: call `TargetManager.HandleTargetting()` in
|
|
||||||
`TickRemoteMoveTo` + the player block, BEFORE `MoveToManager.UseTime()` (retail order).
|
|
||||||
- Delete the AP-79 tracker fields (`TrackedTarget*` / `_playerMoveToTarget*`) and the
|
|
||||||
manual poll blocks — the voyeur round-trip replaces them.
|
|
||||||
- Register: delete AP-79 row same commit. **Visual gate**: a server-directed creature
|
|
||||||
chasing the player still tracks + moves identically (the exact behavior AP-79 drove).
|
|
||||||
|
|
||||||
### R5-V3 — Wire PositionManager (Sticky); retire TS-39; apply mt-0 sticky guid
|
|
||||||
- Add `PositionManager` to each entity. Bind `MoveToManager.StickTo →
|
|
||||||
PositionManager.StickTo`, `MoveToManager.Unstick → PositionManager.UnStick`.
|
|
||||||
- Integrate `PositionManager.adjust_offset` into the per-frame body integration
|
|
||||||
(the composed delta-frame chokepoint — retail's UpdatePositionInternal). This is
|
|
||||||
the load-bearing wiring: sticky steer must ADD to the body's per-tick motion.
|
|
||||||
- Per-tick `PositionManager.UseTime()` AFTER MoveToManager.UseTime (retail order).
|
|
||||||
- Apply the mt-0 wire flags (UpdateMotion.cs already parses them, unconsumed):
|
|
||||||
`0x1 StickToObject` → `stick_to_object(guid)` → `PositionManager.StickTo`;
|
|
||||||
`0x2 StandingLongJump` → `MotionInterpreter.StandingLongJump`.
|
|
||||||
- Register: delete TS-39 row same commit. **Visual gate**: a sticky scenario (server
|
|
||||||
/follow-style sticky moveto or a moving-platform stick) holds the target instead of
|
|
||||||
stopping.
|
|
||||||
|
|
||||||
### R5-V4 (capstone, optional) — MovementManager facade + #164 + head stance dispatch + docs
|
|
||||||
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner (structural;
|
|
||||||
must keep the 183-case + funnel + moveto suites green UNMODIFIED).
|
|
||||||
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy flag,
|
|
||||||
raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
|
|
||||||
- Head stance-change dispatch for mt 6-9 (verification item 1 / the S3 exclusion at
|
|
||||||
`RetailObserverTraceConformanceTests.cs:33`).
|
|
||||||
- Final register/ISSUES/roadmap/memory + successor handoff.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Constraint scope (why TS-35 stays open)
|
|
||||||
|
|
||||||
ConstraintManager is the **server-position rubber-band leash**, armed ONLY from
|
|
||||||
`SmartBox::HandleReceivedPosition` (arm on every inbound position packet, anchor =
|
|
||||||
self or server-received position) with two constants from `GetStart/MaxConstraintDistance`
|
|
||||||
(0x0050ebc0 / 0x0050ec10) that are **x87 float returns BN elided — literal values
|
|
||||||
unknown** (need a live cdb read or Ghidra float-return fix). acdream has no SmartBox
|
|
||||||
leash (its position reconciliation is separate), so:
|
|
||||||
- Port the class + wire it into PositionManager (IsFullyConstrained routes through it;
|
|
||||||
adjust_offset participates in the chain) for structural faithfulness.
|
|
||||||
- **Do NOT arm it** (no ConstrainTo call site) — the leash + jump-blocking + the two
|
|
||||||
unknown constants are out of R5 scope. IsFullyConstrained stays false = TS-35's
|
|
||||||
current behavior. Update TS-35's `where` to point at the now-real-but-unarmed
|
|
||||||
ConstraintManager; keep the row OPEN. Filing an issue for the leash port + the two
|
|
||||||
unknown constants.
|
|
||||||
|
|
||||||
## Open verification items (carry into V1 tests / cdb)
|
|
||||||
1. `GetStart/MaxConstraintDistance` x87 return constants — unknown (constraint arming,
|
|
||||||
deferred). File as issue.
|
|
||||||
2. `HandleTargetting`'s 0.5s/10s quanta + `StickyTime` 1.0s / `StickyRadius` 0.3f /
|
|
||||||
sticky speed `×5` + `15.0f` fallback — ACE values; verify against named-retail.
|
|
||||||
3. mt-0 header flags: confirm `0x1`/`0x2` (wire) == decomp `0x100`/`0x200` shift
|
|
||||||
(recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword).
|
|
||||||
4. `TargetInfo` pass-by-value-vs-ref (ACE's two `// ref?` markers) — retail copy-constructs
|
|
||||||
(`TargetInfo::TargetInfo(©, src)` at every fan-out); port as copy.
|
|
||||||
|
|
||||||
## Do-NOT-re-port (already shipped)
|
|
||||||
MoveToManager (R4, full incl. queue + HandleUpdateTarget consumer), MotionInterpreter
|
|
||||||
(R3, #161 apply-pass), MotionTableManager+GetObjectSequence (R2), CSequence (R1),
|
|
||||||
RemoteWeenie + PhysicsBody.InWorld (R4-V5), InterpolationManager (acdream's remote
|
|
||||||
Interp — not an R5 target).
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,191 +0,0 @@
|
||||||
# R5 wiring handoff — ARC DONE 2026-07-05 (V1-V5 all shipped)
|
|
||||||
|
|
||||||
> **ARC CLOSE-OUT 2026-07-05.** All five slices landed: V1 (Core classes,
|
|
||||||
> `3d89446d`), V2 (TargetManager voyeur wiring, AP-79 retired, `fffe90b3`),
|
|
||||||
> V3 (sticky melee #171, three slices, TS-39 retired, gate PASSED), V4
|
|
||||||
> behavioral items (head stance dispatch + #164 + mt-0 flags, `f423884b`,
|
|
||||||
> gate PASSED), **V5 (MovementManager facade — structural, zero behavior
|
|
||||||
> change)**: `src/AcDream.Core/Physics/Motion/MovementManager.cs` (retail
|
|
||||||
> acclient.h `/* 3463 */`; MakeMoveToManager 0x00524000, PerformMovement
|
|
||||||
> 0x005240d0, UseTime 0x005242f0, HitGround 0x00524300, HandleExitWorld
|
|
||||||
> 0x00524350, CancelMoveTo 0x005241b0, HandleUpdateTarget 0x00524790,
|
|
||||||
> IsMovingTo 0x00524260) owns each entity's interp+moveto pair. The three
|
|
||||||
> wiring sites (`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the
|
|
||||||
> chase harness) construct through `MoveToFactory` + `MakeMoveToManager()`
|
|
||||||
> — the factory closure is the acdream stand-in for retail's
|
|
||||||
> physics_obj/weenie_obj backpointers; `RemoteMotion.Motion/.MoveTo` and
|
|
||||||
> `PlayerMovementController.MoveTo` are now child VIEWS of the facade
|
|
||||||
> (`RemoteMotion.Movement` / `PlayerMovementController.Movement`), so the
|
|
||||||
> comment-dense call sites read unchanged. Relay call sites repointed:
|
|
||||||
> both landing HitGround pairs + the player landing pair, despawn
|
|
||||||
> HandleExitWorld, TickRemoteMoveTo + the player Update UseTime,
|
|
||||||
> RouteServerMoveTo (now takes the facade; routes through the retail
|
|
||||||
> PerformMovement dispatch), InstallSpeculativeTurnToTarget, both host
|
|
||||||
> HandleUpdateTarget/InterruptCurrentMovement closures, TS-36 interrupt
|
|
||||||
> chains. NOT absorbed (per slice spec): unpack_movement stays App
|
|
||||||
> (RouteServerMoveTo + the UM heads), TS-42 per-tick order untouched (R6),
|
|
||||||
> #170/#171 gate-passed machinery untouched. 15 facade conformance tests
|
|
||||||
> (`MovementManagerTests.cs`); suite 4052 green, protected suites
|
|
||||||
> unmodified. Register: TS-41/TS-42 wording freshened, AD-36 retire note
|
|
||||||
> corrected (facade half closed; no new rows). **Open follow-ons: #167
|
|
||||||
> ConstraintManager arming (TS-35), R6 per-tick order (TS-42), TS-43
|
|
||||||
> remote teleport hook.** Next work per the milestones doc: M1.5 critical
|
|
||||||
> path — #137 dungeon collision, #138 teleport-OUT, A7 dungeon lighting.
|
|
||||||
|
|
||||||
(Original handoff below, kept for the trail.)
|
|
||||||
|
|
||||||
Successor to `2026-07-03-r5-entry-handoff.md`. **R5-V1 shipped** (`3d89446d`):
|
|
||||||
the retail movement-manager family is ported to Core as faithful, fully-tested,
|
|
||||||
UNWIRED classes. The remaining slices (V2/V3/V4) are GameWindow integration
|
|
||||||
whose acceptance is **visual verification against the running client** — the one
|
|
||||||
thing the CLAUDE.md says requires the user. Do them one at a time, visual-gate
|
|
||||||
each, do NOT stack.
|
|
||||||
|
|
||||||
## What V1 landed (`3d89446d`, full suite 4006 green)
|
|
||||||
|
|
||||||
New Core (`src/AcDream.Core/Physics/Motion/`), all with conformance tests:
|
|
||||||
- `StickyManager`, `ConstraintManager`, `PositionManager` (facade),
|
|
||||||
`TargetManager` + `TargettedVoyeurInfo`, `IPhysicsObjHost` (the CPhysicsObj
|
|
||||||
back-pointer seam the App implements per entity), `MotionDeltaFrame`.
|
|
||||||
- `TargetInfo` extended to the full retail 10-field struct (additive defaults).
|
|
||||||
- `MoveToMath`: `CylinderDistanceNoZ` (signed), `NormalizeCheckSmall`,
|
|
||||||
`GlobalToLocalVec`.
|
|
||||||
- **Rename**: `AcDream.Core.Physics.PositionManager` (the misnamed remote
|
|
||||||
anim+interp combiner) → `RemoteMotionCombiner`. This freed the name for the
|
|
||||||
faithful facade and removed the ambiguity that breaks any file importing both
|
|
||||||
`AcDream.Core.Physics` + `.Motion` — **GameWindow imports both**, so the
|
|
||||||
wiring below could not compile without this rename.
|
|
||||||
|
|
||||||
Decomp + ACE cross-ref + decoded math: this directory (`r5-*-decomp.md`,
|
|
||||||
`r5-ace-crossref.md`, `r5-port-plan.md`). The port plan §2 has the decoded x87
|
|
||||||
mush (sticky steer, constraint taper, voyeur gates) — trust it over the raw BN.
|
|
||||||
|
|
||||||
## The wiring model (all slices)
|
|
||||||
|
|
||||||
acdream has no per-entity `CPhysicsObj`. Introduce one `IPhysicsObjHost` per
|
|
||||||
entity — **in a dedicated App class `EntityPhysicsHost`, NOT inline in
|
|
||||||
GameWindow** (code-structure rule #1). GameWindow keeps a
|
|
||||||
`Dictionary<uint, EntityPhysicsHost> _physicsHosts`; each host's `GetObjectA`
|
|
||||||
is `id => _physicsHosts.GetValueOrDefault(id)` (cross-entity resolve for the
|
|
||||||
voyeur round-trip). Construct a host in `EnsureRemoteMotionBindings` (remotes)
|
|
||||||
and `EnterPlayerModeNow` (player); remove it + `NotifyVoyeurOfEvent(ExitWorld)`
|
|
||||||
on despawn/exit-world.
|
|
||||||
|
|
||||||
Host accessor mapping (behavioral-equivalence anchor — match AP-79 exactly):
|
|
||||||
- `Position` = the entity's **`WorldEntity.Position`** (world-space) via
|
|
||||||
`_entitiesByServerGuid[id].Position`. This is EXACTLY the source the AP-79
|
|
||||||
poll used for a target (`trackedEnt.Position`), so for the moveto case
|
|
||||||
(quantum 0, `GetInterpolatedPosition` = Position) the voyeur system delivers
|
|
||||||
the identical position the adapter did.
|
|
||||||
- `Velocity` = body velocity (only used for quantum>0, i.e. sticky).
|
|
||||||
- `Radius` = setup cylsphere radius (today MoveToManager passes 0 — the P4 note;
|
|
||||||
wiring the real radius here is the "finally needed" bit).
|
|
||||||
- `InContact` = transient Contact bit (constraint gate; V3).
|
|
||||||
- `MinterpMaxSpeed` = `interp.GetMaxSpeed()` or null (sticky speed; V3).
|
|
||||||
- `CurTime`/`PhysicsTimerTime` = the real clocks (R4-V5 fixed the remote clock;
|
|
||||||
reuse the same epoch-seconds source; the player uses `SimTimeSeconds`).
|
|
||||||
- `HandleUpdateTarget` = fan to the entity's `MoveToManager.HandleUpdateTarget`
|
|
||||||
(+ `PositionManager.HandleUpdateTarget` once V3 adds it).
|
|
||||||
|
|
||||||
### V2 — wire TargetManager, retire AP-79 (behaviorally a no-op refactor)
|
|
||||||
Repoint MoveToManager's `setTarget`/`clearTarget`/`getTargetQuantum`/
|
|
||||||
`setTargetQuantum` seams at `host.TargetManager`. Per tick, call
|
|
||||||
`host.TargetManager.HandleTargetting()` BEFORE `mtm.UseTime()` (retail
|
|
||||||
`UpdateObjectInternal` order) — in `TickRemoteMoveTo` (remotes, GameWindow.cs
|
|
||||||
~4469, both call sites ~9688/9911) and the player pre-Update block
|
|
||||||
(GameWindow.cs ~7994). **Every entity that can be a target must tick
|
|
||||||
HandleTargetting** — the creature-chase target is the player, so the player's
|
|
||||||
host MUST tick it (that's what pushes updates to the chasing creatures).
|
|
||||||
Delete the AP-79 fields (`RemoteMotion.TrackedTarget*`, the `_playerMoveToTarget*`
|
|
||||||
GameWindow fields) and the two manual poll blocks. **Delete the AP-79 register
|
|
||||||
row same commit.** Keep the 183-case/funnel/moveto suites green (unmodified).
|
|
||||||
|
|
||||||
Behavioral-equivalence: the voyeur radius = MoveToManager's `set_target` radius
|
|
||||||
(0.5) = AP-79's `TrackedTargetRadius`; both deliver `HandleUpdateTarget(Ok)`
|
|
||||||
when the target drifts >radius; both deliver ExitWorld on despawn. Identical for
|
|
||||||
the moveto case. **VISUAL GATE**: a server-directed creature still chases the
|
|
||||||
player, and player auto-walk-to-object still tracks — no visible change, confirm
|
|
||||||
nothing broke.
|
|
||||||
|
|
||||||
Lifecycle watchouts: (1) when a watcher despawns, its host removal must let the
|
|
||||||
target drop the dead voyeur (RemoveVoyeur on ClearTarget, or prune on send
|
|
||||||
failure). (2) The immediate-snapshot-on-subscribe (retail AddVoyeur) means the
|
|
||||||
watcher gets one `HandleUpdateTarget(Ok)` the instant it sets a target — matches
|
|
||||||
AP-79's `!FedOnce` first delivery. (3) HandleTargetting self-throttles to 0.5s;
|
|
||||||
the AP-79 poll ran every frame but only DELIVERED on drift — same effective
|
|
||||||
cadence for delivery, but a target moving fast between 0.5s ticks sends less
|
|
||||||
often. If chase feels choppier than AP-79, that's the throttle — retail-faithful,
|
|
||||||
but note it.
|
|
||||||
|
|
||||||
### V3 — wire PositionManager (sticky), retire TS-39, apply mt-0 flags
|
|
||||||
|
|
||||||
> **STATUS 2026-07-04: SHIPPED + visual gate PASSED** ("Looks good, ship it";
|
|
||||||
> three slices: `5bd2b8bc` binding+radii, `7a823176` UP-snap suppression while
|
|
||||||
> stuck TS-44, `69966950` deep-overlap sign pin AP-82)
|
|
||||||
> — sticky seams bound (StickTo/Unstick/UnstickFromObject, remote + player),
|
|
||||||
> AdjustOffset at the UpdatePositionInternal slot (combiner chained as the
|
|
||||||
> interp stage in the remote-player branch; pre-sweep compose in the NPC
|
|
||||||
> branch + player physics tick), UseTime at the UpdateObjectInternal tail,
|
|
||||||
> real setup cylsphere radii threaded, exit-world/teleport teardown (remote
|
|
||||||
> teleport gap = TS-43), SERVERVEL yields to a stuck entity (TS-41). TS-39
|
|
||||||
> retired. **The mt-0 wire flags (0x1 StickToObject / 0x2 StandingLongJump)
|
|
||||||
> were NOT part of the user-approved #171 scope — they move to V4.**
|
|
||||||
Add a `PositionManager` to each host. Bind `MoveToManager.StickTo →
|
|
||||||
host.PositionManager.StickTo`, `MoveToManager.Unstick → host.PositionManager.UnStick`.
|
|
||||||
The load-bearing part: integrate `host.PositionManager.AdjustOffset(deltaFrame,
|
|
||||||
quantum)` into the per-frame body integration at the composed-delta chokepoint
|
|
||||||
(retail `UpdatePositionInternal` — in acdream that's where `RemoteMotionCombiner.
|
|
||||||
ComputeOffset` runs, GameWindow ~9716; sticky steer must ADD to the body's
|
|
||||||
per-tick motion). Per tick `host.PositionManager.UseTime()` AFTER
|
|
||||||
`mtm.UseTime()`. Apply the mt-0 wire flags (`UpdateMotion.cs` parses both,
|
|
||||||
unconsumed): `0x1 StickToObject` → `host.stick_to_object(guid)` →
|
|
||||||
`PositionManager.StickTo`; `0x2 StandingLongJump` →
|
|
||||||
`MotionInterpreter.StandingLongJump`. **Delete the TS-39 register row same
|
|
||||||
commit.** **VISUAL GATE**: a sticky scenario (server /follow-style sticky moveto
|
|
||||||
or a moving-platform stick) HOLDS the target instead of stopping-and-drifting.
|
|
||||||
|
|
||||||
### V4 (capstone) — MovementManager facade + #164 + head-stance dispatch + docs
|
|
||||||
|
|
||||||
> **STATUS 2026-07-04: the three BEHAVIORAL items SHIPPED + visual gate
|
|
||||||
> PASSED** ("looks good", `f423884b`): head style-on-change at both GameWindow routing heads
|
|
||||||
> (@00524502-0052452c, all movement types), #164 action-replay Autonomous
|
|
||||||
> bit (raw 305982), mt-0 wire flags consumed (0x1 stick_to_object
|
|
||||||
> 0x005127e0 → PositionManager.StickTo at both case-0 tails; 0x2 →
|
|
||||||
> `Motion.StandingLongJump`, unconditional per @0052458e). Conformance:
|
|
||||||
> stance-on-arm harness scenario + the autonomy funnel test; suite 4041
|
|
||||||
> green. **The MovementManager facade DEFERRED to its own slice** (the
|
|
||||||
> handoff's own "optional if the arc runs long" clause — this arc ran two
|
|
||||||
> full gate cycles). **→ SHIPPED 2026-07-05 as R5-V5; see the arc
|
|
||||||
> close-out banner at the top of this doc.**
|
|
||||||
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner
|
|
||||||
(structural; keep 183-case/funnel/moveto green UNMODIFIED). Optional if the
|
|
||||||
arc runs long — the retirements above don't need it.
|
|
||||||
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy
|
|
||||||
flag, raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
|
|
||||||
- Head stance-change dispatch for mt 6-9 (the `RetailObserverTraceConformance
|
|
||||||
Tests.cs:33` "S3 wires the unpack-level style-on-change" exclusion — retail's
|
|
||||||
`unpack_movement` head does `InqStyle != wire-style → DoMotion(style)`
|
|
||||||
independent of movement type; acdream applies style only on the mt-0 path).
|
|
||||||
- Final register/ISSUES/roadmap/memory + successor handoff.
|
|
||||||
|
|
||||||
## Open items carried
|
|
||||||
- **#167**: ConstraintManager leash-arming + the two unknown x87 constants
|
|
||||||
(`GetStart/MaxConstraintDistance`) — deferred; needs cdb/Ghidra. TS-35 stays
|
|
||||||
open (register corrected: the mechanism is the leash, not doorway-jamming).
|
|
||||||
- Verify against named-retail before treating as ground truth: the sticky/target
|
|
||||||
constants (StickyRadius 0.3 / StickyTime 1.0 / follow ×5 / fallback 15;
|
|
||||||
HandleTargetting 0.5s throttle / 10s staleness) — ACE values, cross-checked
|
|
||||||
against the mush but ACE flagged its own `// ref?` uncertainty on the
|
|
||||||
TargetInfo copy semantics (ported as copy — retail copy-constructs at every
|
|
||||||
fan-out).
|
|
||||||
|
|
||||||
## Load-bearing lessons (from V1)
|
|
||||||
- The `Physics.PositionManager` name collision was a REAL compile break for any
|
|
||||||
file importing both namespaces, not cosmetic — the recon agent missed the
|
|
||||||
existing (misnamed) class entirely (grepped for retail semantics). Renaming it
|
|
||||||
was the "do it right" fix. Lesson: grep for the NAME too, not just the concept.
|
|
||||||
- ConstraintManager is NOT a general joint/constraint system — it's a narrow
|
|
||||||
server-position rubber-band leash (3 call sites total: SmartBox arm, teleport
|
|
||||||
disarm, jump-gate read). Don't over-scope it.
|
|
||||||
- The voyeur system is peer-to-peer intra-client: it needs EVERY entity to be a
|
|
||||||
host + tick HandleTargetting, or cross-entity delivery silently no-ops.
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
# Session handoff — R5-V1/V2 + world-loading fixes (2026-07-03, late)
|
|
||||||
|
|
||||||
Fresh session enters HERE. Worktree `vigorous-joliot-f0c3ad`, branch
|
|
||||||
`claude/vigorous-joliot-f0c3ad`, tree CLEAN at `64f83d7c`. Full suite **4007**
|
|
||||||
green (+ the R5-V1 conformance tests + the #168 relocate test). Everything below
|
|
||||||
is committed.
|
|
||||||
|
|
||||||
## What landed this session
|
|
||||||
|
|
||||||
| Commit | What |
|
|
||||||
|---|---|
|
|
||||||
| `3d89446d` | **R5-V1** — PositionManager facade + StickyManager + ConstraintManager + the full **TargetManager voyeur system** ported to Core, fully tested, UNWIRED. Renamed the misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`. Decomp/ACE/plan in `docs/research/2026-07-03-r5-managers/`. |
|
|
||||||
| `2b5e8a67` | R5-V1 docs: wiring handoff, register TS-35 correction, ISSUES #167 (ConstraintManager leash unarmed). |
|
|
||||||
| `fffe90b3` | **R5-V2** — wired the TargetManager voyeur system per-entity via `EntityPhysicsHost` (App), retired the AP-79 poll adapter. **Verified live** — this is why remote creatures now chase the player. |
|
|
||||||
| `315af02f` | **fix #168** — the pending-bucket trap (`RelocateEntity` couldn't recover an entity stranded in `_pendingByLandblock`). Invisible player / disappear-on-run-out. |
|
|
||||||
| `9b06a9b8` | **fix #169** — the cold-spawn streaming "hole" (far login-spawn diffs against the half-loaded startup window; residence marked before loads land → hole). Now `ForceReloadWindow`s on a far login-spawn. |
|
|
||||||
| `7c5bc97c` | ISSUES #168/#169 filed DONE. |
|
|
||||||
| `64f83d7c` | ISSUES #170 filed (creature chase/attack animation divergence). |
|
|
||||||
|
|
||||||
**The user's headline ask this session** — "the world doesn't load / character
|
|
||||||
disappears" — is FIXED and verified live (`0xADAF` loads, player transitions
|
|
||||||
`PENDING → DRAWSET PRESENT`). Root-caused to two pre-existing streaming bugs;
|
|
||||||
R5-V2 was verified line-by-line to be read-only w.r.t. position/cell/landblock/
|
|
||||||
streaming and RULED OUT as the cause. Durable lesson: `feedback_streaming_residence_race`.
|
|
||||||
|
|
||||||
## NEXT — two ready work streams (Claude picks; both are teed up)
|
|
||||||
|
|
||||||
### Option A — #170: remote creature chase+attack renders wrong vs retail
|
|
||||||
**Well-scoped, user-confirmed via retail side-by-side (the oracle).** A monster
|
|
||||||
chasing+attacking the player glides, over-plays attacks, and shows uniform/wrong
|
|
||||||
attack animations; retail (same local ACE) renders it correctly → the divergence
|
|
||||||
is CLIENT-side. Motion trace (`ACDREAM_DUMP_MOTION`, guid 0x80000244): ACE sends
|
|
||||||
`mt-0 stance 0x3C` → `mt-6 chase spd 2.03` → a stream of `mt-0` attacks
|
|
||||||
`0x62/0x63/0x64 spd 0.97`; acdream shows a MOTIONDONE `pending=True` loop.
|
|
||||||
Decomposes into THREE sub-bugs (full detail in ISSUES #170):
|
|
||||||
1. **Wrong/uniform attack anims** → likely **#159** (CombatAnimationPlanner uses
|
|
||||||
2013-decomp command numbering, not ACE/DRW — `0x10000062/63/64` misclassify).
|
|
||||||
This one has a KNOWN fix path (renumber `CombatAnimationMotionCommands` to
|
|
||||||
`DatReaderWriter.Enums.MotionCommand` values; parity test).
|
|
||||||
2. **Over-frequency / stuck** — the `pending_motions` queue completes+re-queues
|
|
||||||
in a tight loop. R3/R4 animation-sequencer / MotionDone territory (grep the
|
|
||||||
named decomp for how retail's `CMotionInterp` handles an attack UM stream
|
|
||||||
arriving over an active moveto — attack one-shots should play over locomotion
|
|
||||||
and RETURN to it, not wedge).
|
|
||||||
3. **Glide** — position moves (mt-6 + UP dead-reckon) but no locomotion legs play
|
|
||||||
(attacks override) → smooth slide. AP-80 / #160 dead-reckon-vs-animation family.
|
|
||||||
Entry points: `MotionInterpreter`/`AnimationSequencer` attack dispatch +
|
|
||||||
`pending_motions`; `CombatAnimationPlanner` (#159); `ServerControlledLocomotion`.
|
|
||||||
Start with #159 (bounded), then trace the pending-motions loop with the motion
|
|
||||||
dump. **Retail is the oracle — the user runs it side-by-side; ask for a
|
|
||||||
frame-by-frame retail comparison, don't guess.**
|
|
||||||
|
|
||||||
### Option B — R5-V3: wire PositionManager sticky, retire TS-39, apply mt-0 flags
|
|
||||||
The paused R5 continuation. Full plan in
|
|
||||||
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` (§V3). Bind
|
|
||||||
`MoveToManager.StickTo/Unstick → PositionManager` (now that V2 gives each entity
|
|
||||||
an `EntityPhysicsHost`, adding a `PositionManager` to it is the natural next
|
|
||||||
step), integrate `adjust_offset` into the body tick, apply the UpdateMotion mt-0
|
|
||||||
flags (0x1 sticky-guid → `stick_to_object`, 0x2 → `StandingLongJump`). Retires
|
|
||||||
TS-39. Then R5-V4 (MovementManager facade + #164 + head-stance dispatch).
|
|
||||||
|
|
||||||
**Recommendation:** #170 is more user-visible (they just watched it break) and is
|
|
||||||
now well-scoped; R5-V3 is the "finish what we started" path. Either is fine —
|
|
||||||
Claude's call per work-order autonomy. #170's part 1 (#159) is the smallest
|
|
||||||
concrete win.
|
|
||||||
|
|
||||||
## R5 arc status
|
|
||||||
- **V1** (Core managers + tests) — DONE `3d89446d`.
|
|
||||||
- **V2** (voyeur wiring, retire AP-79) — DONE `fffe90b3`, verified live.
|
|
||||||
- **V3** (sticky, retire TS-39, mt-0 flags) — PENDING. `r5-wiring-handoff.md` §V3.
|
|
||||||
- **V4** (MovementManager facade + #164 + head-stance dispatch) — PENDING. §V4.
|
|
||||||
- Open R5-adjacent: #167 (ConstraintManager leash unarmed + 2 unknown x87 consts),
|
|
||||||
TS-35 (stays open until the leash is armed), #164 (action-replay Autonomous bit).
|
|
||||||
|
|
||||||
## Debug apparatus that proved decisive (reuse for #170)
|
|
||||||
- `ACDREAM_PROBE_ENT=1` — `EntityVanishProbe`: `[ent] APPEND → PENDING/LOADED`,
|
|
||||||
`DRAWSET PRESENT/ABSENT`, `[dyn] player DRAWN/CULLED`. Separates "not in draw
|
|
||||||
set" from "present-but-culled." A one-line `[lb] ADD` in `GpuWorldState.AddLandblock`
|
|
||||||
(added then stripped) exposed the streaming hole as a missing Y-band — re-add
|
|
||||||
it if you chase another streaming gap.
|
|
||||||
- `ACDREAM_DUMP_MOTION=1` — every inbound `UM` (guid, stance, cmd, speed) + the
|
|
||||||
resulting `SetCycle` + `[MOTIONDONE]`. THE tool for #170 — it's how the
|
|
||||||
attack-stream-over-chase was found.
|
|
||||||
|
|
||||||
## Session gotchas (environment)
|
|
||||||
- **The test character `+Acdream` (0x5000000A) is saved OUT in the wilderness**
|
|
||||||
(landblock ~`0xADAF`/`0xAEAE`, ~1 km SE of Holtburg), from an earlier run of
|
|
||||||
the #168 disappear bug. It spawns there every login. **With the #169 fix that
|
|
||||||
now loads correctly** (far login-spawn ForceReloadWindows), so it's testable —
|
|
||||||
and there are Mite Scamps + a Mosswart Feeder right at the spawn (handy for
|
|
||||||
#170). To get back to Holtburg, the user GM-teleports.
|
|
||||||
- **Client lifecycle: the USER manages it.** Launch with plain `dotnet run
|
|
||||||
--no-build` in the background (never pre-close/kill). If a rebuild is locked by
|
|
||||||
a running client (`The file is locked by AcDream.App`), ASK the user to close
|
|
||||||
it — don't kill it.
|
|
||||||
- **ACE stale session:** a hard-closed or rapidly-relaunched client leaves ACE
|
|
||||||
thinking the character is still in-world → `session failed: CharacterList not
|
|
||||||
received` for ~3 min. Graceful close (window X / WM_CLOSE, exit 0) clears in
|
|
||||||
~5 s. Don't retry-spam; wait ~160 s if it's stuck. This session hit it once
|
|
||||||
from rapid relaunches.
|
|
||||||
- PowerShell `Tee-Object`/`Out-File` write UTF-16 → the plain-text grep needs
|
|
||||||
`tr -d '\000'` first (the launch logs read as UTF-16). Used throughout.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
- R5 decomp/plan: `docs/research/2026-07-03-r5-managers/` (decomp per manager,
|
|
||||||
ACE cross-ref, port plan, wiring handoff).
|
|
||||||
- ISSUES: #167 (constraint leash), #168/#169 (DONE this session), #170 (creature
|
|
||||||
animation), #159 (combat command numbering), #160/#165/#166 (remote-motion).
|
|
||||||
- Memory: `feedback_streaming_residence_race`, and the R5 research index entry in
|
|
||||||
`MEMORY.md` (animation-sequencer deep-dive line).
|
|
||||||
- Roadmap Phase R: R5-V1/V2 shipped; V3/V4 next.
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
# Handoff — #170 creature chase renders as slide (PARTIAL FIX landed; residual = "sustain the run") — 2026-07-04
|
|
||||||
|
|
||||||
> **⚠ SUPERSEDED (2026-07-04, second session): the residual is FIXED — pending visual gate.**
|
|
||||||
> The "Ready stop-node backlog drains a beat slower" framing below was DISPROVEN by a
|
|
||||||
> full-stack offline harness (`tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs`)
|
|
||||||
> plus corrected per-guid attribution of the launch-drainq.log evidence. The Core
|
|
||||||
> drain/turn/run machinery is healthy; both handoff hypotheses ((a) tick counts,
|
|
||||||
> (b) drain trigger rate) are moot. The real mechanism: the per-tick branch arbitration
|
|
||||||
> sent any UP-receiving NPC down the SERVERVEL leg, which **skips `MoveToManager.UseTime`**
|
|
||||||
> — the armed moveto was starved for the whole server-side chase (funnel: 16 arms →
|
|
||||||
> 11 dispatched turns → 1 run install), legs stayed Ready while the body glided on
|
|
||||||
> UP-synthesized velocity. Retail runs `MovementManager::UseTime` unconditionally
|
|
||||||
> (`UpdateObjectInternal` 0x005156b0 @0x00515998). Fix: armed movetos always take the
|
|
||||||
> MOVETO leg (GameWindow `TickAnimations`, `moveToArmed` gate; register TS-41 narrowed,
|
|
||||||
> TS-42 added for the one-frame drain-order divergence). Current status + next steps
|
|
||||||
> live in `docs/ISSUES.md` #170. This doc remains as the evidence record for the flood
|
|
||||||
> fix (`427332ac`) and the cdb apparatus.
|
|
||||||
|
|
||||||
Fresh session picks up HERE for #170. Worktree `vigorous-joliot-f0c3ad`, branch
|
|
||||||
`claude/vigorous-joliot-f0c3ad`. Tree CLEAN at **`427332ac`** (the partial fix +
|
|
||||||
env-gated probes). This session root-caused #170 end-to-end with **live retail cdb
|
|
||||||
tracing** + acdream runtime probes and landed a **verified partial fix**. One
|
|
||||||
residual remains (the run isn't fully sustained). This doc is the SSOT; the paste
|
|
||||||
prompt is `docs/research/2026-07-04-170-pickup-prompt.md`.
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
A monster chasing a fleeing player renders as a **slide** in acdream (glides toward
|
|
||||||
you in an idle/attack pose) vs **runs to close, stops to swing** in retail (same
|
|
||||||
local ACE). Root cause, proven:
|
|
||||||
|
|
||||||
- The chase **run cycle is manufactured client-side** from the `mt-6` MoveToObject
|
|
||||||
stream: `HandleUpdateTarget → MoveToObject_Internal → (TurnToHeading node
|
|
||||||
completes via UseTime) → BeginMoveForward → _DoMotion(RunForward)` sets the
|
|
||||||
motion-table **substate = RunForward** (the legs). `RunForward` is NEVER on the
|
|
||||||
wire — the server only sends mt-6 + the attack UMs.
|
|
||||||
- `MoveToManager.BeginTurnToHeading` (retail `0x00529b90`) bails while
|
|
||||||
`CMotionInterp.motions_pending` is non-empty (**retail-faithful guard**).
|
|
||||||
- acdream's `pending_motions` **exploded to ~1.3M entries** because the NPC
|
|
||||||
per-tick called `rm.Motion.apply_current_movement` **every frame**, re-dispatching
|
|
||||||
the whole interpreted state (stance + attack + stops) and appending a
|
|
||||||
`pending_motions` node each time. `MotionsPending()` stayed permanently true →
|
|
||||||
the chase turn never started → `BeginMoveForward/RunForward` ~never fired → slide.
|
|
||||||
- **FIX (landed `427332ac`):** delete the per-frame `apply_current_movement` in the
|
|
||||||
grounded remote-NPC path (`GameWindow.cs` ~9992). Retail dispatches per motion
|
|
||||||
**event** (per UM), never per frame.
|
|
||||||
|
|
||||||
**Result (verified live):** flood 1.3M → depth ~1 (add≈done); "stuck in attack
|
|
||||||
animation" GONE (user-confirmed); run cycle installs (`BeginMoveForward` 1→10,
|
|
||||||
`RunForward` held 0→7). **PARTIAL** — still not fully sustained (below).
|
|
||||||
|
|
||||||
## The one remaining residual (= the next session's job)
|
|
||||||
|
|
||||||
The run isn't sustained: `BeginTurnToHeading` is still blocked
|
|
||||||
`motionsPending=True` **256/272 (94%)** of the time, because a small **`Ready`
|
|
||||||
(0x41000003) stop-node backlog** keeps `pending_motions` from ever fully emptying
|
|
||||||
between swings. acdream gets ~10 run-starts to **retail's 21**, so it now
|
|
||||||
**twitches forward + glides** (short run bursts + idle) instead of a clean
|
|
||||||
run-then-stop.
|
|
||||||
|
|
||||||
**Where the `Ready` backlog comes from (traced, not guessed):**
|
|
||||||
- `MotionInterpreter.StopInterpretedMotion` (`MotionInterpreter.cs:3254`) appends a
|
|
||||||
`Ready` `pending_motions` node (line 3292) whenever the stop **succeeds**
|
|
||||||
(`sink.StopMotion` → `CMotionTable.StopSequenceMotion` returns true).
|
|
||||||
- `StopSequenceMotion` (`CMotionTable.cs:559`) returns **false** for a *redundant*
|
|
||||||
sidestep/turn stop (Case A: not our substate; Case B: no matching modifier) — so
|
|
||||||
those correctly DON'T queue `Ready`. The `Ready` nodes that DO accumulate come
|
|
||||||
from stops that **succeed**: chiefly the per-UM tail
|
|
||||||
`StopInterpretedMotion(TurnRight)` (`MotionInterpreter.cs:3008`) stopping the
|
|
||||||
MoveTo's OWN steering turn, plus the MoveTo-cancel `StopCompletely`.
|
|
||||||
- Those `Ready` nodes drain via `MotionDone` (fired 1:1 by `MotionTableManager`
|
|
||||||
per completed `_pendingAnimations` entry — `AnimationDone` per AnimDone hook
|
|
||||||
`GameWindow.cs:10306` + `UseTime`→`CheckForCompletedMotions` 0-tick sweep at
|
|
||||||
`10309`). They drain **a beat slower** than they add → backlog grows (lag 1→10
|
|
||||||
over a chase). Retail hits `add_to_queue == MotionDone` **exactly** (cdb-proven).
|
|
||||||
|
|
||||||
**The decisive open question for the fix:** WHY do acdream's `Ready` stop-nodes
|
|
||||||
drain slower than retail's? Two concrete hypotheses to test with a retail cdb
|
|
||||||
trace (retail is the oracle; do NOT guess in this verbatim-ported R2/R3 machinery):
|
|
||||||
1. **Tick count.** acdream's `Ready` stop entries have `outTicks > 0` (from
|
|
||||||
`StopSequenceMotion` Case A → `GetObjectSequence(styleDefault, stopCall:true,
|
|
||||||
out outTicks)`), so they wait for `AnimationDone` instead of draining
|
|
||||||
immediately via the per-frame 0-tick `CheckForCompletedMotions`. Retail's may be
|
|
||||||
0-tick. → cdb: break `CMotionTable::StopObjectMotion`/`GetObjectSequence` on a
|
|
||||||
live chasing monster, read the returned tick count for a turn-stop.
|
|
||||||
2. **Drain trigger rate.** Retail may fire `CheckForCompletedMotions`/`MotionDone`
|
|
||||||
more aggressively than acdream's once-per-frame `Manager.UseTime`. → cdb: count
|
|
||||||
`CMotionInterp::MotionDone` vs `add_to_queue` (already have the script:
|
|
||||||
`scratchpad/cdb-drain.cdb` — retail = add==done) and add
|
|
||||||
`CPhysicsObj::CheckForCompletedMotions`/`MotionTableManager::UseTime` counts.
|
|
||||||
|
|
||||||
Target acceptance: acdream's `pending_motions` drains to `add==done` (fully empties
|
|
||||||
between swings like retail) → `BeginTurnToHeading` proceeds per mt-6 arm →
|
|
||||||
`BeginMoveForward ≈ MoveToObject` (retail was 21≈22) → the creature runs to close
|
|
||||||
distance, plants to swing. **Visual gate:** user runs acdream + retail side-by-side.
|
|
||||||
|
|
||||||
## Evidence table (live traces this session)
|
|
||||||
|
|
||||||
| Metric | retail (cdb) | acdream BEFORE fix | acdream AFTER fix (`427332ac`) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `pending_motions` add vs done | **254 == 254** | 1.37M vs 5.7K | 425 vs 424 |
|
|
||||||
| max `pending_motions` depth | shallow | **1,332,575** | ~1–2 |
|
|
||||||
| `BeginMoveForward` (run installs) / chase | 21 | 1 | 10 |
|
|
||||||
| `MoveToObject` arms / chase | 22 | (7 in an attack-heavy cap) | 32 |
|
|
||||||
| `HandleUpdateTarget` (voyeur re-drive) | 689 | 44 | fires |
|
|
||||||
| `BeginTurnToHeading` motionsPending True/False | (empties often) | n/a | **256 / 16** |
|
|
||||||
| lingering queue contents | — | 0x8000003C×671K (stance) | **0x41000003 (Ready) backlog** |
|
|
||||||
|
|
||||||
## Apparatus (all in place at `427332ac`; reuse, then strip when #170 closes)
|
|
||||||
|
|
||||||
**acdream env-gated probes** (`ACDREAM_MVTO_DIAG=1`, alongside `ACDREAM_DUMP_MOTION=1`):
|
|
||||||
- `MoveToManager.cs` `s_mvtoDiag`: `[mvto] BeginMoveForward cmd=…`, `HandleUpdateTarget
|
|
||||||
… match=… init=… mtState=…`, `CancelMoveTo (real) wasType=…`, `UseTime enter/dispatch
|
|
||||||
node=…`, `BeginTurnToHeading motionsPending=… curCmd=…`.
|
|
||||||
- `MotionInterpreter.cs` `s_drainDiag`: `[drain] add=… done=… lag=… depth=…` (aggregate
|
|
||||||
add_to_queue vs MotionDone) + `[drainq] depth=… q=[…]` (queue CONTENTS when depth≥2 —
|
|
||||||
this is what named `Ready` as the lingering node).
|
|
||||||
- `GameWindow.cs`: `[npc-tick] guid=… branch=SERVERVEL|MOVETO` (which per-tick branch),
|
|
||||||
+ the earlier `UM ↳ actions …` inbound-action dump (capture aid).
|
|
||||||
|
|
||||||
**Launch (user manages lifecycle; graceful close):**
|
|
||||||
```
|
|
||||||
$env:ACDREAM_DUMP_MOTION="1"; $env:ACDREAM_MVTO_DIAG="1" # + ACDREAM_LIVE/DAT_DIR/host/port/user/pass
|
|
||||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
|
|
||||||
```
|
|
||||||
In-world: **aggro a Mite Scamp at the wilderness spawn (~0xADAF), RUN AWAY so it
|
|
||||||
CHASES** (the bug is the chase, not attack-in-place — no chase ⇒ no `[mvto]` lines).
|
|
||||||
Logs are UTF-16 → `tr -d '\000'` before grep.
|
|
||||||
|
|
||||||
**Retail cdb toolchain** (Step -1; retail is the oracle for the residual):
|
|
||||||
- Binary `C:\Turbine\Asheron's Call\acclient.exe` MATCHES `refs/acclient.pdb`
|
|
||||||
(`py tools/pdb-extract/check_exe_pdb.py "…/acclient.exe"`). cdb at
|
|
||||||
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
|
|
||||||
- Working scripts saved in the scratchpad this session: `cdb-lookup.cdb` (symbol
|
|
||||||
names), `cdb-count.cdb` / `cdb-chase.cdb` (BeginMoveForward/MoveToObject/
|
|
||||||
HandleUpdateTarget/RunForward-dispatch counts), `cdb-drain.cdb`
|
|
||||||
(add_to_queue vs MotionDone vs motions_pending). Pattern: count + `gc`,
|
|
||||||
auto-`qd` after N `move_to_interpreted_state` hits (user makes a monster chase
|
|
||||||
retail during the trace). Key addrs: `MoveToManager::BeginMoveForward 0x00529a00`,
|
|
||||||
`_DoMotion 0x00529010`, `CancelMoveTo 0x00529930`, `MoveToObject 0x00529680`,
|
|
||||||
`HandleUpdateTarget 0x0052a7d0`, `UseTime 0x0052a780`;
|
|
||||||
`CMotionInterp::move_to_interpreted_state 0x005289c0`, `DoInterpretedMotion
|
|
||||||
0x00528360`, `add_to_queue 0x00527b80`, `MotionDone 0x00527ec0`, `motions_pending
|
|
||||||
0x00527fe0`, `get_state_velocity 0x00527d50`.
|
|
||||||
|
|
||||||
## DO-NOT-RETRY / superseded
|
|
||||||
|
|
||||||
- **The `eb423fb7` "MovementManager coexistence / R5-V4" hypothesis is WRONG** — the
|
|
||||||
attack UM cancelling the MoveTo is retail-faithful and NOT the cause. Superseded by
|
|
||||||
this doc. (Register/ISSUES updated.)
|
|
||||||
- The `d2ccc80e` velocity fix (`get_state_velocity` → `set_local_velocity` each
|
|
||||||
grounded tick) is CORRECT but **position-only** — it does NOT fix the legs; keep it.
|
|
||||||
- `CombatAnimationPlanner` (#159, fixed `2de5a011`) was a **red herring** for the
|
|
||||||
Mite Scamp (its 0x62/63/64 attacks were always in the correct block; the planner
|
|
||||||
is unwired). Real #170 is the MoveTo/pending_motions chain above.
|
|
||||||
- `BeginTurnToHeading`'s `if (motions_pending) return` guard is **retail-faithful** —
|
|
||||||
do NOT patch it. Fix the DRAIN so the queue empties, don't remove the guard.
|
|
||||||
- The per-frame `apply_current_movement` deletion is the ROOT fix for the flood
|
|
||||||
(retail dispatches per UM). Do NOT re-add it.
|
|
||||||
|
|
||||||
## Key file:line map
|
|
||||||
|
|
||||||
- `GameWindow.cs` ~9982 (grounded remote-NPC branch; the deleted per-frame
|
|
||||||
apply_current_movement + the kept `get_state_velocity` velocity refresh);
|
|
||||||
~10306/10309 (the pending-motions DRAIN: `Manager.AnimationDone` per AnimDone
|
|
||||||
hook + `Manager.UseTime`); ~4251 `EnsureRemoteMotionBindings` (MoveToManager +
|
|
||||||
TargetManager voyeur wiring, `getSelfId=serverGuid` correct).
|
|
||||||
- `MoveToManager.cs`: `BeginMoveForward:741`, `BeginTurnToHeading:821`
|
|
||||||
(`if (_interp.MotionsPending()) return` at ~833), `HandleTurnToHeading:1164`,
|
|
||||||
`HandleUpdateTarget:1229`, `MoveToObject_Internal:1333`, `UseTime:953`,
|
|
||||||
`CancelMoveTo:1475`.
|
|
||||||
- `MotionInterpreter.cs`: `AddToQueue:2297` (pending_motions add),
|
|
||||||
`MotionsPending:2298`, `MotionDone:2318` (pops one head),
|
|
||||||
`ApplyInterpretedMovement:2920` (style→forward→sidestep-stop→turn-stop),
|
|
||||||
`StopInterpretedMotion:3254` (adds `Ready` on success, line 3292).
|
|
||||||
- `CMotionTable.cs`: `StopSequenceMotion:559` (false for redundant stop),
|
|
||||||
`StopObjectMotion:640`, `GetObjectSequence` (Branch 1 substate write; Branch 3
|
|
||||||
action overlay — verified faithful).
|
|
||||||
- `MotionTableManager.cs`: `AnimationDone:290`, `CheckForCompletedMotions:322`,
|
|
||||||
`UseTime:342`, `PerformMovement:409` (StopInterpreted → AddToQueue(Ready,ticks)).
|
|
||||||
|
|
||||||
## Session gotchas
|
|
||||||
- Client + retail both hit the SAME local ACE (127.0.0.1:9000). acdream char
|
|
||||||
`+Acdream` (0x5000000A) spawns in the wilderness (~0xADAF) with Mite Scamps.
|
|
||||||
- Two workflow tracers returned junk/schema-cap this session; the synthesis+verify
|
|
||||||
carried it BUT its "run cycle from mt-6/BeginMoveForward" map was later REFUTED for
|
|
||||||
attack-in-place (BeginMoveForward=0) and CONFIRMED only for the CHASE — always
|
|
||||||
trace the CHASE scenario, and trust the live cdb over the decomp synthesis.
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# Pickup prompt — #170 "sustain the creature run" (paste into a fresh session)
|
|
||||||
|
|
||||||
Read `docs/research/2026-07-04-170-creature-run-handoff.md` first — it is the SSOT
|
|
||||||
for this task and carries the full evidence, the retail cdb numbers, the apparatus,
|
|
||||||
and the DO-NOT-RETRY list. Then continue #170.
|
|
||||||
|
|
||||||
**Where we are:** the primary #170 bug is FIXED and user-verified at `427332ac`
|
|
||||||
(branch `claude/vigorous-joliot-f0c3ad`). A per-frame `apply_current_movement`
|
|
||||||
re-dispatch was flooding `CMotionInterp.pending_motions` to ~1.3M entries, which
|
|
||||||
kept `MotionsPending()` permanently true and blocked the MoveTo chase turn
|
|
||||||
(`BeginTurnToHeading`), so a chasing creature slid in an idle+attack pose instead
|
|
||||||
of running. Deleting that per-frame call (GameWindow ~9992) dropped the queue to
|
|
||||||
depth ~1 (add≈done), killed the "stuck in attack animation", and the run cycle now
|
|
||||||
installs (`BeginMoveForward` 1→10). The "stuck attack" is confirmed gone by the user.
|
|
||||||
|
|
||||||
**Your job — the ONE remaining residual: "sustain the run."** The run isn't
|
|
||||||
sustained yet: `BeginTurnToHeading` is still blocked `motionsPending=True` ~94% of
|
|
||||||
the time because a small **`Ready` (0x41000003) stop-node backlog** keeps
|
|
||||||
`pending_motions` from ever fully emptying between swings. acdream gets ~10
|
|
||||||
run-starts vs retail's 21, so the creature now twitches-forward + glides instead of
|
|
||||||
a clean run-then-stop. Retail hits `add_to_queue == MotionDone` EXACTLY (cdb-proven);
|
|
||||||
acdream's `Ready` stop-nodes drain a beat slower.
|
|
||||||
|
|
||||||
**Do this, in order:**
|
|
||||||
1. Re-read the handoff's "residual" + "DO-NOT-RETRY" sections. In particular: the
|
|
||||||
`BeginTurnToHeading` `if (motions_pending) return` guard is retail-faithful — DO
|
|
||||||
NOT patch it; fix the DRAIN so the queue empties like retail.
|
|
||||||
2. **Retail is the oracle** (Step -1). Retail's cdb is available; the binary matches
|
|
||||||
`refs/acclient.pdb`. Trace the `Ready`-stop drain on a live chasing monster to
|
|
||||||
settle the two hypotheses in the handoff: (a) do acdream's `Ready` stop entries
|
|
||||||
carry `outTicks > 0` (so they wait for `AnimationDone` instead of the per-frame
|
|
||||||
0-tick `CheckForCompletedMotions`) while retail's are 0-tick? (b) does retail fire
|
|
||||||
`CheckForCompletedMotions`/`MotionDone` more aggressively than acdream's
|
|
||||||
once-per-frame `Manager.UseTime`? Reuse `scratchpad/cdb-drain.cdb` +
|
|
||||||
the addrs in the handoff. Ask the user to make a monster chase their retail char
|
|
||||||
during the trace.
|
|
||||||
3. The env-gated acdream probes are already in place (`ACDREAM_MVTO_DIAG=1`) —
|
|
||||||
`[drainq]` dumps the queue contents (this is what named `Ready` as the lingering
|
|
||||||
node). Capture a chase (aggro + RUN AWAY) to compare against retail.
|
|
||||||
4. Implement the faithful drain fix (verbatim-ported R2/R3 machinery — get it EXACTLY
|
|
||||||
right, no redesign; this is the revert-prone area). Acceptance: acdream
|
|
||||||
`add==done` (queue fully empties between swings) → `BeginMoveForward ≈ MoveToObject`
|
|
||||||
per chase → creature runs to close distance, plants to swing. **Visual-gate with
|
|
||||||
the user (retail side-by-side)** — that is the acceptance test.
|
|
||||||
5. When it lands + is visually confirmed: **STRIP all the temporary #170 probes**
|
|
||||||
(`s_mvtoDiag` in MoveToManager.cs, `s_drainDiag` in MotionInterpreter.cs, the
|
|
||||||
`[npc-tick]` lines + the `UM ↳ actions` dump in GameWindow.cs), move #170 to
|
|
||||||
Recently-closed in `docs/ISSUES.md` with the SHAs, and update the animation
|
|
||||||
research/memory index.
|
|
||||||
|
|
||||||
**Gotchas:** both acdream (+Acdream char) and retail hit the same local ACE
|
|
||||||
(127.0.0.1:9000); trace the CHASE (player fleeing), not attack-in-place — no chase ⇒
|
|
||||||
no `[mvto]` lines; PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep); user
|
|
||||||
manages client lifecycle (graceful close). Trust the live cdb over the earlier
|
|
||||||
workflow synthesis (its "run from mt-6" map was refuted for attack-in-place).
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# Pickup prompt — #171 sticky melee / R5-V3 (paste into a fresh session)
|
|
||||||
|
|
||||||
Read `docs/research/2026-07-04-171-sticky-melee-handoff.md` first — it is the
|
|
||||||
SSOT for this task (root causes, retail anchors, approved scope, gotchas).
|
|
||||||
Then implement R5-V3.
|
|
||||||
|
|
||||||
**Context:** #170 (chase slide) is CLOSED — gate passed at `4cad626f`; do not
|
|
||||||
reopen that machinery. During its gate the user observed #171: in a pack
|
|
||||||
melee, monsters sit partly inside each other with slightly stale facings vs
|
|
||||||
retail on the same ACE. Investigated + **fix approved by the user 2026-07-04**.
|
|
||||||
|
|
||||||
**The two causes (both R5-V3 scope, already register-tracked):**
|
|
||||||
1. Sticky melee is a no-op (TS-39): ACE arms melee chases with `Sticky`;
|
|
||||||
retail's arrival hands off to `PositionManager::StickTo` →
|
|
||||||
`StickyManager::adjust_offset` (0x00555430, per-tick 0.3 m gap + facing
|
|
||||||
tracking). Our `MoveToManager.StickTo`/`Unstick` seams are unbound —
|
|
||||||
attackers freeze at stale arrival poses.
|
|
||||||
2. Arrival radii are zero: `getOwnRadius: () => 0f` (the R5-V3 pin in
|
|
||||||
`EnsureRemoteMotionBindings`) + `RouteServerMoveTo` never sets
|
|
||||||
`MovementStruct.Radius/Height` — retail/ACE arrive edge-to-edge with
|
|
||||||
setup-derived radii (ACE `PhysicsObj.MoveToObject` reads the TARGET's
|
|
||||||
PartArray radius/height).
|
|
||||||
|
|
||||||
**Do, in order:**
|
|
||||||
1. Grep the named decomp for `StickyManager::` + `PositionManager::` bodies
|
|
||||||
(adjust_offset 0x00555430; per-tick anchors already pinned:
|
|
||||||
`PositionManager::adjust_offset` @0x00512d0e inside UpdatePositionInternal,
|
|
||||||
`PositionManager::UseTime` @0x005159b3 in UpdateObjectInternal).
|
|
||||||
Pseudocode, then port verbatim; cross-check
|
|
||||||
`references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,PositionManager}.cs`.
|
|
||||||
Groundwork already in tree: `MoveToMath.CylinderDistanceNoZ` /
|
|
||||||
`GlobalToLocalVec` / `NormalizeCheckSmall`; `ConstraintManager` (R5-V1) is
|
|
||||||
the port-style reference. Do NOT arm ConstraintManager (#167 separate).
|
|
||||||
2. Bind `StickTo`/`UnStick` in `EnsureRemoteMotionBindings` AND the player
|
|
||||||
wiring; wire PositionManager into the tick at the retail-matching points.
|
|
||||||
3. Thread real cylsphere radii (own from the entity's Setup; target resolved
|
|
||||||
at `RouteServerMoveTo`'s MoveToObject branch). Sweep remote + player.
|
|
||||||
4. Same-commit register bookkeeping: retire TS-39, update the radius pin.
|
|
||||||
5. Extend `RemoteChaseEndToEndHarnessTests` with a sticky scenario (arrive →
|
|
||||||
target strafes → follower tracks gap + facing; UnStick on next
|
|
||||||
PerformMovement). Harness bodies MUST mirror the live RemoteMotion
|
|
||||||
construction (InWorld=true, RemoteWeenie — see RemoteChaseDrainBisectTests).
|
|
||||||
6. `dotnet build` + full `dotnet test` green → commit → launch for the user's
|
|
||||||
visual gate: pack melee side-by-side vs retail, strafe around the pack;
|
|
||||||
attackers should reshuffle + keep facing like retail. Acceptance is retail
|
|
||||||
PARITY — some overlap is ACE-server-side and shows on retail too.
|
|
||||||
|
|
||||||
**Gotchas:** revert-prone remote-motion area — verbatim port, no redesign;
|
|
||||||
per-entity probes must print the guid (memory
|
|
||||||
`feedback_probe_identity_attribution`); PowerShell Tee logs are UTF-16
|
|
||||||
(`tr -d '\000'` before grep); the user manages client lifecycle (graceful
|
|
||||||
close); both clients hit the same local ACE (127.0.0.1:9000).
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Handoff — #171 group-melee interpenetration + facing drift (R5-V3: StickyManager + arrival radii) — 2026-07-04
|
|
||||||
|
|
||||||
Fresh session picks up HERE for #171. Worktree `vigorous-joliot-f0c3ad`, branch
|
|
||||||
`claude/vigorous-joliot-f0c3ad`, tree CLEAN after the #170 close-out
|
|
||||||
(`4cad626f`). The fix scope below is USER-APPROVED ("Ok I approve",
|
|
||||||
2026-07-04). Pickup prompt: `docs/research/2026-07-04-171-pickup-prompt.md`.
|
|
||||||
|
|
||||||
## Symptom (user report, #170 gate session)
|
|
||||||
|
|
||||||
In a pack melee vs the player, acdream monsters end up **partly inside each
|
|
||||||
other** with **slightly stale facings**, vs retail on the same local ACE.
|
|
||||||
The #170 chase itself (turn → sustained run → plant → swing) is CLOSED and
|
|
||||||
gate-passed — do not reopen that machinery.
|
|
||||||
|
|
||||||
## Root causes (code-grounded, investigated 2026-07-04)
|
|
||||||
|
|
||||||
1. **Sticky melee is a no-op — register TS-39.** ACE arms EVERY melee chase
|
|
||||||
with `Sticky | UseFinalHeading | MoveAway | FailWalk`
|
|
||||||
(`references/ACE/Source/ACE.Server/WorldObjects/Monster_Navigation.cs:406-419`).
|
|
||||||
The retail arrival chain: `MoveToManager::BeginNextNode` (sticky bit set,
|
|
||||||
queue empty) reads `SoughtObjectId/Radius/Height` BEFORE `CleanUp` zeroes
|
|
||||||
them (that ordering IS already ported — `MoveToManager.cs` ~700-714) and
|
|
||||||
calls `PositionManager::StickTo(tlid, radius, height)`; from then on
|
|
||||||
`StickyManager::adjust_offset` (0x00555430) runs per tick and holds a
|
|
||||||
~0.3 m edge gap + facing against the moving target. Every
|
|
||||||
`MovementManager::PerformMovement` head calls `unstick_from_object` →
|
|
||||||
`PositionManager::UnStick` (our `MoveToManager.PerformMovement:414`
|
|
||||||
invokes the seam). acdream's `StickTo`/`Unstick` seams are UNBOUND
|
|
||||||
(GameWindow `EnsureRemoteMotionBindings` + the player wiring bind
|
|
||||||
neither) → attackers complete-and-freeze at stale arrival poses until the
|
|
||||||
next wire re-arm. This is the direct cause of BOTH observed symptoms.
|
|
||||||
|
|
||||||
2. **Arrival radii are zero.** Retail/ACE arrive EDGE-TO-EDGE: ACE
|
|
||||||
`PhysicsObj.MoveToObject` (`references/ACE/…/Physics/PhysicsObj.cs:936-956`)
|
|
||||||
reads the TARGET's `PartArray.GetRadius()/GetHeight()` and passes them into
|
|
||||||
`MoveToObject_Internal`; the mover's own radius feeds
|
|
||||||
`GetCurrentDistance`'s `cylinder_distance` when `UseSpheres` (ACE default
|
|
||||||
TRUE, `DistanceToObject` default 0.6). acdream:
|
|
||||||
- `GameWindow.EnsureRemoteMotionBindings` binds `getOwnRadius/getOwnHeight
|
|
||||||
: () => 0f` — the comment IS the R5-V3 pin ("setup cylsphere radius lands
|
|
||||||
with R5-V3");
|
|
||||||
- `GameWindow.RouteServerMoveTo` never sets `MovementStruct.Radius/Height`
|
|
||||||
(target radii) → 0.
|
|
||||||
Net: every attacker closes ~one body-radius deeper than retail → dogpile.
|
|
||||||
|
|
||||||
3. **Server-side caveat:** ACE has no monster-vs-monster avoidance; UP
|
|
||||||
hard-snaps plant bodies where ACE says (retail hard-snaps identically).
|
|
||||||
Some overlap is server-authoritative and visible on retail too —
|
|
||||||
**acceptance is retail parity, not zero overlap.**
|
|
||||||
|
|
||||||
Ruled out: the between-snap collision sweep. Remotes run the full
|
|
||||||
`ResolveWithTransition` (GameWindow ~10100) and `CollisionExemption` keeps
|
|
||||||
creature cylinders collidable for non-viewer movers.
|
|
||||||
|
|
||||||
## Approved fix scope (= the R5-V3 slice, roadmap-aligned)
|
|
||||||
|
|
||||||
1. **Port `StickyManager` verbatim** (grep `docs/research/named-retail/
|
|
||||||
acclient_2013_pseudo_c.txt` for `StickyManager::` first — `StickTo`,
|
|
||||||
`UnStick`, `adjust_offset` 0x00555430, `HandleUpdateTarget`; cross-check
|
|
||||||
`references/ACE/Source/ACE.Server/Physics/Managers/StickyManager.cs`).
|
|
||||||
Groundwork ALREADY in tree (`MoveToMath`): `CylinderDistanceNoZ` (signed —
|
|
||||||
the adjust_offset gap math), `GlobalToLocalVec`, `NormalizeCheckSmall` —
|
|
||||||
all documented as adjust_offset consumers. `ConstraintManager` (the sibling
|
|
||||||
PositionManager member) was ported R5-V1 (TS-35, unarmed — #167 is
|
|
||||||
SEPARATE, do not arm it here).
|
|
||||||
2. **PositionManager**: the facade that owns Sticky + Constraint
|
|
||||||
(`references/ACE/…/Managers/PositionManager.cs`). Retail per-tick anchors
|
|
||||||
pinned this session from the named decomp:
|
|
||||||
- `PositionManager::adjust_offset` called inside
|
|
||||||
`CPhysicsObj::UpdatePositionInternal` @0x00512d0e (BEFORE process_hooks);
|
|
||||||
- `PositionManager::UseTime` called in `UpdateObjectInternal` @0x005159b3
|
|
||||||
(AFTER `CPartArray::HandleMovement`).
|
|
||||||
Wire the acdream equivalents at the matching points of the remote tick
|
|
||||||
(GameWindow `TickAnimations`) and the player path.
|
|
||||||
3. **Bind the seams**: `MoveToManager.StickTo` → PositionManager.StickTo;
|
|
||||||
`MoveToManager.Unstick` → PositionManager.UnStick — in
|
|
||||||
`EnsureRemoteMotionBindings` AND the player wiring (`EnterPlayerModeNow`
|
|
||||||
area, ~13128). The TargetManager voyeur fan-out already delivers target
|
|
||||||
info; the R5-V2 comment at GameWindow ~4327 says exactly this:
|
|
||||||
"PositionManager sticky joins the fan-out in V3" — StickyManager is a
|
|
||||||
voyeur consumer in retail (its own HandleUpdateTarget).
|
|
||||||
4. **Thread real radii**: own radius/height from the entity's Setup (the
|
|
||||||
spawn path already reads `setup.Radius`/`setup.Height` for collision
|
|
||||||
registration, GameWindow ~4187); target radius/height at the
|
|
||||||
`RouteServerMoveTo` MoveToObject branch (resolve the target entity's
|
|
||||||
setup — retail reads the TARGET object, not the wire). Sweep BOTH the
|
|
||||||
remote and player MoveToManager bindings.
|
|
||||||
5. **Register bookkeeping (same commit):** retire TS-39; retire/narrow the
|
|
||||||
radius pin note; new rows for any adaptation introduced.
|
|
||||||
6. **Conformance:** extend `RemoteChaseEndToEndHarnessTests` with a sticky
|
|
||||||
scenario — arm a sticky MoveToObject, arrive, then MOVE the target
|
|
||||||
sideways: assert the follower tracks (gap ≈ stick distance, facing follows)
|
|
||||||
and `UnStick` fires on the next PerformMovement head. The harness already
|
|
||||||
models the full tick order; add a PositionManager step where retail has it.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Revert-prone area** (remote motion). Follow the workflow: grep named
|
|
||||||
decomp FIRST, pseudocode, port verbatim, ACE cross-check, harness, then
|
|
||||||
wire. No redesigns; the two prior #170 reverts were redesigns.
|
|
||||||
- The harness gotcha from #170: any new harness body MUST replicate the live
|
|
||||||
`RemoteMotion` construction (`InWorld=true`, `RemoteWeenie`,
|
|
||||||
Contact|OnWalkable|Active) or the TS-40 guard strips links and everything
|
|
||||||
wedges (`RemoteChaseDrainBisectTests` documents the shape).
|
|
||||||
- Per-entity probes must print the guid (memory:
|
|
||||||
`feedback_probe_identity_attribution` — the #170 misread).
|
|
||||||
- `StickyManager::adjust_offset` sign semantics: inside the gap the signed
|
|
||||||
distance goes NEGATIVE and the per-tick delta inverts (backs off) — ACE
|
|
||||||
StickyManager.cs:156, already noted on `MoveToMath.CylinderDistanceNoZ`.
|
|
||||||
- Do NOT arm ConstraintManager (#167 — two unknown x87 constants).
|
|
||||||
- Visual gate at the end: pack melee side-by-side vs retail; the user strafes
|
|
||||||
around the pack; attackers should reshuffle + keep facing like retail.
|
|
||||||
|
|
||||||
## Where things are (quick map)
|
|
||||||
|
|
||||||
- `MoveToManager.cs`: `PerformMovement:411` (Unstick head), `BeginNextNode`
|
|
||||||
sticky arrival ~700-714, `GetCurrentDistance` (UseSpheres cylinder math).
|
|
||||||
- `MoveToMath.cs`: `CylinderDistance`, `CylinderDistanceNoZ`,
|
|
||||||
`GlobalToLocalVec`, `NormalizeCheckSmall`.
|
|
||||||
- `GameWindow.cs`: `EnsureRemoteMotionBindings` ~4251 (seams + radius 0f pin),
|
|
||||||
`RouteServerMoveTo` ~4457 (MovementStruct — Radius/Height unset), remote
|
|
||||||
tick `TickAnimations` (PositionManager wiring points), player wiring ~13128.
|
|
||||||
- `ConstraintManager.cs` (R5-V1) — PositionManager sibling, reference for the
|
|
||||||
port style.
|
|
||||||
- Register rows: TS-39 (sticky seams), TS-35 (ConstraintManager), TS-41/42
|
|
||||||
(#170 rows, adjacent machinery).
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# Pickup prompt — R5-V5: MovementManager facade (paste into a fresh session)
|
|
||||||
|
|
||||||
> **DONE 2026-07-05.** Implemented as specified; suite 4052 green with the
|
|
||||||
> protected suites unmodified. Arc close-out lives in
|
|
||||||
> `2026-07-03-r5-managers/r5-wiring-handoff.md` (top banner).
|
|
||||||
|
|
||||||
Read `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` first (the
|
|
||||||
§V4 status note + the facade paragraph are the SSOT), then
|
|
||||||
`docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md` (the
|
|
||||||
retail struct + method inventory is already decompiled — do NOT re-decompile).
|
|
||||||
Then implement R5-V5.
|
|
||||||
|
|
||||||
**Context:** the R5 arc is one slice from done. Shipped + user-gated
|
|
||||||
2026-07-04: V3 sticky melee (#171, three slices `5bd2b8bc`/`7a823176`/
|
|
||||||
`69966950` — TS-39 retired, TS-43/TS-44/AP-82 added) and the V4 behavioral
|
|
||||||
items (`f423884b` — head style-on-change for all movement types, #164
|
|
||||||
action-replay Autonomous bit, mt-0 wire flags 0x1 stick_to_object / 0x2
|
|
||||||
StandingLongJump). Worktree `vigorous-joliot-f0c3ad`, branch
|
|
||||||
`claude/vigorous-joliot-f0c3ad`.
|
|
||||||
|
|
||||||
**The slice (STRUCTURAL — zero behavior change):** retail gives every
|
|
||||||
entity ONE `MovementManager` (acclient.h; decomp 0x00524xxx) that owns the
|
|
||||||
`motion_interpreter` + `moveto_manager` the R4/R5 work ported. acdream
|
|
||||||
carries them as loose per-entity objects (`RemoteMotion.Motion`/`.MoveTo`,
|
|
||||||
`PlayerMovementController.Motion`/`.MoveTo`) wired by hand at three sites
|
|
||||||
(`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the harness). Build the
|
|
||||||
owner class in `src/AcDream.Core/Physics/Motion/MovementManager.cs`:
|
|
||||||
|
|
||||||
1. Owns MotionInterpreter + MoveToManager (lazy `MakeMoveToManager` per
|
|
||||||
retail 0x005245b3) + the relay methods whose call shapes are already in
|
|
||||||
the decomp doc: `UseTime` (minterp → moveto order), `HitGround` (minterp
|
|
||||||
FIRST then moveto — 0x00524300, already wired inline at 3 sites),
|
|
||||||
`HandleExitWorld`, `CancelMoveTo`, `HandleUpdateTarget` (→ moveto).
|
|
||||||
2. Repoint the three wiring sites to construct/hold ONE MovementManager per
|
|
||||||
entity; the existing seam bindings (sinks, StickTo/Unstick,
|
|
||||||
UnstickFromObject, PositionManager handoff) move onto/through it
|
|
||||||
unchanged. GameWindow keeps the wire unpack (Core.Net types stay out of
|
|
||||||
Core.Physics).
|
|
||||||
3. Do NOT reshuffle the per-tick order (TS-42's documented inversion is R6
|
|
||||||
scope, not this slice), do NOT touch the #170/#171 gate-passed machinery,
|
|
||||||
do NOT absorb RouteServerMoveTo/the routing heads into Core.
|
|
||||||
|
|
||||||
**Acceptance:** `dotnet build` + full `dotnet test` green with the
|
|
||||||
183-case/funnel/moveto/chase/sticky suites UNMODIFIED (a structural slice
|
|
||||||
that needs a test edit is a red flag — stop and reassess). Register: no new
|
|
||||||
rows expected; update row source columns that name the old wiring shape.
|
|
||||||
No visual gate needed (zero behavior change) — but launch once and confirm
|
|
||||||
a creature still chases + a door still opens before calling it done.
|
|
||||||
|
|
||||||
**After this slice:** the R5 arc is DONE — update the roadmap/milestones
|
|
||||||
line, write the arc close-out in the wiring handoff, then return to M1.5's
|
|
||||||
critical path (#137 dungeon collision, #138 teleport-OUT, A7 lighting).
|
|
||||||
|
|
||||||
**Gotchas:** revert-prone area — structural moves only, no "while I'm
|
|
||||||
here" improvements; the three wiring sites are load-bearing and
|
|
||||||
comment-dense (keep the retail-anchor comments with the code they
|
|
||||||
describe); the harness (`RemoteChaseEndToEndHarnessTests`) mirrors
|
|
||||||
GameWindow's wiring field-for-field — update its construction to the
|
|
||||||
facade in the SAME commit or the mirror lies; PowerShell Tee logs are
|
|
||||||
UTF-16 (`tr -d '\000'` before grep); the user manages client lifecycle
|
|
||||||
(graceful close; if a rebuild is locked by a running client, ask).
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
# CCylSphere collision family — retail pseudocode (port prep)
|
|
||||||
|
|
||||||
**Date:** 2026-07-05 · **Trigger:** the Holtburg town-network portal platform
|
|
||||||
(stab `0xC0A9B465`, Setup `0x020019E3`, CylSphere r=2.597 m h=0.256 m) blocks
|
|
||||||
the player with an endless rim slide instead of the retail step-up-onto-top.
|
|
||||||
Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less stab
|
|
||||||
CylSpheres — the collision SHAPE is right; the RESPONSE family was never
|
|
||||||
ported. Feeds #137 (dungeon door feet flow through the same dispatcher).
|
|
||||||
|
|
||||||
**Sources:** named-retail pseudo-C (addresses below) = ground truth;
|
|
||||||
`references/ACE/Source/ACE.Server/Physics/CylSphere.cs` = cross-reference
|
|
||||||
(settles BN x87 garbles; one ACE bug found, noted in §8).
|
|
||||||
|
|
||||||
## Retail function inventory
|
|
||||||
|
|
||||||
| Function | Address | pseudo-C line |
|
|
||||||
|---|---|---|
|
|
||||||
| `CCylSphere::intersects_sphere(CTransition*)` — dispatcher | `0x0053b440` | :324558 |
|
|
||||||
| `CCylSphere::intersects_sphere(Position*, float scale, CTransition*)` — wrapper | `0x0053b8f0` | :324744 |
|
|
||||||
| `CCylSphere::collides_with_sphere` | `0x0053a880` | :323943 |
|
|
||||||
| `CCylSphere::normal_of_collision` | `0x0053ab50` | :324102 |
|
|
||||||
| `CCylSphere::collide_with_point` | `0x0053acb0` | :324173 |
|
|
||||||
| `CCylSphere::slide_sphere` | `0x0053b2a0` | :324502 |
|
|
||||||
| `CCylSphere::step_sphere_up` | `0x0053b310` | :324516 |
|
|
||||||
| `CCylSphere::land_on_cylinder` | `0x0053b3d0` | :324542 |
|
|
||||||
| `CCylSphere::step_sphere_down` | `0x0053a9b0` | :324032 |
|
|
||||||
| `COLLISIONINFO::set_contact_plane(plane, is_water)` | `0x00509d80` | :271925 |
|
|
||||||
|
|
||||||
## 1. Wrapper (0x0053b8f0) — globalize the cylinder
|
|
||||||
|
|
||||||
```
|
|
||||||
intersects_sphere(cyl, Position* objPos, float scale, CTransition* t):
|
|
||||||
SPHEREPATH::cache_localspace_sphere(&t->sphere_path, objPos, 1f)
|
|
||||||
world_cyl = { low_pt: objPos.localtoglobal(cyl.low_pt * scale),
|
|
||||||
radius: cyl.radius * scale,
|
|
||||||
height: cyl.height * scale }
|
|
||||||
return world_cyl.intersects_sphere(t) // axis stays world-Z
|
|
||||||
```
|
|
||||||
|
|
||||||
**acdream mapping:** `ShadowEntry` already stores the globalized base point
|
|
||||||
(`Position` = entity pos + rotated scaled local offset, registration sites in
|
|
||||||
`GameWindow.cs`) and pre-scaled Radius/CylHeight — the wrapper's work is done
|
|
||||||
at registration. `cache_localspace_sphere` matters only for
|
|
||||||
`localspace_pos` (used by step_sphere_up's normal rotation, §6).
|
|
||||||
|
|
||||||
## 2. collides_with_sphere (0x0053a880) — pure overlap test
|
|
||||||
|
|
||||||
```
|
|
||||||
collides_with_sphere(cyl, CSphere* sphere, Vector3* disp, float radsum):
|
|
||||||
// disp = sphere.center − cyl.low_pt (caller computes)
|
|
||||||
if (disp.x² + disp.y² <= radsum²) // XY overlap
|
|
||||||
halfH = cyl.height * 0.5
|
|
||||||
if (|halfH − disp.z| <= sphere.radius − F_EPSILON + halfH) // Z band
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
```
|
|
||||||
|
|
||||||
`radsum` at every call site = `cyl.radius − F_EPSILON + sphere.radius`
|
|
||||||
(ε shaved ONCE, in the dispatcher preamble). The ε is what makes "resting
|
|
||||||
exactly on the top" a non-overlap, so landings settle instead of re-colliding.
|
|
||||||
|
|
||||||
## 3. Dispatcher (0x0053b440)
|
|
||||||
|
|
||||||
```
|
|
||||||
intersects_sphere(cyl, CTransition* t): // cyl in world frame
|
|
||||||
sp = t.sphere_path; oi = t.object_info
|
|
||||||
s0 = sp.global_sphere[0]; disp0 = s0.center − low_pt
|
|
||||||
if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center − low_pt
|
|
||||||
radsum = cyl.radius − F_EPSILON + s0.radius
|
|
||||||
|
|
||||||
// ── branch 1: placement / ethereal — detection only ──
|
|
||||||
if (sp.insert_type == PLACEMENT_INSERT || sp.obstruction_ethereal):
|
|
||||||
if collides(s0, disp0) → COLLIDED
|
|
||||||
if num_sphere>1 && collides(s1, disp1) → COLLIDED
|
|
||||||
return OK
|
|
||||||
|
|
||||||
// ── branch 2: step-down probe — land on the top ──
|
|
||||||
if (sp.step_down): return step_sphere_down(t, s0, disp0, radsum)
|
|
||||||
|
|
||||||
// ── branch 3: walkable probe — cylinder occupancy blocks ──
|
|
||||||
if (sp.check_walkable):
|
|
||||||
if collides(s0, disp0) → COLLIDED
|
|
||||||
if num_sphere>1 && collides(s1, disp1) → COLLIDED
|
|
||||||
return OK
|
|
||||||
|
|
||||||
// ── branch 4: normal sweep (collide flag clear) ──
|
|
||||||
if (!sp.collide):
|
|
||||||
if (oi.state & (CONTACT|ON_WALKABLE)): // grounded
|
|
||||||
if collides(s0, disp0) → step_sphere_up(t, s0, disp0, radsum)
|
|
||||||
if num_sphere>1 && collides(s1, disp1)
|
|
||||||
→ slide_sphere(t, s1, disp1, radsum, sphereNum=1) // §8: retail passes disp1
|
|
||||||
elif (oi.state & PATH_CLIPPED):
|
|
||||||
if collides(s0, disp0) → collide_with_point(t, s0, disp0, radsum, 0)
|
|
||||||
else: // airborne
|
|
||||||
if collides(s0, disp0) → land_on_cylinder(t, s0, disp0, radsum)
|
|
||||||
if num_sphere>1 && collides(s1, disp1)
|
|
||||||
→ collide_with_point(t, s1, disp1, radsum, 1)
|
|
||||||
return OK
|
|
||||||
|
|
||||||
// ── branch 5: collide-flag re-test — exact-TOI cap landing ──
|
|
||||||
if collides(s0,disp0) || (num_sphere>1 && collides(s1,disp1)):
|
|
||||||
movement = sp.global_curr_center[0] − s0.center − block_offset(cur→check)
|
|
||||||
if |movement.z| < F_EPSILON → COLLIDED
|
|
||||||
timecheck = (height + s0.radius − disp0.z) / movement.z
|
|
||||||
offset = movement * timecheck
|
|
||||||
if radsum² < |xy(offset + disp0)|² → OK // rewound off the cap
|
|
||||||
t2 = (1 − timecheck) * sp.walk_interp
|
|
||||||
if t2 >= sp.walk_interp || t2 < −0.1 → COLLIDED
|
|
||||||
pt = s0.center + offset; pt.z −= s0.radius
|
|
||||||
ci.set_contact_plane(Plane(n=(0,0,1), d=−pt.z), is_water=1) // literal 1, §7
|
|
||||||
ci.contact_plane_cell_id = sp.check_pos.objcell_id
|
|
||||||
sp.walk_interp = t2
|
|
||||||
sp.add_offset_to_check_pos(offset)
|
|
||||||
return ADJUSTED
|
|
||||||
return OK
|
|
||||||
```
|
|
||||||
|
|
||||||
State bits (verified against our `ObjectInfoState`): CONTACT=0x1,
|
|
||||||
ON_WALKABLE=0x2, PATH_CLIPPED=0x8, PERFECT_CLIP=0x40.
|
|
||||||
|
|
||||||
## 4. step_sphere_down (0x0053a9b0) — land on the top during a step-down probe
|
|
||||||
|
|
||||||
```
|
|
||||||
step_sphere_down(t, s0, disp0, radsum):
|
|
||||||
if !collides(s0,disp0) && !(num_sphere>1 && collides(s1,disp1)) → OK
|
|
||||||
stepScale = sp.step_down_amt * sp.walk_interp
|
|
||||||
if |stepScale| < F_EPSILON → COLLIDED
|
|
||||||
deltaz = height + s0.radius − disp0.z // lift so bottom rests on top
|
|
||||||
interp = (1 − deltaz / stepScale) * sp.walk_interp // divisor = stepScale (BN garbled; ACE)
|
|
||||||
if interp >= sp.walk_interp || interp < −0.1 → COLLIDED
|
|
||||||
contactPt = (s0.center.x, s0.center.y, s0.center.z + deltaz − s0.radius)
|
|
||||||
ci.set_contact_plane(Plane(n=(0,0,1), d=−contactPt.z), is_water=1) // §7
|
|
||||||
ci.contact_plane_cell_id = sp.check_pos.objcell_id
|
|
||||||
sp.walk_interp = interp
|
|
||||||
sp.add_offset_to_check_pos((0,0,deltaz))
|
|
||||||
return ADJUSTED
|
|
||||||
```
|
|
||||||
|
|
||||||
This is THE missing piece that made step-up-onto-a-wide-cylinder impossible:
|
|
||||||
`CTransition::step_up`'s internal step-down probe needs branch 2 to produce a
|
|
||||||
walkable contact plane ON the cylinder top.
|
|
||||||
|
|
||||||
## 5. normal_of_collision (0x0053ab50)
|
|
||||||
|
|
||||||
```
|
|
||||||
normal_of_collision(cyl, sp, sphere, dispCheck, radsum, sphereNum, out n) → bool definite:
|
|
||||||
dispCurr = sp.global_curr_center[sphereNum] − low_pt
|
|
||||||
if (radsum² < dispCurr.x² + dispCurr.y²): // curr was XY-OUTSIDE → side hit
|
|
||||||
n = (dispCurr.x, dispCurr.y, 0) // radial, horizontal
|
|
||||||
// definite unless the contact could actually be a diagonal cap hit:
|
|
||||||
zBandOverlapAtCurr = |halfH − dispCurr.z| <= sphere.radius − F_EPSILON + halfH
|
|
||||||
noZMovement = |dispCurr.z − dispCheck.z| <= F_EPSILON
|
|
||||||
return zBandOverlapAtCurr || noZMovement
|
|
||||||
// curr was XY-INSIDE the footprint → cap hit
|
|
||||||
n = (0, 0, (dispCheck.z − dispCurr.z <= 0) ? +1 : −1) // descending → top (+1)
|
|
||||||
return true
|
|
||||||
```
|
|
||||||
|
|
||||||
Cap polarity settled by ACE + geometry (BN's x87 branch rendering is
|
|
||||||
untrustworthy here — [[feedback_bn_decomp_field_names]] class 2).
|
|
||||||
|
|
||||||
## 6. step_sphere_up (0x0053b310) / land_on_cylinder (0x0053b3d0) / slide_sphere (0x0053b2a0)
|
|
||||||
|
|
||||||
```
|
|
||||||
step_sphere_up(t, s0, disp0, radsum):
|
|
||||||
if (oi.step_up_height < s0.radius + height − disp0.z) // too tall
|
|
||||||
→ slide_sphere(t, s0, disp0, radsum, 0)
|
|
||||||
definite = normal_of_collision(..., 0, out n)
|
|
||||||
if normalize_check_small(n) → COLLIDED
|
|
||||||
nWorld = localspace_pos.localtoglobalvec(n) // rotate by the OBJECT's frame
|
|
||||||
if CTransition::step_up(t, nWorld) → OK
|
|
||||||
else → sp.step_up_slide(t)
|
|
||||||
|
|
||||||
land_on_cylinder(t, s0, disp0, radsum): // airborne foot hit
|
|
||||||
normal_of_collision(..., 0, out n)
|
|
||||||
if normalize_check_small(n) → COLLIDED
|
|
||||||
sp.set_collide(n) // backup + Collide flag
|
|
||||||
sp.walkable_allowance = LANDING_Z (0.0871557)
|
|
||||||
return ADJUSTED
|
|
||||||
|
|
||||||
slide_sphere(t, sphere, disp, radsum, sphereNum):
|
|
||||||
normal_of_collision(..., sphereNum, out n)
|
|
||||||
if normalize_check_small(n) → COLLIDED
|
|
||||||
return CSphere::slide_sphere(sphere, sp, ci, n, sp.global_curr_center[sphereNum])
|
|
||||||
```
|
|
||||||
|
|
||||||
The airborne landing closes through the retry loop: land_on_cylinder
|
|
||||||
(ADJUSTED, sets `sp.collide`) → next attempt → branch 5 exact-TOI rests the
|
|
||||||
sphere on the top + CP → next attempt → ε-shaved overlap now misses → OK →
|
|
||||||
TransitionalInsert Phase 3 `sp.Collide` placement re-test validates on the
|
|
||||||
CP → landing completes.
|
|
||||||
|
|
||||||
## 7. collide_with_point (0x0053acb0) — PathClipped / head-sphere hits
|
|
||||||
|
|
||||||
Port per ACE `CylSphere.CollideWithPoint` verbatim (self-contained TOI math):
|
|
||||||
non-PerfectClip movers → `set_collision_normal` + COLLIDED. PerfectClip →
|
|
||||||
exact time-of-impact reposition (`add_offset_to_check_pos`) + ADJUSTED, with
|
|
||||||
the not-definite branch deriving cap-vs-side from the movement.
|
|
||||||
|
|
||||||
## 8. Divergences + settled ambiguities (register-relevant)
|
|
||||||
|
|
||||||
1. **`is_water=1` on cylinder-top contact planes is RETAIL** (literal 1 at
|
|
||||||
0x0053aae2 and the branch-5 site; `set_contact_plane` 0x00509d80 stores
|
|
||||||
arg3 → `contact_plane_is_water`). Port verbatim; do not "fix".
|
|
||||||
2. **ACE bug (do NOT copy):** ACE's grounded head-sphere leg passes the FOOT
|
|
||||||
disp to `SlideSphere`; retail 0x0053b843 passes the HEAD disp (`x_2`).
|
|
||||||
Retail wins. (Class: [[feedback_bn_decomp_field_names]] #3 — ACE decode
|
|
||||||
wrong in a branch ACE rarely exercises.)
|
|
||||||
3. **Block offset in branch 5:** retail subtracts the cur→check landblock
|
|
||||||
offset; acdream's physics frame is continuous world-space → offset = 0.
|
|
||||||
Standing frame adaptation (same as SlideSphere's gDelta note).
|
|
||||||
4. **Ethereal targets:** branch 1 returns COLLIDED on overlap even for
|
|
||||||
ethereal; passability comes from the caller's Layer-2 override
|
|
||||||
(pc:276961-276989, non-static + !step_down → forced OK) plus the #150
|
|
||||||
step-down skip. The previous port consumed ObstructionEthereal with an
|
|
||||||
early OK before any test — response-equivalent for non-static targets,
|
|
||||||
but branch 1 is the faithful shape and also gives placement inserts the
|
|
||||||
retail blocked-by-cylinder semantics. Ported faithfully now.
|
|
||||||
5. **`normalize_check_small`** = normalize; returns true (fail) when |v| < ε
|
|
||||||
before normalizing — maps to `LengthSquared() < EpsilonSq` guard.
|
|
||||||
6. **step_sphere_up normal rotation:** retail rotates the collision normal by
|
|
||||||
the target OBJECT's frame (`localspace_pos` = the object's Position cached
|
|
||||||
by the wrapper) before `CTransition::step_up`. For yaw-only AC objects
|
|
||||||
this only affects yawed radial normals; ported faithfully via
|
|
||||||
`Vector3.Transform(n, obj.Rotation)`.
|
|
||||||
|
|
||||||
## 9. acdream port surface
|
|
||||||
|
|
||||||
`Transition.CylinderCollision` (TransitionTypes.cs) becomes the branch-4/5
|
|
||||||
dispatcher body; new private siblings `CylCollidesWithSphere`,
|
|
||||||
`CylNormalOfCollision`, `CylStepSphereUp`, `CylStepSphereDown`,
|
|
||||||
`CylSlideSphere`, `CylLandOnCylinder`, `CylCollideWithPoint`. Callers
|
|
||||||
unchanged (`FindObjCollisionsInCell` Cylinder branch; the BspOnlyDispatch
|
|
||||||
gate and the #150 ethereal step-down skip sit ABOVE this dispatch and are
|
|
||||||
unaffected). `DoStepUp` (= CTransition::step_up, A6.P6) and
|
|
||||||
`SpherePath.StepUpSlide` are reused as-is.
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# Pickup prompt — M1.5 dungeon track: #137 collision first (paste into a fresh session)
|
|
||||||
|
|
||||||
Read `claude-memory/project_physics_collision_digest.md` FIRST (the collision
|
|
||||||
SSOT + DO-NOT-RETRY table — binding), then the ISSUES entries for **#137**,
|
|
||||||
**#153**, **#138** (in that order), plus the prior-art closed fixes **#150**
|
|
||||||
(ethereal door step-down skip), **#151** (portal-less wall shells), **#152**
|
|
||||||
(building-cache re-base). Then work the track below.
|
|
||||||
|
|
||||||
**Context:** the R5 movement-manager arc is DONE and merged — `main` ==
|
|
||||||
`claude/vigorous-joliot-f0c3ad` @ `aa734f34` (facade close-out:
|
|
||||||
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`). M1.5's remaining
|
|
||||||
critical path is the dungeon track: **#137 dungeon collision → #153/#138
|
|
||||||
teleport-out residuals → A7 dungeon lighting** (A7 last — its 5-dungeon-site
|
|
||||||
capture protocol needs dungeons walkable first).
|
|
||||||
|
|
||||||
**Slice 1 — #137 (dungeon collision at doors + wall openings), oracle-first:**
|
|
||||||
1. AUTONOMOUS PREP: wire nothing yet. Re-read the EnvCell collision path
|
|
||||||
(`CellTransit`, the door apparatus, `ShadowObjectRegistry` per-cell
|
|
||||||
registration — A6.P4 architecture) and check whether the #150 ethereal
|
|
||||||
step-down skip (`TransitionTypes.cs`, retail pc:276795-276806) already
|
|
||||||
covers EnvCell dungeon doors or only building doors. Grep named-retail
|
|
||||||
before any fresh decompilation.
|
|
||||||
2. REPRO (needs the user in-world): the 0x0007 dungeon, with
|
|
||||||
`ACDREAM_PROBE_RESOLVE=1` (+ `ACDREAM_CAPTURE_RESOLVE=<path>` if a replay
|
|
||||||
diff is warranted). Characterize per site: which door / which opening,
|
|
||||||
expected vs actual, open-vs-closed state. The issue text says symptoms are
|
|
||||||
NOT yet characterized — do not fix ahead of the repro.
|
|
||||||
3. Fix per the digest workflow (retail decomp is the oracle; ACE second).
|
|
||||||
Acceptance: doors block/pass per open/closed state, wall openings pass,
|
|
||||||
solid walls block — matching retail in 0x0007. Register row per deviation,
|
|
||||||
same commit.
|
|
||||||
|
|
||||||
**Slice 2 — #153 (portals only work once / teleport-onto-unstreamed-edge
|
|
||||||
runaway), apparatus-first:** the issue is REOPENED with a narrowed residual
|
|
||||||
(arrival on a NOT-yet-streamed landblock NEAR AN EDGE → cell-march + Z
|
|
||||||
free-fall + garbage outbound cell). The issue text itself says **DO NOT
|
|
||||||
guess-patch** — the original #145 burned 5 attempts. Build the capture +
|
|
||||||
anchor/guard diagnostic at the crossing first. ⚠️ The issue's "likely fix
|
|
||||||
shape" is a streaming-gap HOLD — but the user REVERTED a previous teleport
|
|
||||||
hold as "shaky and bandaid" (`feedback_no_holds_for_slow_foundation`). If the
|
|
||||||
evidence really does point at a hold (as retail's synchronous-load
|
|
||||||
equivalent), STOP and get explicit user approval with the retail anchor in
|
|
||||||
hand; do not ship it on your own authority.
|
|
||||||
|
|
||||||
**Then:** the #138 acceptance run (portal out → world + objects + avatar all
|
|
||||||
present across REPEATED round-trips, collision working — its component fixes
|
|
||||||
are shipped; #151/#152 closed the wall residuals; what's left is the gate),
|
|
||||||
and A7 lighting (#154 dungeon-dim, #142 windowed interiors, #143 portal
|
|
||||||
swirl, #140 Fix D) as the M1.5 closer.
|
|
||||||
|
|
||||||
**Gotchas:** visual gates + repro sessions need the user (plan autonomous
|
|
||||||
decomp/probe prep around their availability); PowerShell Tee logs are UTF-16
|
|
||||||
(`tr -d '\000'` before grep); the user manages client lifecycle (plain
|
|
||||||
`dotnet run`, graceful close, ask if a rebuild is locked); probes:
|
|
||||||
`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` / `ACDREAM_CAPTURE_RESOLVE` /
|
|
||||||
`ACDREAM_PROBE_ENT` are all wired and DebugPanel-toggleable.
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# MP0 baseline — frame-profiler capture, attribution verdict, gate decision
|
|
||||||
|
|
||||||
**Date:** 2026-07-05. **Build:** Release, `ACDREAM_FRAME_PROF=1`, vsync off.
|
|
||||||
**Character:** `+Horan` (`0x5000000B`, testaccount2). **Logs:** `mp0-baseline.log`
|
|
||||||
(primary run: full route with 4 teleports, ~2 min) + `mp0-baseline-run3.log`
|
|
||||||
(supplemental: longer town session, same build) — both untracked in the worktree
|
|
||||||
root; the numbers below are transcribed from them.
|
|
||||||
|
|
||||||
**Route (reconstructed from log markers):** login → Holtburg outdoor
|
|
||||||
(`0xA9B4`) → movement/turning → teleport 1 → **dungeon `0x0007`** (dungeon
|
|
||||||
collapse streaming path) → walk → teleport 2 → **outdoor town `0xCE94`** →
|
|
||||||
pan/walk → teleport 3 → **dungeon again** → walk → teleport 4 → Holtburg →
|
|
||||||
close (graceful).
|
|
||||||
|
|
||||||
## The numbers (per 5-second `[frame-prof]` window)
|
|
||||||
|
|
||||||
| State | cpu_ms p50 | p95 | p99 | max | gpu_ms p50 | alloc/frame p50 | GC gen0/gen1/gen2 per 5 s |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Login settle (Holtburg) | 2.1 | 10.2 | **52.8** | **123.3** | 0.4 | 1.57 MB | 93 / **50** / **36** |
|
|
||||||
| Holtburg steady/turning | 1.6–2.0 | 2.2–2.6 | 2.8–3.7 | 15–20 | 0.4 | 1.58–1.60 MB | ~80 / 3–13 / 3–5 |
|
|
||||||
| Dungeon `0x0007` walk | **0.4–0.5** | 0.5–0.6 | 0.9 | 1.2–1.5 | 0.1 | 0.50–0.63 MB | 119 / **0** / **0** |
|
|
||||||
| Town `0xCE94` pan/walk | 2.9–3.6 | 4.0–5.0 | 7.2–15.7 | 26–37 | 1.2–1.6 | 2.5–3.0 MB | ~60 / 5–22 / 5–11 |
|
|
||||||
| Teleport→dungeon window | 2.8 | 3.3 | 14.1 | **211.2** | 1.3 | (max **75.7 MB** in one frame) | 62 / 11 / 10 |
|
|
||||||
| Teleport→outdoor windows | 1.2–2.9 | 3.4–3.8 | 4.5–7.0 | 33 | 0.2–1.5 | (max 20–31 MB single frame) | ~80–93 / 15–26 / 5 |
|
|
||||||
| Holtburg end-of-run moving | 2.6–2.9 | 3.3–5.1 | 8.5–**23.0** | 20–**87** | 2.5–2.7 | 2.14 MB | ~50–58 / 24–32 / 7–8 |
|
|
||||||
| (run 3) town views | 1.7–2.4 | 2.7–3.4 | 7.9–15.8 | 29–39 | 0.4–2.2 | 1.7–2.2 MB | ~70–109 / 17–32 / 6–11 |
|
|
||||||
|
|
||||||
Stage attribution (`upd` / `upl` / `imgui`): **≈ 0.0 ms p50 in every window**
|
|
||||||
(occasional upd p95 0.1–1.7 during streaming). GPU never exceeded p95 3.7 ms.
|
|
||||||
|
|
||||||
## Attribution verdict
|
|
||||||
|
|
||||||
1. **Steady-state medians are far better than the spec §1 assumption.** The
|
|
||||||
spec assumed ~6 ms / ~165 FPS dense-town CPU. Captured: worst steady town
|
|
||||||
view is p50 3.3–3.6 ms (~280–300 FPS); Holtburg ~1.6–2.4 ms (~400–600 FPS);
|
|
||||||
dungeons 0.4–0.5 ms (~2000 FPS). **Caveat:** the canonical Fort
|
|
||||||
Tethana/Arwic "dense worst-case axiom view" was NOT in this route — the
|
|
||||||
§1 numbers came from there. The 300-FPS throughput goal is close to met in
|
|
||||||
the captured views but must be re-verified at that view before any
|
|
||||||
"target met" claim.
|
|
||||||
2. **The frame is CPU-render-side, as assumed.** GPU ≤ ~2.7 ms everywhere;
|
|
||||||
upd/upl/imgui ≈ 0 → virtually all CPU time is unattributed render-side
|
|
||||||
submission. The spec's MP3 thesis stands.
|
|
||||||
3. **The smoothness gap is real, and it is GC.** Every town window violates
|
|
||||||
the ≤16 ms goal at max (20–87 ms) and several at p99 (up to 23 ms), while
|
|
||||||
dungeon windows — the ONLY windows with **zero gen1/gen2 collections** —
|
|
||||||
are spike-free (max 1.5 ms). Steady-state allocation is **1.5–3.0 MB per
|
|
||||||
frame** (≈ 0.6–1.2 GB/s at these frame rates), driving gen2 collections
|
|
||||||
roughly 1–2 per second in towns. The correlation is airtight across all
|
|
||||||
28 windows: spikes appear exactly where gen1/gen2 counts are non-zero.
|
|
||||||
4. **The hitch scenario is confirmed and quantified.** Teleport-into-dungeon
|
|
||||||
produced the worst frame of the session (**211 ms**) with a **75.7 MB**
|
|
||||||
single-frame allocation (decode + hydrate storm); login settle hit 123 ms
|
|
||||||
max with 36 gen2 collections in one window. This is exactly the class MP1
|
|
||||||
(bake — load becomes mmap reads, not managed decode) targets.
|
|
||||||
|
|
||||||
## Gate decision (spec §5)
|
|
||||||
|
|
||||||
**PROCEED to MP1, with one amendment.** The assumed cost split (render-side
|
|
||||||
CPU dominant, GPU idle, hitches at load boundaries) is CONFIRMED, so the
|
|
||||||
phase order stands. The amendment: the capture elevates **steady-state
|
|
||||||
allocation churn** from "MP4 cleanup" to a first-class smoothness lever —
|
|
||||||
1.5–3 MB/frame of per-frame garbage is the direct cause of the town p99/max
|
|
||||||
violations, and it is NOT load-related (it persists in steady windows).
|
|
||||||
|
|
||||||
**Amendment (recorded in the spec):** after MP1 ships, run a bounded
|
|
||||||
**steady-state allocation triage** (one session: `dotnet-trace` /
|
|
||||||
allocation-sampling on the live client, identify the top ~5 per-frame churn
|
|
||||||
sites). Sites *inside* the MP3 rewrite surface (per-frame submission
|
|
||||||
rebuild — the prime suspect for MB-scale per-frame lists) are left for MP3
|
|
||||||
to remove structurally; sites *outside* it get targeted fixes immediately.
|
|
||||||
The full zero-alloc pass remains MP4. This front-loads the biggest
|
|
||||||
smoothness win without letting "fix allocations" sprawl into an unphased
|
|
||||||
rewrite.
|
|
||||||
|
|
||||||
## Follow-ups noted (not gates)
|
|
||||||
|
|
||||||
- Re-measure the Fort Tethana axiom view at the MP2 and MP3 gates (the
|
|
||||||
degrade port materially changes exactly that view).
|
|
||||||
- `upd ≈ 0.0` everywhere is suspicious-adjacent but plausible (light sim in
|
|
||||||
these scenes); if a future capture shows heavy OnUpdate work still reading
|
|
||||||
0.0, verify the stage scope wasn't bypassed by an early return upstream.
|
|
||||||
- Dungeon at ~2000 FPS with zero GC pressure is the existence proof that the
|
|
||||||
engine's frame loop CAN run allocation-free at four-digit FPS — towns are
|
|
||||||
paying for churn, not for fundamentals.
|
|
||||||
|
|
||||||
## Apparatus state
|
|
||||||
|
|
||||||
`[frame-prof]` (env `ACDREAM_FRAME_PROF=1`, DebugPanel "Frame profiler"
|
|
||||||
checkbox) is PERMANENT — it is the measurement instrument for every MP gate.
|
|
||||||
Implementation: `src/AcDream.App/Diagnostics/` (FrameStatsBuffer /
|
|
||||||
GpuFrameTimer / FrameProfiler), plan
|
|
||||||
`docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md`, commits
|
|
||||||
`7d74c68c`..`4b44a152`.
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# Pickup prompt — post-#137-corridor session: the #176/#177 render pair (paste into a fresh session)
|
|
||||||
|
|
||||||
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (Option A —
|
|
||||||
one DrawInside(viewer_cell); binding DO-NOT-RETRY table), then **ISSUES #176
|
|
||||||
and #177**, then this file. The physics digest
|
|
||||||
(`claude-memory/project_physics_collision_digest.md`) carries the full
|
|
||||||
2026-07-06 collision saga if background is needed — do NOT reopen it.
|
|
||||||
|
|
||||||
## Where we are (2026-07-06 end of session)
|
|
||||||
|
|
||||||
**The #137 Facility Hub corridor collision arc is DONE, user-gated** ("not
|
|
||||||
collision anymore. Good." / "Looks good"). Branch
|
|
||||||
`claude/vigorous-joliot-f0c3ad` (worktree), 10 commits ahead of `e73e45da`,
|
|
||||||
NOT merged to main. All suites green (Core 2562 / App 713 / UI 425 / Net 385).
|
|
||||||
|
|
||||||
| Commit | Fix |
|
|
||||||
|---|---|
|
|
||||||
| `a11df5b8` | BSPQuery Contact-branch stub slide responses leaked sliding normals (the absorbing wedge). Retail's BSP layer never writes `collision_info.sliding_normal` — only `validate_transition` 0x0050ac21; body persistence success-only (`SetPositionInternal` 0x005154c2). |
|
|
||||||
| `e8651b38` | `slide_sphere` opposing-normals branch returned OK; retail returns COLLIDED_TS (0x0053762c). The "phantom wall" normal was SYNTHETIC (negated movement). PortalSide-poly theory refuted. |
|
|
||||||
| `d4869154` | `CheckOtherCells` queried remaining cells at a stale pre-climb center (P2 lesson one loop deeper) — the seam shake. Per-iteration `footCenter = sp.GlobalSphere[0].Origin` refresh. |
|
|
||||||
| `aa96d7ad` | The collision capsule topped out at 1.2 m (callers passed `sphereHeight: 1.2f`; head sphere center 0.72). Dat Setup 0x02000001: spheres (0,0,0.475)+(0,0,1.350) r=0.48, top 1.83 = Height 1.835. Callers now pass 1.835. Register TS-46. The window climb. |
|
|
||||||
|
|
||||||
**#137 stays OPEN for the DOORS half only** (block/pass per open state).
|
|
||||||
The #175 door-pose fix (2026-07-05) still needs its user gate — ask for it
|
|
||||||
whenever the user is next at the hub double door (closed blocks AT the
|
|
||||||
visual panels from both sides, no embed, no phantom wall).
|
|
||||||
|
|
||||||
## NEXT ARC: #176 + #177 (render, both filed 2026-07-06 from the gate session)
|
|
||||||
|
|
||||||
- **#176 — purple flashing on dungeon floors at cell seams, camera-angle
|
|
||||||
dependent.** Survives all physics fixes → render-side. Magenta/purple =
|
|
||||||
the placeholder-texture class ([[feedback_ui_resolve_zero_magenta]]).
|
|
||||||
- **#177 — stairs between levels pop in/out.** Invisible from the corridor
|
|
||||||
looking into the stair room, appear on entering, vanish on the last step
|
|
||||||
running down. The #119 visibility class, dungeon edition. Anchor cells:
|
|
||||||
the transit `0x8A020182 → 0x8A020183` drops z −6 → −9 on stairs
|
|
||||||
(launch-137-gate2.log).
|
|
||||||
|
|
||||||
**The load-bearing topology fact both issues share (discovered this
|
|
||||||
session):** Facility Hub corridor FLOORS are portal polygons — PortalSide
|
|
||||||
floor-portals to under-rooms (e.g. 0x8A02016E visual polys 1/3/5 → 0x011E,
|
|
||||||
horizontal at z=−6, spanning the whole floor; 0x011E is a hall at z=−12).
|
|
||||||
Level connections run through these floor-portals. "Purple at the seams" is
|
|
||||||
purple exactly where portal surfaces meet, and the stairs' rooms hang off
|
|
||||||
the same portal graph — suspect the render portal-flood/portal-surface
|
|
||||||
handling of HORIZONTAL portals.
|
|
||||||
|
|
||||||
**⚠️ The id-space trap (cost this saga a wrong mechanism):**
|
|
||||||
`CellPortal.PolygonId` indexes the VISUAL polygon table (`CellStruct.Polygons`),
|
|
||||||
NOT `PhysicsPolygons`. Same ids in both tables are UNRELATED polygons.
|
|
||||||
|
|
||||||
## Tooling built this session (reuse, don't rebuild)
|
|
||||||
|
|
||||||
- `Issue137CorridorSeamInspectionTests` — dat-inspection theories (add
|
|
||||||
`InlineData` cells as needed): portal spans (`CorridorCell_PortalPolygonWorldSpans`),
|
|
||||||
full-vertex poly dumps (`WindowShaft_FullPolyDump`), physics-BSP leaf
|
|
||||||
membership, hit-normal candidate sweep (use |align| — winding flips),
|
|
||||||
`HumanSetup_CollisionSpheres_DatTruth`.
|
|
||||||
- `Issue137CorridorSeamReplayTests` — dat-backed `PhysicsEngine` corridor
|
|
||||||
harness (`BuildCorridorEngine`: hydrate THREE portal rings or ring-3
|
|
||||||
cells are invisible to the flood — why clean-room replays kept passing).
|
|
||||||
In-test probe capture pattern: `Console.SetOut(StringWriter)` +
|
|
||||||
`PhysicsDiagnostics.Probe*Enabled = true` → line-diff offline vs live.
|
|
||||||
- Live probe logs (worktree root, PowerShell Tee = UTF-16, `tr -d '\000'`
|
|
||||||
before grep): `launch-137-seam-probes.log` (790 MB, step-level),
|
|
||||||
`launch-137-gate2.log`, `launch-137-gate3.log`,
|
|
||||||
`resolve-137-seam-capture.jsonl` (body snapshots, untracked).
|
|
||||||
|
|
||||||
## Physics DO-NOT-RETRY highlights from today (full table in the digest)
|
|
||||||
|
|
||||||
- No `SetSlidingNormal` in the BSP/sphere layer; opposing branch returns
|
|
||||||
Collided; failed transitions never write body sliding state.
|
|
||||||
- The absorbed exactly-anti-parallel frame against a persisted sliding
|
|
||||||
normal is RETAIL behavior — fix normal PROVENANCE, not the abort.
|
|
||||||
- No height-budget check in the step-down accept — retail's climb cap is
|
|
||||||
`adjust_sphere_to_plane`'s walk_interp −0.5 overshoot bound (0x00538210)
|
|
||||||
+ the placement insert rejecting the HEAD in solids.
|
|
||||||
- Probe-field misreads: `[neg-poly]`/`[neg-poly-dispatch]` print `stepUp=`
|
|
||||||
= NegStepUp (dispatch class), NOT sp.StepUp. `[walkable-nearest]` is a
|
|
||||||
logger, not the decision-maker.
|
|
||||||
- Remaining registered leaks (rows exist, fix later): TS-45
|
|
||||||
(`SphereCollision`'s SetSlidingNormal tail), TS-4 (Path-6 steep-tangent),
|
|
||||||
TS-46 (scalar sphere approximation; remotes use human dims).
|
|
||||||
|
|
||||||
## Launch protocol (unchanged)
|
|
||||||
|
|
||||||
Build green first; PowerShell launch with the env block from CLAUDE.md
|
|
||||||
(+ `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1` for gates), background +
|
|
||||||
Tee to `launch-*.log`. The user manages client lifecycle. Graceful close →
|
|
||||||
ACE session clears in ~5 s; hard kill → ~3 min. The test character may be
|
|
||||||
saved in odd places after collision testing (last session it was inside the
|
|
||||||
window alcove and ACE bounced it to Holtburg — the user portals back).
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue