diff --git a/CLAUDE.md b/CLAUDE.md index f1b8e6e..7e343da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -618,10 +618,27 @@ acdream's plan lives in two files committed to the repo: approval. **Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices -1+2+3 shipped 2026-05-12 (this evening); the natural next step is the -L.2d slice 1 brainstorm / design spec. Cold-start prompt for the next -session: [`docs/research/2026-05-12-l2d-next-session-prompt.md`](docs/research/2026-05-12-l2d-next-session-prompt.md). -Full handoff: [`docs/research/2026-05-12-l2a-shipped-l2d-handoff.md`](docs/research/2026-05-12-l2a-shipped-l2d-handoff.md). +1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 shipped 2026-05-12. L.2g slice 1 +is CODE-COMPLETE (parser + registry mutator + WorldSession dispatcher + +GameWindow subscriber, 4 commits, build green, 6 new tests pass), but +its visual verification is **deferred to the B.4b session** — clicking +on a door does nothing today because Phase B.4's input-action handler +was never wired (the wire builders and bindings exist, but +`GameWindow.OnInputAction` has no case for `SelectDblLeft`, so the +outbound Use never sends). **The natural next step is Phase B.4b — +finish the outbound Use handler wiring** (subscribe `SelectDblLeft` → +`WorldPicker.Pick` → `InteractRequests.BuildUse` → send), then re-run +the Holtburg inn-doorway visual test which verifies both L.2g slice 1 +and B.4b in one pass. Estimated 30-50 LOC, ~30 min, 1-2 subagent +dispatches. + +L.2g slice 1 ship handoff: [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md) +— full evidence + the 4 minor review notes + the 1 Important test-coverage +gap for `ShouldSkip` (the B.4b visual test's hex-dump will settle whether +ACE sends `state=0x4` alone or `0x14`). +Design spec: [`docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md`](docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md). +Implementation plan: [`docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md`](docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md). +L.2d ship handoff: [`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`](docs/research/2026-05-13-l2d-slice1-shipped-handoff.md). **Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.** Three commits land the L.2 "make every bad movement outcome explainable" @@ -699,13 +716,25 @@ together comprise the streaming + rendering perf foundation for the project. **Next phase candidates (in rough preference order):** -- **L.2d slice 1 brainstorm + spec** (`docs/research/2026-05-12-l2d-next-session-prompt.md`). - Direct continuation of tonight's L.2a evidence: port `CBuildingObj` collision - + per-cell walkability so doorway gaps are walkable. Unblocks "walk into a - building" + sets up G.3 dungeon streaming. **Note:** triage the 8 pre-existing - test failures first (none introduced by L.2a slices — verified by stash + rerun - — but most touch movement/physics code L.2d will evolve). See the handoff doc's - "Open concerns" section. +- **Phase B.4b — finish the outbound Use handler wiring.** + Direct M1 blocker discovered while running the L.2g slice 1 visual + test: the wire builders (`InteractRequests.BuildUse`), classes + (`SelectionState`, `WorldPicker`), input-action enum entries + (`SelectDblLeft` etc.), and keybindings (LMB-dblclick → `SelectDblLeft`) + all ship today, but `GameWindow.OnInputAction`'s switch has NO case + for any of the `Select*` actions — clicking on a door fires the + diagnostic `[input] SelectDblLeft Press` but nothing downstream + listens. Memory file `project_interaction_pipeline.md` updated to + reflect this reality. Shape: subscribe `InputAction.SelectDblLeft` + → build a world ray from current mouse → `WorldPicker.Pick(...)` → + store in `_selection` → call `InteractRequests.BuildUse(seq, guid)` + + `_liveSession.SendGameMessage(body)`. Probably also subscribe + `SelectLeft` for select-without-use and `UseSelected` for the R + hotkey. Estimate: 30-50 LOC, 1-2 subagent dispatches, ~30 min. + Verifies L.2g slice 1 in the same Holtburg-doorway visual test once + it lands. Full context: + [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md) + "Why the visual test is deferred" section. - **Triage the chronic open-issue list** in `docs/ISSUES.md` — #2 (lightning), #4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree), #41 (remote-motion blips) have been open since diff --git a/docs/ISSUES.md b/docs/ISSUES.md index cea28d6..19562fe 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,65 @@ Copy this block when adding a new issue: # Active issues +## #57 — B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing + +**Status:** OPEN +**Severity:** HIGH (M1 blocker — demo target *"open the inn door, click an NPC, pick up an item"* is fully blocked) +**Filed:** 2026-05-12 +**Component:** input / interaction / `GameWindow.OnInputAction` + +**Description:** Discovered 2026-05-12 while running the L.2g slice 1 +visual test. Phase B.4 (2026-04-28) shipped half of itself: the wire- +message builders (`InteractRequests.BuildUse` / `BuildUseWithTarget` / +`BuildPickUp`), the supporting classes (`SelectionState`, `WorldPicker`), +the `InputAction` enum entries (`SelectLeft`, `SelectDblLeft`, +`SelectRight`, `SelectDblRight`, `UseSelected`, `SelectionPickUp`, etc.), +and the default keybindings (LMB → `SelectLeft`, LMB-dblclick → +`SelectDblLeft`, RMB → `SelectRight`, R → `UseSelected`, F → +`SelectionPickUp`). What was never shipped: a case for ANY of those +actions in `GameWindow.OnInputAction`'s switch. The runtime diagnostic +`[input] SelectLeft Press` fires when you click — confirming the +dispatcher resolves the chord — but nothing downstream listens, so the +click silently does nothing. Neither does double-click, R, or F. The +inbound side (`MoveToObjectReceived`, `StateUpdated` after L.2g slice 1) +is wired and ready; the block is purely outbound. + +**Root cause / status:** B.4 handler integration step was evidently +dropped or never landed. Memory file +`memory/project_interaction_pipeline.md` was updated 2026-05-12 to +reflect this reality (previous text claimed shipped). + +**Files:** +- `src/AcDream.App/Rendering/GameWindow.cs` — `OnInputAction` switch + around line 8546+ has no `Select*` cases. +- `src/AcDream.Core.Net/Messages/InteractRequests.cs` — wire builders + exist but have zero callers in `src/`. +- `src/AcDream.Core/Selection/SelectionState.cs` — class exists, zero + production callers. +- `src/AcDream.App/Rendering/WorldPicker.cs` — class exists, zero + production callers. +- `src/AcDream.UI.Abstractions/Input/KeyBindings.cs:300-320` — bindings + for `SelectLeft` / `SelectDblLeft` / `SelectRight` / `SelectDblMid` + exist. + +**Research:** [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](research/2026-05-12-l2g-slice1-shipped-handoff.md) +"Why the visual test is deferred" section has the full investigation. + +**Acceptance:** Double-left-clicking on a door in the Holtburg inn +doorway sends a `0xF7B1 / 0x0036 Use` to the server, the server flips +the door's `Ethereal` bit and broadcasts `SetState (0xF74B)`, the +L.2g-slice-1 chain mutates `ShadowObjectRegistry`, the +`CollisionExemption.ShouldSkip` check honors it, and the player can +walk through the doorway. Visual verification + log grep (per the +L.2g handoff's reproducibility recipe) both pass. + +**Status promotion:** This is a phase-sized follow-up (estimated +30-50 LOC, ~30 min). Promoted to **Phase B.4b** in the L.2 milestone +context and the CLAUDE.md "Next phase candidates" list. Will be closed +as `DONE (promoted to Phase B.4b)` once that phase's design spec lands. + +--- + ## #55 — Static-entity slow path reports ~1.45M `meshMissing` per 5s at r4 standstill **Status:** OPEN diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index 7db0325..05ab3b0 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -228,6 +228,62 @@ client sees when observing acdream. - Require conformance notes in tests or research docs for every AC-specific algorithm ported under L.2. +### L.2g - Dynamic PhysicsState Toggling + +Goal: server-driven post-spawn state changes (chiefly `ETHEREAL` flips) are +honored by the local collision stack. + +Triggered 2026-05-12 evening by the L.2d slice 1.5 trace: the Holtburg +doorway blocker is a closed Door entity (Setup `0x020019FF`) whose +`PhysicsState.Ethereal` bit flips when the player Uses the door. The L.2d +shape-fidelity work doesn't cover this — the door's collision shape is +already correct; what's missing is honoring the *runtime* state change. + +Scope is intentionally narrow: + +- Parse inbound `GameMessageSetState (opcode 0xF74B)`. +- Plumb the new `PhysicsState` value into `ShadowObjectRegistry`'s cached + per-entity state so the existing `CollisionExemption.IsExempt(...)` check + sees the up-to-date bits. +- Verify the Holtburg inn-door scenario: walk into doorway → blocked, Use + door → door swings open AND player can walk through, auto-close after + 30s → door closes AND player is blocked again. +- Confirm the existing `UpdateMotion` pipeline drives `(NonCombat, On/Off)` + on non-creature entities (door swing animation). If not, one-line fix. + +Excluded from L.2g scope (deferred): + +- Door-specific UX polish: "door is locked" sound, creature-AI bump-open. +- Any Door-specific class hierarchy — generic state-flip infrastructure + is enough; doors are the verification scenario, not a privileged case. + +Lane: informal sixth lane "dynamic state." The existing five-lane table +treats per-entity state as static-after-spawn; L.2g makes it dynamic. + +Full design spec: +[docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md). + +M1 critical path: this slice unblocks the *"open the inn door"* demo +scenario. + +Current shipped slice (2026-05-12): + +| Commit | Subject | +|---|---| +| `2459f28` | `feat(phys L.2g slice 1): inbound SetState (0xF74B) parser` | +| `d538915` | `feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState` | +| `536a608` | `feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe` | +| `108e386` | `feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log` | + +Slice 1 is CODE-COMPLETE: parser + registry mutator + WorldSession +dispatcher + GameWindow subscriber. 6 new tests pass (3 parser + 3 +registry). Build clean. Per-commit + final integration code reviews +all approved. **Visual verification deferred to Phase B.4b** — the +inbound SetState chain can't fire at runtime until B.4b finishes the +outbound Use handler. See +[docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](../research/2026-05-12-l2g-slice1-shipped-handoff.md) +for full evidence + the 4 minor + 1 Important review notes. + ## Named Retail Anchors Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 97816be..94238cb 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -101,9 +101,19 @@ doorway. Open the inn door. Click an NPC and see selection feedback. Pick up an item from the ground. **Phases to ship:** -- **L.2 (all sub-lanes a–f)** — Movement & Collision Conformance. Currently - active; L.2a slices 1+2+3 shipped 2026-05-12. -- **B.4** — `Use` / `UseWithTarget` / `PickUp` outbound messages. +- **L.2 (all sub-lanes a–g)** — Movement & Collision Conformance. + L.2a slices 1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 (code) shipped + 2026-05-12. L.2g slice 1 visual verification deferred to B.4b session + (the inbound SetState chain can't fire until the outbound Use sends). +- **B.4 / B.4b** — `Use` / `UseWithTarget` / `PickUp` interaction. + B.4 (2026-04-28) shipped the wire builders, `SelectionState`, + `WorldPicker`, the input-action enums, and the keybindings — but + *not* the `GameWindow.OnInputAction` handler that ties them + together. As of 2026-05-12, clicking on a door silently does nothing. + **B.4b** is the small follow-up slice (~30-50 LOC) that subscribes + `SelectDblLeft` and routes through pick → BuildUse → send. Once B.4b + lands, the same Holtburg-doorway visual test verifies both L.2g + slice 1 and B.4b in one pass. **Freeze on landing:** - L.2 zone (collision, cell ownership, transition parity, wire authority) diff --git a/docs/research/2026-05-12-l2g-slice1-shipped-handoff.md b/docs/research/2026-05-12-l2g-slice1-shipped-handoff.md new file mode 100644 index 0000000..8a682cd --- /dev/null +++ b/docs/research/2026-05-12-l2g-slice1-shipped-handoff.md @@ -0,0 +1,241 @@ +# L.2g slice 1 shipped — handoff (code-complete; visual test deferred) + +**Date:** 2026-05-12 evening. +**Branch:** `claude/gallant-mestorf-3bf2e3` (ready to merge to main). +**Predecessors:** +- [2026-05-13-l2d-slice1-shipped-handoff.md](2026-05-13-l2d-slice1-shipped-handoff.md) — the L.2d trace that identified Door entities as the Holtburg doorway blocker, motivating L.2g. +- [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md) — the L.2g design spec (commit `2c10dd4`). +- [docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md](../superpowers/plans/2026-05-12-phase-l2g-slice1.md) — the L.2g slice 1 implementation plan (commit `869677b`). + +--- + +## TL;DR + +L.2g slice 1 **code is complete and unit-tested.** The four commits land +the full inbound `SetState (0xF74B)` pipeline: parser → WorldSession +event → GameWindow handler → `ShadowObjectRegistry.UpdatePhysicsState`. +After this slice, the existing `CollisionExemption.ShouldSkip` +short-circuit (cited at `acclient_2013_pseudo_c.txt:276782`) honors +runtime ETHEREAL flips without any resolver-path edit. + +**The visual verification at Holtburg's inn doorway is deferred to the +next session.** Cause: Phase B.4's outbound Use handler turns out to be +unwired — clicking on a door silently does nothing because no +production code subscribes to the `SelectLeft` / `SelectDblLeft` input +actions. Without the outbound Use, the server never sees a "open the +door" request, so the inbound SetState we just ported never fires. + +L.2g slice 1 is the inbound half of the round-trip. Phase **B.4b** (a +small ~30-50 LOC slice) is the outbound half. Both halves are required +for the M1 demo target *"open the inn door."* B.4b is the next session's +work. + +--- + +## What shipped on this branch + +| Commit | Subject | +|---|---| +| [`2459f28`](.) | `feat(phys L.2g slice 1): inbound SetState (0xF74B) parser` | +| [`d538915`](.) | `feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState` | +| [`536a608`](.) | `feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe` | +| [`108e386`](.) | `feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log` | + +Plus docs/scaffolding earlier in the session: +- `2c10dd4` — L.2g design spec + L.2 plan-of-record + milestones + CLAUDE.md updates. +- `869677b` — L.2g slice 1 implementation plan (this doc's companion). + +**Build:** clean. **Tests:** 6 new tests pass (3 for parser, 3 for +registry mutator). Full suite: 1037 pass / 8 pre-existing-baseline fail. +No regressions. Per-commit + final integration code reviews all approved. + +--- + +## What the code now does end-to-end + +When the server broadcasts a `SetState (0xF74B)`: + +1. **Parse** — `WorldSession`'s dispatcher routes opcode `0xF74B` into + `SetState.TryParse(body)`, which returns + `SetState.Parsed(Guid, PhysicsState, InstanceSequence, StateSequence)`. +2. **Probe** (gated on `ACDREAM_PROBE_BUILDING=1`) — one-shot per + session, dumps the first message's body bytes as + `[setstate-hex] body.len=N first-N-bytes: 4B F7 ...` for wire-format + confidence. +3. **Event** — `WorldSession.StateUpdated` fires with the parsed value. +4. **Subscribe** — `GameWindow.OnLiveStateUpdated` (added to the live- + session attach block alongside `OnLiveVectorUpdated`) calls + `_physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState)`. +5. **Mutate** — `ShadowObjectRegistry.UpdatePhysicsState` walks every + per-cell list the entity occupies and rewrites `list[i] with { State = newState }`. +6. **Per-tick diagnostic** (same probe flag) — emits + `[setstate] guid=0x... state=0x... instSeq=... stateSeq=...` for the + greppable trail. +7. **Resolver** — next physics tick, `FindObjCollisions` calls + `CollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState)` + on the entity. The check is unchanged from L.2d slice 1; it + short-circuits when `(state & ETHEREAL_PS) != 0 && (state & IGNORE_COLLISIONS_PS) != 0`. + +**Slice 0.5 freebie folded in:** all 6 `[entity-source]` probe-log +sites in `GameWindow.cs` now emit `state=0x{state:X8} flags={flags}` +so ETHEREAL flips are greppable end-to-end from spawn through state +change. + +--- + +## Why the visual test is deferred — the B.4 discovery + +Before launching the visual test, the user reported that right-click +in-client was bound to camera orbit (correctly), and asked whether +left-click should open a door. Investigation produced this finding: + +| Component | State | +|---|---| +| `InteractRequests.BuildUse(seq, guid)` wire builder | ✅ implemented + tested | +| `SelectionState`, `WorldPicker` classes | ✅ exist in source | +| `InputAction.SelectLeft` / `SelectDblLeft` / `SelectRight` enum | ✅ defined | +| KeyBindings: LMB → `SelectLeft`, LMB-dblclick → `SelectDblLeft`, RMB → `SelectRight` | ✅ wired in `KeyBindings.cs:300-320` | +| `GameWindow.OnInputAction` switch case for `Select*` | ❌ **missing** | +| Any production caller of `SelectionState`, `WorldPicker`, `InteractRequests.BuildUse` | ❌ **none in `src/`** | + +The diagnostic line `[input] SelectLeft Press` fires on LMB-click — the +dispatcher knows the action — but nothing downstream listens. The +click silently does nothing. The R hotkey similarly does nothing +because the corresponding `UseSelected` case is also absent from the +switch. + +So the M1 outbound Use path is **half-shipped**: every component below +the handler exists, but the handler that ties them together was never +landed (despite a 2026-04-28 memory entry claiming "B.4 shipped"). +Phase B.4b is the slice that fixes this. + +This is **not** an L.2g defect. L.2g's code path is correct and unit- +tested; it just can't be exercised at runtime until the outbound Use +sends a SetState-triggering request to the server. + +--- + +## Open notes from reviews (minor — defer to next polish pass) + +The per-commit and final integration code reviews approved every commit. +Four observations flagged as Minor that are worth folding into a future +polish pass: + +1. **`SetState.cs` "total body size" phrasing diverges from `VectorUpdate.cs`.** + New form: `"Total body size: 16 bytes (4-byte opcode + 12-byte payload)"`. + Sibling form: `"Total body size after opcode: 32 bytes"`. The new form + is more self-documenting, but the spec asked to align with the + sibling. Cosmetic. +2. **`[setstate-hex]` log uses redundant `Math.Min(body.Length, 32)`.** + Called twice in the same line; could be hoisted to a local. + Harmless for a one-shot diagnostic. +3. **`WorldSession.cs` uses the fully-qualified + `AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled`** instead + of adding `using AcDream.Core.Physics;` and using the short form. + Every other call site in `GameWindow.cs` and `BSPQuery.cs` uses the + unqualified form. Style inconsistency. +4. **`[setstate]` diagnostic emits guid + state as hex but instSeq + + stateSeq as decimal.** Cosmetic. + +### One Important review note (worth following up explicitly) + +The final integration reviewer flagged: the test +`UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits` asserts the +cached state changes to `0x4` but does **not** verify the chain +through `CollisionExemption.ShouldSkip`. That short-circuit requires +**both** `ETHEREAL_PS (0x4)` AND `IGNORE_COLLISIONS_PS (0x10)` to be +set simultaneously (`(state & 0x4) && (state & 0x10)`). A state of +`0x4` alone does NOT exempt collision. Per the reviewer, ACE's +`PhysicsObj.cs:787-791` may set both bits when doors open (broadcast +value `0x14` or higher) — but this is not verified by the test suite. + +**The B.4b visual test will settle this definitively:** the slice-1 +hex-dump probe will capture the real `state=0x????????` wire value the +first time a door opens. If ACE sends `0x14` or higher, the existing +chain works as-is. If ACE sends `0x4` only, we need a tiny adjustment +to `CollisionExemption.cs` (the `&&` would become `||`, OR we make the +collision exemption fire on ETHEREAL alone, OR we widen the test). + +**Action for B.4b session:** after the door-open visual test, grep the +launch log for `[setstate-hex]` and the `[setstate]` line that fires on +the Use → confirm the state bits ACE actually sends. If `0x4` only, +file a tiny L.2g slice 1b to widen `CollisionExemption.ShouldSkip` or +the test's assertion. + +--- + +## Next session + +**Pick: Phase B.4b — finish the outbound Use handler wiring.** + +Concretely: +- Subscribe `InputAction.SelectDblLeft` in `GameWindow.OnInputAction` + switch. +- Build a world ray from current mouse position + (`WorldPicker.BuildRay(mouse, vp, view, proj)`). +- Pick the closest entity (`WorldPicker.Pick(ray, entities, cache, skipGuid, maxDist)`). +- Store result in `_selection` (`SelectionState.Set(guid)`). +- Call `InteractRequests.BuildUse(seq, guid)` + `_liveSession.SendGameMessage(body)`. +- Probably also subscribe `InputAction.SelectLeft` for select-without- + use (single-click selects; double-click selects + uses). +- Optionally subscribe `InputAction.UseSelected` (R hotkey) to send Use + on the already-selected guid. +- Sequence-number management — there's a game-action sequence counter + on `WorldSession` already used by the outbound chat path; reuse it. + +Estimate: 30-50 LOC, 1-2 subagent-driven implementations + reviews, ~30 min. + +Once B.4b lands, **immediately re-run the Holtburg inn doorway visual +test** with `ACDREAM_PROBE_BUILDING=1`. Both L.2g slice 1 + B.4b are +verified by the same scenario; no separate L.2g visual test needed. + +--- + +## Reproducibility + +Same launch recipe as L.2d slice 1 (see CLAUDE.md "Running the client +against the live server"). For visual test once B.4b lands: + +```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 = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_DEVTOOLS = "1" +$env:ACDREAM_PROBE_BUILDING = "1" +$env:ACDREAM_PROBE_RESOLVE = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 | + Tee-Object -FilePath "launch-l2g+b4b.log" +``` + +Then walk into the Holtburg inn doorway, double-left-click the door, +wait for the swing animation, walk through. After 30s, watch the +auto-close fire. + +After closing the client: + +```powershell +Select-String -Path launch-l2g+b4b.log -Pattern "setstate-hex|setstate.*guid|entity-source.*Door|input.*SelectDblLeft" +``` + +Expected matches: +- One `[setstate-hex] body.len=16 ...` line (confirms holtburger's 12-byte payload). +- One `[entity-source] name=Door ... state=0x00000000 flags=None ...` at spawn. +- An `[input] SelectDblLeft Press` when you double-click. +- A `[setstate] guid=0x000F... state=0x????????` after the door opens. +- A second `[setstate] guid=0x000F... state=0x00000000` ~30s later when auto-close fires. + +--- + +## Worktree state at handoff + +- Branch `claude/gallant-mestorf-3bf2e3` ready to merge to main. +- 6 commits ahead of main: `2c10dd4` (spec + docs), `869677b` (plan), + `2459f28` / `d538915` / `536a608` / `108e386` (L.2g slice 1 code). +- One launch.log artifact (`launch-l2g-slice1.log`) in the working + tree from the attempted visual test — **not committed** (gitignored + or transient). Safe to discard; B.4b will produce a fresh log. + +User wants to start a fresh session for B.4b. diff --git a/docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md b/docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md new file mode 100644 index 0000000..2ad4e62 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md @@ -0,0 +1,899 @@ +# Phase L.2g slice 1 — Dynamic PhysicsState Toggling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Spec:** [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../specs/2026-05-12-l2g-dynamic-physicsstate-design.md) (committed in `2c10dd4`). +**Branch:** `claude/gallant-mestorf-3bf2e3` (do all commits here; user merges to main separately). + +**Goal:** Parse inbound `GameMessageSetState (opcode 0xF74B)` and propagate +the new `PhysicsState` value into `ShadowObjectRegistry`'s cached per-entity +record so the existing `CollisionExemption.IsExempt(...)` short-circuit honors +runtime ETHEREAL flips — unblocking the M1 demo's *"open the inn door"* line. + +**Architecture:** One new wire-message parser (`SetState`), one new event on +`WorldSession`, one new mutator method on `ShadowObjectRegistry` +(`UpdatePhysicsState`), one new subscriber in `GameWindow`. **No resolver +changes.** The existing `CollisionExemption.cs` short-circuit (cited at +`acclient_2013_pseudo_c.txt:276782`) already handles ETHEREAL; slice 1 just +feeds it fresh data. + +**Tech Stack:** C# .NET 10, xUnit for tests, BinaryPrimitives for +little-endian reads. Mirror the existing `VectorUpdate.cs` parser pattern. + +**Retail anchor (port reference):** `CPhysicsObj::set_state` at +`docs/research/named-retail/acclient_2013_pseudo_c.txt:283044`. The retail +implementation: `this->state = arg2` (line 283048) plus three side-effect +handlers for the changed-bit set (`0x800` lighting, `0x20` nodraw, `0x4000` +hidden). **Slice 1 ports only the state-store half**; ETHEREAL (`0x4`) is +not in the side-effect set, so the cosmetic handlers are not on the +M1-critical path and stay deferred. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/AcDream.Core.Net/Messages/SetState.cs` | Create | New inbound DTO + `TryParse` for opcode `0xF74B`. Mirrors `VectorUpdate.cs`. | +| `src/AcDream.Core.Net/WorldSession.cs` | Modify | Add `StateUpdated` event + dispatch branch for `op == SetState.Opcode`. | +| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | Modify | Add `UpdatePhysicsState(uint, uint)` method that mutates the cached `ShadowEntry.State` in every cell the entity occupies. | +| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Subscribe to `_liveSession.StateUpdated`, route `(guid, newState)` to `_physicsEngine.ShadowObjects.UpdatePhysicsState(...)`. Extend `[entity-source]` log with `state=` + `flags=` (slice 0.5 freebie). | +| `tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs` | Create | TryParse byte-level tests (well-formed, truncated, opcode-mismatch). | +| `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs` | Modify | Add `UpdatePhysicsState_FlipsEthereal_NextLookupExempt` test using `CollisionExemption.ShouldSkip`. | + +**No new project references needed** — all files live in existing assemblies. + +--- + +## Task 1: Parser DTO + TryParse for `SetState` (opcode `0xF74B`) + +**Files:** +- Create: `src/AcDream.Core.Net/Messages/SetState.cs` +- Create: `tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs` + +**Reference template:** `src/AcDream.Core.Net/Messages/VectorUpdate.cs` +(read it before writing — same opcode dispatch convention, same body-length +check shape, same `BinaryPrimitives` style). + +**Wire format** (per +`references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122`, +matched by every other acdream parser): + +``` +offset 0 : u32 opcode (= 0xF74B) +offset 4 : u32 guid +offset 8 : u32 physics_state (bitmask; ETHEREAL = 0x4) +offset 12 : u16 instance_sequence +offset 14 : u16 state_sequence +Total: 16 bytes from start of body. +``` + +- [ ] **Step 1.1: Write the failing TryParse tests** + +Create `tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs` with: + +```csharp +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class SetStateTests +{ + [Fact] + public void TryParse_WellFormedBody_ReturnsParsed() + { + // Build a synthetic SetState body: opcode + guid + state + 2×u16 seq. + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Bu); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4, 4), 0x000F4244u); // door guid + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8, 4), 0x00000004u); // ETHEREAL bit + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(12, 2), (ushort)355); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(14, 2), (ushort)42); + + var parsed = SetState.TryParse(buf); + + Assert.NotNull(parsed); + Assert.Equal(0x000F4244u, parsed.Value.Guid); + Assert.Equal(0x00000004u, parsed.Value.PhysicsState); + Assert.Equal((ushort)355, parsed.Value.InstanceSequence); + Assert.Equal((ushort)42, parsed.Value.StateSequence); + } + + [Fact] + public void TryParse_Truncated_ReturnsNull() + { + var buf = new byte[10]; // < 16 bytes + Assert.Null(SetState.TryParse(buf)); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Cu); // UpdateMotion, not SetState + Assert.Null(SetState.TryParse(buf)); + } +} +``` + +- [ ] **Step 1.2: Run tests to verify they fail (RED)** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStateTests"` +Expected: Compile error — `SetState` type not defined. + +- [ ] **Step 1.3: Write the parser** + +Create `src/AcDream.Core.Net/Messages/SetState.cs`: + +```csharp +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound SetState GameMessage (opcode 0xF74B). The server +/// broadcasts this whenever a previously-spawned entity's +/// PhysicsState bitmask changes after CreateObject — chiefly +/// when a door opens / closes (server flips ETHEREAL_PS = 0x4) or a +/// spell projectile becomes ethereal post-impact. +/// +/// +/// Wire layout (per +/// references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122, +/// matched by every other acdream parser): +/// +/// +/// u32 opcode — 0xF74B +/// u32 objectGuid +/// u32 physicsState — bitmask (acclient.h:2815 / 2819) +/// u16 instanceSequence — stale-packet rejection +/// u16 stateSequence — stale-packet rejection +/// +/// +/// +/// Total body size: 16 bytes from start (opcode + 12-byte payload). +/// +/// +/// +/// Server-side reference: +/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15 +/// (ACE writes the same field order but appears to use uint for the +/// sequence fields; verified against retail format by hex-dump probe in +/// Task 5). Holtburger has been validated against a retail-format server, +/// so its 12-byte payload is the trusted spec. +/// +/// +public static class SetState +{ + public const uint Opcode = 0xF74Bu; + + public readonly record struct Parsed( + uint Guid, + uint PhysicsState, + ushort InstanceSequence, + ushort StateSequence); + + /// + /// Parse a 0xF74B body. must start with the + /// 4-byte opcode (matches the convention used by VectorUpdate / + /// UpdateMotion / UpdatePosition). Returns null on truncation or + /// opcode mismatch. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 16) return null; + try + { + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4)); + if (opcode != Opcode) return null; + + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)); + uint state = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4)); + ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(12, 2)); + ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(14, 2)); + + return new Parsed(guid, state, instSeq, stateSeq); + } + catch + { + return null; + } + } +} +``` + +- [ ] **Step 1.4: Run tests to verify they pass (GREEN)** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStateTests"` +Expected: 3 passed. + +- [ ] **Step 1.5: Verify project build still green** + +Run: `dotnet build` +Expected: Build succeeded, 0 errors, 0 new warnings. + +- [ ] **Step 1.6: Commit** + +``` +git add src/AcDream.Core.Net/Messages/SetState.cs tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs +git commit -m "feat(phys L.2g slice 1): inbound SetState (0xF74B) parser + +DTO + TryParse for the GameMessageSetState wire message. The server +broadcasts this when an already-spawned entity's PhysicsState changes +post-CreateObject — chiefly when a door's Ethereal bit toggles on Use. + +Wire format per holtburger SetStateData (validated against retail-format +servers): u32 opcode + u32 guid + u32 state + u16 instanceSequence + u16 +stateSequence = 16 bytes total. Mirrors the existing VectorUpdate.cs +template. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `ShadowObjectRegistry.UpdatePhysicsState(guid, newState)` + +**Files:** +- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (add new method after `UpdatePosition`, before `Deregister`) +- Modify: `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs` (append new test) + +**Design rationale:** `ShadowEntry` is a `readonly record struct` (value type) +stored as copies inside per-cell `List`. Mutation pattern: +find every cell the entity occupies via `_entityToCells[entityId]`, then +replace each in-list copy with `list[i] = list[i] with { State = newState }`. + +**Retail anchor:** `CPhysicsObj::set_state` at +`docs/research/named-retail/acclient_2013_pseudo_c.txt:283044`. Retail +does `this->state = arg2` (line 283048) — direct overwrite. Our cached +state lives in the registry copy, not the entity, so the equivalent is +"overwrite every shadow copy." + +- [ ] **Step 2.1: Write the failing test** + +Append to `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs` +(top-of-file using directives already have `AcDream.Core.Physics` + xUnit): + +```csharp + // ----------------------------------------------------------------------- + // UpdatePhysicsState — L.2g slice 1 (doors flip ETHEREAL post-spawn) + // ----------------------------------------------------------------------- + + [Fact] + public void UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits() + { + // Register a door-like entity with State=0 (closed = solid). + var reg = new ShadowObjectRegistry(); + const uint doorId = 0x000F4244u; + reg.Register(doorId, 0x020019FFu, new Vector3(12f, 12f, 50f), + Quaternion.Identity, 1f, OffX, OffY, LbId, + state: 0u, flags: EntityCollisionFlags.None); + + // Sanity: cached state starts at 0 (no ETHEREAL). + var before = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId); + Assert.Equal(0u, before.State); + + // Flip ETHEREAL_PS (0x4) — the server's "door is now open" message. + reg.UpdatePhysicsState(doorId, 0x00000004u); + + // Cached state should now show the new bit. + var after = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId); + Assert.Equal(0x00000004u, after.State); + } + + [Fact] + public void UpdatePhysicsState_UnregisteredEntity_IsNoOp() + { + var reg = new ShadowObjectRegistry(); + // No entity registered. Should not throw. + reg.UpdatePhysicsState(0xDEADBEEFu, 0x00000004u); + Assert.Equal(0, reg.TotalRegistered); + } + + [Fact] + public void UpdatePhysicsState_EntitySpanningMultipleCells_AllCellsUpdated() + { + // Entity at (24,12) with radius=2 spans cells (0,0) and (1,0). + var reg = new ShadowObjectRegistry(); + reg.Register(99u, 0x01000099u, new Vector3(24f, 12f, 50f), + Quaternion.Identity, 2f, OffX, OffY, LbId, + state: 0u); + + reg.UpdatePhysicsState(99u, 0x00000004u); + + uint cellA = LbId | 1u; // cx=0 + uint cellB = LbId | (1u*8 + 0 + 1); // cx=1 + var inA = reg.GetObjectsInCell(cellA).Single(e => e.EntityId == 99u); + var inB = reg.GetObjectsInCell(cellB).Single(e => e.EntityId == 99u); + Assert.Equal(0x00000004u, inA.State); + Assert.Equal(0x00000004u, inB.State); + } +``` + +You may need a `using System.Linq;` at the top of the test file. Add it if +not already present. + +- [ ] **Step 2.2: Run tests to verify they fail (RED)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~UpdatePhysicsState"` +Expected: Compile error — `UpdatePhysicsState` method not defined. + +- [ ] **Step 2.3: Implement `UpdatePhysicsState`** + +Insert into `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` after the +`UpdatePosition` method (around line 127, before the `Deregister` summary +comment): + +```csharp + /// + /// Update the cached bits for an + /// already-registered entity. Called by the inbound + /// SetState (0xF74B) dispatcher when the server broadcasts a + /// post-spawn PhysicsState change — chiefly doors flipping + /// ETHEREAL_PS = 0x4 on Use, so the + /// short-circuit can honor + /// the new state on the next resolve. + /// + /// + /// Retail equivalent: CPhysicsObj::set_state at + /// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044 + /// — direct write `this->state = arg2`. Retail also fires side-effect + /// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden) + /// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1 + /// scopes to the bare state-write. + /// + /// + /// + /// Implementation: is a value-type record + /// copied into per-cell lists, so we rewrite the copy in each cell the + /// entity occupies. Unregistered entities are a no-op (callers don't + /// have to gate). + /// + /// + public void UpdatePhysicsState(uint entityId, uint newState) + { + if (!_entityToCells.TryGetValue(entityId, out var cellIds)) + return; // not registered — no-op + + foreach (var cellId in cellIds) + { + if (!_cells.TryGetValue(cellId, out var list)) continue; + for (int i = 0; i < list.Count; i++) + { + if (list[i].EntityId == entityId) + list[i] = list[i] with { State = newState }; + } + } + } +``` + +- [ ] **Step 2.4: Run tests to verify they pass (GREEN)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~UpdatePhysicsState"` +Expected: 3 passed. + +- [ ] **Step 2.5: Verify full test suite still green (no regressions)** + +Run: `dotnet test` +Expected: All previously-passing tests still pass. **Note:** ~8 pre-existing +failures may be in the baseline (see `docs/research/2026-05-13-l2d-slice1-shipped-handoff.md` +"Open concerns"); ensure the count does not increase. Stash + rerun to +confirm if uncertain: `git stash && dotnet test 2>&1 | findstr Failed` then +`git stash pop`. + +- [ ] **Step 2.6: Commit** + +``` +git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +git commit -m "feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState + +New mutator that overwrites cached PhysicsState bits on every shadow copy +of the named entity. The existing CollisionExemption.ShouldSkip(...) check +(acclient_2013_pseudo_c.txt:276782) reads the same cached field, so a +post-spawn ETHEREAL flip is now honored on the next resolver tick without +any resolver-path change. + +Retail anchor: CPhysicsObj::set_state at acclient_2013_pseudo_c.txt:283044. +Slice 1 scopes to the bare state-write — retail's cosmetic side-effect +handlers (0x800 lighting, 0x20 nodraw, 0x4000 hidden) don't fire for the +ETHEREAL bit and stay deferred. + +Three TDD tests cover: ETHEREAL flip from 0->0x4; unregistered-entity +no-op; entity spanning multiple cells gets all copies updated. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Wire `0xF74B` into `WorldSession` dispatcher + new event + +**Files:** +- Modify: `src/AcDream.Core.Net/WorldSession.cs` (one new event declaration near the existing `VectorUpdated`/`MotionUpdated` events; one new `else if` branch in the inbound dispatcher near `op == VectorUpdate.Opcode`) + +**Reference pattern:** Read the existing `VectorUpdate.Opcode` branch first +(it's at WorldSession.cs:739–752). Copy its shape exactly. + +- [ ] **Step 3.1: Add the public event declaration** + +Find the existing `public event Action<...>? VectorUpdated;` declaration +in `WorldSession.cs` (near line 119, in the events region). Add a sibling: + +```csharp + /// + /// Fires when the server broadcasts a SetState (0xF74B) game + /// message — a previously-spawned entity's PhysicsState + /// bitmask changed post-CreateObject. Chiefly doors flipping + /// ETHEREAL_PS = 0x4 on Use (see ACE + /// WorldObjects/Door.cs:127, WorldObject.cs:640-660). + /// Subscribers route the new state into + /// so the + /// existing collision-exemption short-circuit honors the flip on the + /// next resolver tick. + /// + public event Action? StateUpdated; +``` + +Place it immediately after the existing `VectorUpdated` event for grep- +findability. + +- [ ] **Step 3.2: Add the dispatcher branch** + +In the inbound game-message dispatcher (the chain of `else if (op == X.Opcode)` +branches in the same file), add this branch immediately after the +`VectorUpdate.Opcode` branch: + +```csharp + else if (op == SetState.Opcode) + { + // L.2g slice 1 (2026-05-12): server broadcasts SetState + // (0xF74B) when an entity's PhysicsState changes + // post-spawn — chiefly doors flipping ETHEREAL on Use. + // Holtburger validated wire format = 16 bytes (opcode + + // guid + state + 2×u16 sequence). ACE + // GameMessageSetState.cs writes the same field order + // but appears to use u32 for the sequences; Task 5's + // hex-dump probe settles the actual byte count. + var parsed = SetState.TryParse(body); + if (parsed is not null) + StateUpdated?.Invoke(parsed.Value); + } +``` + +The `using AcDream.Core.Net.Messages;` directive should already be at the +top of WorldSession.cs (it's used by every existing parser). Confirm, +don't add a duplicate. + +- [ ] **Step 3.3: Verify build still green** + +Run: `dotnet build` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 3.4: Commit** + +``` +git add src/AcDream.Core.Net/WorldSession.cs +git commit -m "feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + +New StateUpdated event + dispatcher branch routes inbound SetState +messages to subscribers. Mirrors the existing VectorUpdated / +MotionUpdated event pattern. GameWindow will subscribe in the next +commit and feed the parsed (guid, newState) pair to +ShadowObjectRegistry.UpdatePhysicsState. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Subscribe in `GameWindow` and feed `ShadowObjectRegistry` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (one new subscription line + one new handler method) + +This is the final wiring step. After this commit, the server's "door opened" +SetState is end-to-end honored by the collision system. + +- [ ] **Step 4.1: Add the subscription** + +Find the block in `GameWindow.cs` where `_liveSession.MotionUpdated += +OnLiveMotionUpdated;` and `_liveSession.PositionUpdated += +OnLivePositionUpdated;` are wired (around line 1791). Add: + +```csharp + _liveSession.StateUpdated += OnLiveStateUpdated; +``` + +Place it after `_liveSession.VectorUpdated += OnLiveVectorUpdated;` so the +event-subscription order is co-located with its peers. + +- [ ] **Step 4.2: Add the handler method** + +Find the existing `OnLiveVectorUpdated` method body in the same file +(grep `private void OnLiveVectorUpdated`). Add a sibling handler +immediately after it: + +```csharp + /// + /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the + /// new PhysicsState bits into ShadowObjectRegistry so the + /// existing check honors + /// the flip on the next resolver tick. Chiefly doors: + /// server flips ETHEREAL_PS = 0x4 on Use, the door's + /// cylinder collision stops blocking the threshold. + /// + private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed) + { + _physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState); + + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[setstate] guid=0x{parsed.Guid:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}")); + } +``` + +- [ ] **Step 4.3: Verify build still green** + +Run: `dotnet build` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4.4: Smoke-test that no regression breaks the launch path** + +Run a quick non-interactive smoke (do NOT do the full visual test yet — +that's Task 7): + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "0" # offline; just verify the binary starts +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Select-String -Pattern "Exception|FATAL" | + Select-Object -First 5 +``` + +Then kill the process. Expected: no startup exception, no FATAL. If +anything blows up, the new handler subscription or the registry mutator +broke something in the live-session attach path. + +- [ ] **Step 4.5: Commit** + +``` +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(phys L.2g slice 1): GameWindow routes SetState into registry + +End-to-end wiring: WorldSession.StateUpdated fires -> GameWindow +OnLiveStateUpdated -> ShadowObjectRegistry.UpdatePhysicsState -> next +resolver tick sees the updated ETHEREAL bit and CollisionExemption +short-circuits the door cylinder. After this commit the M1 'open the +inn door' scenario is unblocked at the code-path level; visual +verification follows in slice 1's manual test (Task 7). + +The handler also emits a [setstate] diagnostic line when +ACDREAM_PROBE_BUILDING is enabled — gives a greppable trail when the +visual test runs. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Hex-dump probe for first SetState payload (wire-byte verification) + +**Files:** +- Modify: `src/AcDream.Core.Net/WorldSession.cs` (extend the existing `else if (op == SetState.Opcode)` branch added in Task 3) + +**Why:** ACE's `GameMessageSetState.cs:13-14` writes `Sequences.GetCurrentSequence(...)` ++ `Sequences.GetNextSequence(...)` as `uint` calls — potentially 4 bytes +each (16-byte total payload) instead of holtburger's 12 bytes. We default to +holtburger's spec because it's been validated against live retail-format +servers, but we want one-shot evidence on real wire bytes before declaring +slice 1 done. + +The probe is gated on `ACDREAM_PROBE_BUILDING` (existing env var from +L.2d slice 1) and fires once per SetState message; the body bytes are +short enough that this is cheap. + +- [ ] **Step 5.1: Extend the dispatcher branch with a hex-dump** + +Update the `else if (op == SetState.Opcode)` branch from Task 3 to: + +```csharp + else if (op == SetState.Opcode) + { + // L.2g slice 1 (2026-05-12) — see Task 3 above for the + // event-routing intent. The probe-gated hex-dump here + // captures the wire bytes one-shot per session so we can + // confirm holtburger's 12-byte payload format (vs ACE's + // GameMessageSetState.cs claim of u32 sequences = 16 + // bytes) before declaring slice 1 done. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled + && !_setStateHexDumped) + { + _setStateHexDumped = true; + var hex = string.Join(" ", body.Take(Math.Min(body.Length, 32)) + .Select(b => b.ToString("X2"))); + Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}"); + } + + var parsed = SetState.TryParse(body); + if (parsed is not null) + StateUpdated?.Invoke(parsed.Value); + } +``` + +Add the one-shot flag field near the top of the `WorldSession` class +(group with other `_dump*Enabled` flags — grep `private bool _` to find +the cluster): + +```csharp + /// L.2g slice 1: one-shot guard so the [setstate-hex] probe + /// emits the first SetState's body bytes only, not 5–10/sec. + private bool _setStateHexDumped; +``` + +Note: the `body.Take(...)` requires `using System.Linq;` — already present. + +- [ ] **Step 5.2: Verify build still green** + +Run: `dotnet build` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 5.3: Commit** + +``` +git add src/AcDream.Core.Net/WorldSession.cs +git commit -m "feat(phys L.2g slice 1): one-shot hex-dump probe for SetState payload + +Probe-gated diagnostic (ACDREAM_PROBE_BUILDING) emits the first inbound +SetState message's body bytes so we can confirm holtburger's 12-byte +payload format vs ACE's GameMessageSetState.cs claim of u32 sequences +(16-byte payload). One-shot via _setStateHexDumped — won't flood the +log when doors auto-close every 30s. + +If the hex-dump shows body.len > 16, the parser's body-length gate at +SetState.cs needs widening (and the seq-field reads shifted accordingly). +If it shows 16, we ship as-is. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Slice 0.5 — extend `[entity-source]` log with `state` + `flags` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (extend the existing `[entity-source]` log line — there are 2 sites; modify both for consistency) + +**Why:** The L.2d slice 1 handoff flagged this as a useful "slice 1.6" +addendum. It's 5 LOC, fold-into-slice-1-freebie. Makes ETHEREAL flips +greppable end-to-end: spawn -> registry update -> resolver effect. + +- [ ] **Step 6.1: Find both `[entity-source]` log sites** + +Grep `[entity-source]` in `src/AcDream.App/Rendering/GameWindow.cs` and +note the two `Console.WriteLine` calls (one is around line 2978 from the +RegisterLiveEntityForCollision path; the other should be in the +landblock-baked static registration path — grep confirms by file). Both +need the same suffix addition. + +- [ ] **Step 6.2: Extend both log lines** + +For each `[entity-source]` line, append `state=0x{state:X8} flags={flags}` +to the format string. Example transformation: + +Before: +```csharp +Console.WriteLine(System.FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root")); +``` + +After (note: the local variables `state` and `flags` should already be +in scope at both sites — they're computed just before the +`ShadowObjects.Register(...)` call; grep upward 5–10 lines from each log +site to confirm): + +```csharp +Console.WriteLine(System.FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root state=0x{state:X8} flags={flags}")); +``` + +If the `state` or `flags` variables are scoped differently at one site +(e.g. one site is for landblock-baked statics that always have state=0), +substitute the literal `0u` or `EntityCollisionFlags.None` and add a +comment noting the static-default. Keep the field names identical at both +sites so a single regex `state=0x([0-9A-F]+)` catches every entry. + +- [ ] **Step 6.3: Verify build still green** + +Run: `dotnet build` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 6.4: Commit** + +``` +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(phys L.2g slice 1): extend [entity-source] log with state + flags + +5-LOC freebie folded into L.2g slice 1: the [entity-source] probe now +emits the PhysicsState bits + EntityCollisionFlags decoded at +registration. Combined with the new [setstate] handler log line, this +makes door open/close events fully greppable end-to-end: + spawn -> [entity-source] guid=... state=0x00000000 ... + Use -> [setstate] guid=... state=0x00000004 ... + close -> [setstate] guid=... state=0x00000000 ... + +Resolves the 'slice 1.6' suggestion from +docs/research/2026-05-13-l2d-slice1-shipped-handoff.md. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Visual verification at Holtburg inn doorway + +**Files:** None — this is a user-driven test. Document the recipe; report +results in the handoff doc (Task 8). + +**Acceptance:** +1. Walk acdream `+Acdream` into the Holtburg inn doorway. **Expected: blocked at threshold.** +2. Click the door (Use action). **Expected: door swings open; `[setstate]` log line emits with `state=0x00000004`; walk through clears.** +3. Wait ~30 seconds. **Expected: door auto-closes; `[setstate]` log line emits with `state=0x00000000`; threshold blocks again.** +4. Inspect the `[setstate-hex]` line emitted on the first SetState — confirm `body.len=16`. If it's 20 instead, slice 1 has a bug to file as 1b. + +- [ ] **Step 7.1: Launch the client with probes enabled** + +Wait ~5 seconds since the last close (per CLAUDE.md's logout-before-reconnect +note) then: + +```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 = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_DEVTOOLS = "1" +$env:ACDREAM_PROBE_BUILDING = "1" +$env:ACDREAM_PROBE_RESOLVE = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch-l2g-slice1.log" +``` + +- [ ] **Step 7.2: Manually perform the four-step scenario** + +(User-driven. See Acceptance list above.) + +- [ ] **Step 7.3: Inspect the log for the four expected lines** + +After closing the client window: + +```powershell +Select-String -Path launch-l2g-slice1.log -Pattern "setstate-hex|setstate.*guid|entity-source.*Door" +``` + +Expected matches: +- One `[setstate-hex] body.len=16 first-16-bytes: 4B F7 ...` line. +- One `[entity-source] ... name=Door ... state=0x00000000 ...` (or similar). +- A `[setstate] guid=0x000F.... state=0x00000004 ...` after the Use click. +- A `[setstate] guid=0x000F.... state=0x00000000 ...` ~30s after the previous. + +- [ ] **Step 7.4: Decide ship-or-fix** + +Three outcomes: +- **All four log lines match + door scenario works visually:** slice 1 ships. Proceed to Task 8. +- **Log lines correct but visual scenario fails (door visually opens but player still blocked):** the resolver is reading stale state from somewhere we haven't found. Stop and file a "slice 1b — find the second cache layer" note. +- **`[setstate-hex] body.len=20`:** ACE's u32 sequence claim is real. Widen `SetState.cs` body-length gate (`16` -> `20`) and shift sequence reads to `body.Slice(12, 4)` + `body.Slice(16, 4)` (read as `uint`, cast to `ushort` if values are small — high bits will be zero per ACE's `Sequences` design). Re-run from Task 7.1. + +--- + +## Task 8: Ship handoff doc + roadmap update + +**Files:** +- Create: `docs/research/2026-05-XX-l2g-slice1-shipped-handoff.md` (replace `XX` with the actual ship date) +- Modify: `CLAUDE.md` (replace the "the natural next step is the L.2g slice 1 implementation" paragraph with a "Phase L.2g slice 1 shipped " paragraph mirroring the L.2a paragraph style) +- Modify: `docs/plans/2026-04-29-movement-collision-conformance.md` (under the L.2g section, add a "Current shipped slice" subsection noting slice 1 + its commit hashes) + +- [ ] **Step 8.1: Write the ship handoff doc** + +Use the existing handoff at +`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md` as a template. The +new doc should cover: + +- TL;DR: what landed, did the visual test pass. +- What shipped (commit hash + subject per commit from Tasks 1–6). +- What the visual test showed (the four log-line samples from Task 7.3). +- Wire-byte width resolution (12-byte vs 16-byte — whichever the hex-dump + showed). +- Side findings (anything noticed during visual test — door animation + flickers, audio not playing, etc — file under "deferred"). +- Next-session candidates (L.2g slice 2 animation confirmation, deferred UX + polish, OR pick from CLAUDE.md's now-revised "Next phase candidates" + list). + +- [ ] **Step 8.2: Update CLAUDE.md** + +Find the "Currently in Phase L.2 (Movement & Collision Conformance)" +paragraph. Replace its "the natural next step is the L.2g slice 1 +implementation" sentence with "L.2g slice 1 shipped — doors honor +ETHEREAL flips end-to-end; visual-verified at Holtburg inn doorway." Add +a "**Phase L.2g slice 1 shipped .**" descriptive paragraph after +the L.2a paragraph (mirror the L.2a paragraph's depth). + +In the "**Next phase candidates**" list, demote the current L.2g item +out and pick whichever is the next sensible candidate (likely L.2g slice 2 +animation confirmation OR a non-L.2 visual-fidelity item — depends on what +the visual test in Task 7 showed). + +- [ ] **Step 8.3: Update the L.2 plan-of-record** + +In `docs/plans/2026-04-29-movement-collision-conformance.md`, under the +L.2g section, add a "Current shipped slice ():" subsection +listing the slice 1 commit hashes + their subjects (use git log to fill +in). Mirror the L.2c "Current shipped slice (2026-04-30):" subsection +style. + +- [ ] **Step 8.4: Commit** + +``` +git add CLAUDE.md docs/plans/2026-04-29-movement-collision-conformance.md docs/research/2026-05-XX-l2g-slice1-shipped-handoff.md +git commit -m "docs(phys L.2g): slice 1 shipped handoff + plan-of-record + CLAUDE.md + +Slice 1 visual-verified at Holtburg inn doorway: walking into closed door +is blocked, Use opens it, walk-through clears, auto-close re-blocks at 30s. +Wire-byte width settled (see handoff doc). + +L.2g slice 2 (animation confirmation) becomes the next candidate IF the +visual test showed door animation not playing; otherwise slice 2 is a +verify-only no-op and we move to the next phase candidate. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Plan self-review + +**1. Spec coverage check:** + +| Spec section | Task | +|---|---| +| Slice 1 — parse SetState (0xF74B) | Tasks 1 + 3 + 5 | +| Slice 1 — plumb new state into ShadowObjectRegistry | Tasks 2 + 4 | +| Slice 1 — visual verification at Holtburg | Task 7 | +| Slice 0.5 — extend [entity-source] log with state + flags | Task 6 | +| Open Q1 — wire-byte width | Task 5 (hex-dump probe) + Task 7.4 (decision branch) | +| Open Q2 — UpdateMotion drives non-creature entities (door swing animation) | **Deferred to slice 2** (per the spec — animation is verify-only) | +| Open Q3 — SetState delivered to the player who triggered Use | Task 7 visual test verifies (covered implicitly by the four-step scenario) | +| Acceptance — design spec, plan-of-record, milestones, CLAUDE.md all reference L.2g | Already done in `2c10dd4`; Task 8 closes the loop with the slice 1 ship handoff | +| Named retail citation in slice 1 code | Task 2.3 cites `acclient_2013_pseudo_c.txt:283044`; Task 1.3 cites the holtburger struct | + +**2. Placeholder scan:** No `TBD`, `TODO`, "fill in later." `` in +Task 8 is a deliberate placeholder for the engineer to fill in at ship +time — flagged as such in the handoff doc template, not a plan-writing +oversight. The `Task 8.1` doc filename uses `2026-05-XX` for the same +reason. + +**3. Type consistency:** `SetState.Parsed(Guid, PhysicsState, InstanceSequence, StateSequence)` +used consistently in Tasks 1, 3, 4, 5. `UpdatePhysicsState(uint entityId, uint newState)` +signature consistent in Tasks 2 + 4. `ShadowEntry.State` matches the +existing struct definition in `ShadowObjectRegistry.cs:262-280`. + +**4. Risk surface:** All changes are additive. No resolver edits. No +broadphase edits. No retail-port semantics changes. If anything goes +wrong, single-commit revert per task. + +--- + +## Execution + +Plan complete. Two execution options when ready: + +**1. Subagent-Driven (recommended)** — Dispatch a fresh Sonnet subagent per task (Task 1 alone, Tasks 2 + 3 together, Task 4 + 5 + 6 together, Task 7 user-driven, Task 8 docs). Review between dispatches. Each subagent stays bounded to one commit's worth of changes; parent context stays clean. + +**2. Inline Execution** — Drive all tasks in this session using executing-plans. Faster end-to-end but consumes ~4× more parent context. + +Total scope estimate: ~6 commits over ~30–60 minutes of work + Task 7 visual test (~10 minutes when ACE + retail client are already running). diff --git a/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md b/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md new file mode 100644 index 0000000..b7c1e7d --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md @@ -0,0 +1,286 @@ +# Phase L.2g — Dynamic PhysicsState Toggling + +**Status:** Design spec, created 2026-05-12 evening after L.2d slice 1+1.5 +ship and brainstorm completion. +**Branch:** `claude/gallant-mestorf-3bf2e3`. +**Predecessor:** [docs/research/2026-05-13-l2d-slice1-shipped-handoff.md](../../research/2026-05-13-l2d-slice1-shipped-handoff.md) +identified the Holtburg-doorway blocker as a closed Door entity (Setup +`0x020019FF`), not a building-collision-mesh bug. L.2g is the +sub-phase that handles the door-state work the L.2d handoff deferred. +**Roadmap owner:** new L.2 sub-lane "dynamic state" — the L.2 plan-of-record +([docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md)) +explicitly anticipates the L.2g letter (L.2d revised sub-direction +paragraph, lines 195–197). +**Milestone:** M1 — Walkable + clickable world. Demo scenario *"open +the inn door"* depends on this slice landing. + +--- + +## TL;DR + +After the player Uses a door, ACE broadcasts two messages: an +`UpdateMotion` to play the swing-open animation, and a +`GameMessageSetState (opcode 0xF74B)` to flip the door entity's +`PhysicsState.Ethereal` bit. The client must honor the state flip so +the door's collision cylinder stops blocking the threshold while the +door is open. The auto-close (30s) is a second SetState round-trip; +client just follows. + +acdream already parses `PhysicsState` from `CreateObject` and +already short-circuits ETHEREAL targets in +[CollisionExemption.cs](../../../src/AcDream.Core/Physics/CollisionExemption.cs). +**The single missing piece is parsing `0xF74B SetState` and +propagating the new state to `ShadowObjectRegistry`'s cached entity +record.** Everything else already works. Slice 1 is roughly one +commit. + +--- + +## Why L.2g (and not B.4 or "doors only") + +Three placement options were considered during the 2026-05-12 +brainstorm: + +| Option | Verdict | +|---|---| +| **Nest under B.4 interaction** | Rejected. B.4's scope is the *outbound* Use / UseWithTarget / PickUp packet (shipped 2026-04-28). Door state is an inbound + collision-state-toggle problem, not an outbound interaction one. | +| **Special-case Door Setup ID (`0x020019FF`)** | Rejected. Same wire mechanism (`SetState` flipping Ethereal) is also how ACE handles activated traps, opened chests, spell projectiles that become ethereal, and any other server-driven collision-state flip. Specializing on Door Setup ID would leave all those cases broken and re-emerge later as separate bugs. | +| **New L.2 sub-phase "L.2g — Dynamic PhysicsState toggling"** | **Selected.** L.2 already owns "movement & collision conformance"; a door you can't walk through after the server says it's open is a collision-conformance bug. Generic infrastructure (any entity, any state bit) with doors as the verification scenario. | + +The L.2 plan-of-record's L.2d revised paragraph already names L.2g +as a possible letter for this work; we're claiming it. + +**Lane assignment:** informal sixth lane "dynamic state." Updates the +lane table in the L.2 plan-of-record to include collision-state-toggle +as a first-class concern. + +--- + +## Problem evidence + +From the L.2d slice 1.5 trace (Holtburg, 2026-05-12): + +``` +live: spawn guid=0x7A9B4015 name="Door" setup=0x020019FF + pos=(132.6,17.1,94.1)@0xA9B40029 itemType=0x00000080 +[entity-source] id=0x000F4244 entityId=0x000F4244 src=0x020019FF + gfxObj=0x020019FF lb=0xA9B40029 type=Cylinder note=server-spawn-root +``` + +Five Door entities across Holtburg town (cells `0xA9B40029`, +`0xA9B40154`, `0xA9B40155`); each blocks its building's threshold with a +Cylinder collision. The 121 wall hits the L.2a probe attributed to the +building BSP turned out to be the player **already pushed back by the +Door cylinder** then grazing the doorframe. Slice 1.5's per-tick probe +showed `nObj=3` on every doorway resolve: one Door + two sphere checks +against the building BSP. + +The actual blocker is the closed Door, not the building. The blocker +goes away when the Door's PhysicsState gains the Ethereal bit (server +sets this in `Door.Open()`, see +[references/ACE/Source/ACE.Server/WorldObjects/Door.cs:127](../../../references/ACE/Source/ACE.Server/WorldObjects/Door.cs)). + +--- + +## Wire flow + +### Server → client when the player Uses a door + +ACE's `Door.ActOnUse(player)` runs the following sequence: + +1. Check `IsLocked` + behind-test (AC retail allows opening locked doors + from behind). If locked-and-not-behind: broadcast a "door is locked" + chat string + sound effect, no state change. Otherwise: +2. `EnqueueBroadcastMotion(motionOpen)` — broadcasts an + `UpdateMotion` to all clients in range, motion = `(NonCombat, On)`. + This is the door's animation command. +3. `Ethereal = true; EnqueueBroadcastPhysicsState()` — broadcasts a + `GameMessageSetState (0xF74B)`. The new `PhysicsState` value has bit + `0x4` (Ethereal) set. +4. Sets `IsBusy = true` for the duration of the open animation. +5. Schedules `FinalizeClose` after `ResetInterval` (default 30s). + +### Server → client when the auto-close fires + +1. `EnqueueBroadcastMotion(motionClosed)` — `UpdateMotion (NonCombat, Off)`. +2. After the close animation completes, server runs `FinalizeClose`: + `Ethereal = false; EnqueueBroadcastPhysicsState()` — another + `0xF74B SetState` with the Ethereal bit cleared. + +### The wire format of `0xF74B SetState` + +Two sources, **mildly disagreeing on sequence-field width:** + +[GameMessageSetState.cs](../../../references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs) +in ACE writes: + +``` +guid : uint32 (4) +state : uint32 (4) +instance_sequence : uint32 (4) <-- ACE says u32 +state_sequence : uint32 (4) <-- ACE says u32 +``` + +[properties.rs](../../../references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs) +in holtburger parses: + +``` +guid : uint32 (4) +state : uint32 (4) +instance_sequence : uint16 (2) <-- holtburger says u16 +state_sequence : uint16 (2) <-- holtburger says u16 +``` + +Holtburger has been validated against a retail-format server in the +wild. ACE's `Writer.Write((uint)sequence)` may or may not be using a +packed-write extension that downsizes to u16 — needs verification. **The +slice 1 implementation will default to holtburger's 12-byte format and +add a startup hex-dump probe to confirm before the parser is +committed.** If the actual payload is 16 bytes, the parser can be +trivially widened. + +### `PhysicsState.Ethereal` + +Value `0x00000004` (bit 2). Confirmed in: + +- ACE: `references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:10` +- acdream: `src/AcDream.Core/Physics/PhysicsBody.cs:30` +- Retail header: `docs/research/named-retail/acclient.h:2819` (cited + as `ETHEREAL_PS=0x4` in `CollisionExemption.cs:43`). + +--- + +## Current acdream state + +| Component | State | +|---|---| +| `PhysicsState` enum (Ethereal bit) | ✅ defined in `src/AcDream.Core/Physics/PhysicsBody.cs:30` | +| `CollisionExemption.IsExempt(...)` | ✅ already short-circuits when `(ETHEREAL_PS \| IGNORE_COLLISIONS_PS)` are both set on the target. Cites `acclient_2013_pseudo_c.txt:276782`. | +| `CreateObject` parses `PhysicsState` into the entity's shadow record | ✅ since 2026-04-29 | +| `UpdateMotion` pipeline for remote entities | ✅ works for player remotes; need to confirm it accepts non-creature entities with `(NonCombat, On/Off)` | +| `SetState (0xF74B)` inbound parser | ❌ does not exist | +| Propagating a post-spawn PhysicsState change into `ShadowObjectRegistry`'s cached state | ❌ does not exist | +| `[entity-source]` probe log captures `state` bits | ❌ — handoff's "slice 1.6" suggestion | + +--- + +## Slice plan + +### Slice 0.5 (optional prereq, fold into slice 1 if convenient) + +Add `PhysicsState` + `EntityCollisionFlags` to the `[entity-source]` +probe log line. Makes ETHEREAL flips observable from launch-log grep. +~5 LOC under the existing `ACDREAM_PROBE_BUILDING` flag. + +### Slice 1 — MVP (the M1-blocker slice) + +Goal: walk into the Holtburg inn doorway, click Use on the door, walk +through. + +Touchpoints: + +- `src/AcDream.Core.Net/` — new `SetStateMessage` parser for opcode + `0xF74B`. Default to holtburger's 12-byte format; add a hex-dump + emit on first message receipt to confirm exact byte width before the + parser commits. +- `src/AcDream.Core.Net/WorldSession.cs` (or wherever inbound game- + message dispatch lives) — route `0xF74B` to the new parser, then + forward the `(guid, newState)` pair to the entity layer. +- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — new + `UpdatePhysicsState(guid, newState)` method that mutates the + cached state bits on the matching shadow entry. The existing + `CollisionExemption` check reads from this cached state, so no + resolver changes needed. +- Tests — synthetic test in + `tests/AcDream.Core.Tests/` that constructs a ShadowEntry with + `Ethereal=false`, calls `UpdatePhysicsState` flipping it on, and + asserts the next collision query returns "exempt." +- Visual verification — Holtburg inn doorway. Walk in, observe blocked. + Click Use. Observe door swings open AND player can now walk through. + Wait 30 seconds. Observe door closes AND player is blocked again. + +Acceptance: +- `dotnet build` + `dotnet test` green. +- `[resolve]` probe shows the Door cylinder no longer firing when the + door is open. +- Visual verification at Holtburg passes. +- Wire-byte width settled by hex-dump evidence; parser uses correct width. + +### Slice 2 — animation confirmation + +Goal: the door visually swings open and shut, not just becomes +walk-through. + +Most likely a no-op: the existing `UpdateMotion` pipeline that runs +`(NonCombat, On/Off)` commands for player remotes should drive any +entity with a MotionTable. Doors have a MotionTable (the same Setup +`0x020019FF`). Slice 2 is **verify, then either declare done or fix +whatever's missing**. + +If a fix is needed, the most likely cause is the motion handler +gating on `entity is Creature` somewhere upstream — a one-line removal +or a stance-relaxation in `MotionInterpreter`. + +### Deferred — UX polish + +These open only if observation demands them: + +- Sound on "door is locked" (ACE sends a `GameMessageSound` for + `Sound.OpenFailDueToLock`; verify acdream's audio pipeline plays it + via the existing 0xF755 handler). +- Bump-AI for creatures (ACE's `Door.OnCollideObject` auto-opens for + creatures with `AiOptions != 0`). This is server-driven; client gets + the same `SetState` flow. Probably no-op for the client. + +--- + +## Open questions to resolve in implementation + +1. **Wire-byte width of `0xF74B` sequence fields.** Default to + holtburger (u16+u16 = 12 bytes total). Confirm via hex-dump in slice + 1. If wrong, widen to ACE's claimed format (u32+u32 = 16 bytes). +2. **Does `UpdateMotion`'s existing handler dispatch motion to non- + creature entities?** Verified in slice 2. If no, one-line fix. +3. **Does ACE's `EnqueueBroadcastPhysicsState` skip the player who + triggered the Use, or include them?** Reading ACE's code, `EnqueueBroadcast(...)` + broadcasts to *everyone in range including self*. Slice 1 verifies the + player's own client receives the SetState (not just observers). + +--- + +## Acceptance for L.2g overall + +- All slices marked above as "Acceptance" pass. +- L.2 plan-of-record updated with an L.2g section (matching this spec's framing). +- M1 milestone doc updated: `L.2 (all sub-lanes a–f)` → `L.2 (all sub-lanes a–g)`. +- CLAUDE.md's "currently in Phase L.2" paragraph updated to point at L.2g as the active sub-phase. +- A short ship handoff doc filed at + `docs/research/2026-05-XX-l2g-shipped-handoff.md` when slice 1+2 land. + +--- + +## Named retail anchors (for slice 1 code citations) + +- `CPhysicsObj::set_state` — the retail client's setter. Search by + `set_state\(` in + [docs/research/named-retail/acclient_2013_pseudo_c.txt](../../research/named-retail/acclient_2013_pseudo_c.txt). +- `CPhysicsObj::report_collision_with_object` — the retail per-object + collision-test entry; calls `CollisionExemption.IsExempt`-equivalent + inline. +- Header struct: `CPhysicsObj` in + [docs/research/named-retail/acclient.h](../../research/named-retail/acclient.h) + — `state` field is the `PhysicsState` bitmask. + +--- + +## Risk + rollback + +Risk is low. Wire-byte width has a fallback path (widen if hex-dump +shows 16 bytes). ETHEREAL plumbing already exists; we're feeding it +fresh data from one new source. No resolver changes. If slice 1 lands +broken, rollback is a single revert of one commit. + +The slice does **not** touch the broader L.2 collision path. It does +not change `ResolveWithTransition`, BSPQuery, ShadowObjectRegistry +broadphase, or any movement-prediction code. The change-surface is +strictly "one new wire message + one new mutator on cached state." diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3cc4b15..73127de 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1791,6 +1791,7 @@ public sealed class GameWindow : IDisposable _liveSession.MotionUpdated += OnLiveMotionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.VectorUpdated += OnLiveVectorUpdated; + _liveSession.StateUpdated += OnLiveStateUpdated; _liveSession.TeleportStarted += OnTeleportStarted; _liveSession.AppearanceUpdated += OnLiveAppearanceUpdated; @@ -2976,7 +2977,7 @@ public sealed class GameWindow : IDisposable // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root")); + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root state=0x{state:X8} flags={flags}")); } private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) @@ -3754,6 +3755,23 @@ public sealed class GameWindow : IDisposable } } + /// + /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the + /// new PhysicsState bits into ShadowObjectRegistry so the + /// existing check honors + /// the flip on the next resolver tick. Chiefly doors: + /// server flips ETHEREAL_PS = 0x4 on Use, the door's + /// cylinder collision stops blocking the threshold. + /// + private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed) + { + _physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState); + + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[setstate] guid=0x{parsed.Guid:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}")); + } + private static bool IsRemoteLocomotion(uint motion) { uint low = motion & 0xFFu; @@ -5540,9 +5558,11 @@ public sealed class GameWindow : IDisposable // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. // partCached?.BSP?.Root non-null was checked above (else `continue`), // so hasPhys=true on this path. + // state/flags literals: landblock-baked scenery has no server PhysicsState + // broadcast and no PWD bitfield; defaults match static-solid semantics. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{partId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{meshRef.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=partIdx={partIndex} hasPhys=true")); + $"[entity-source] id=0x{partId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{meshRef.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=partIdx={partIndex} hasPhys=true state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); entityBsp++; partIndex++; @@ -5595,9 +5615,10 @@ public sealed class GameWindow : IDisposable origin.X, origin.Y, lb.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight); // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. + // state/flags literals: landblock-baked scenery; no server PhysicsState. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-cylsphere#{ci}")); + $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-cylsphere#{ci} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); entityCyl++; } @@ -5629,9 +5650,10 @@ public sealed class GameWindow : IDisposable origin.X, origin.Y, lb.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight); // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. + // state/flags literals: landblock-baked scenery; no server PhysicsState. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-sphere#{si}")); + $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-sphere#{si} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); entityCyl++; } } @@ -5651,9 +5673,10 @@ public sealed class GameWindow : IDisposable origin.X, origin.Y, lb.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh); // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. + // state/flags literals: landblock-baked scenery; no server PhysicsState. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-radius-fallback")); + $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-radius-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); entityCyl++; } } @@ -5836,9 +5859,10 @@ public sealed class GameWindow : IDisposable origin.X, origin.Y, lb.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight); // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. + // state/flags literals: landblock-baked scenery; no server PhysicsState. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=mesh-aabb-fallback")); + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=mesh-aabb-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); entityCyl++; if (_isScenery) scRegistered++; } diff --git a/src/AcDream.Core.Net/Messages/SetState.cs b/src/AcDream.Core.Net/Messages/SetState.cs new file mode 100644 index 0000000..70740a4 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SetState.cs @@ -0,0 +1,86 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound SetState GameMessage (opcode 0xF74B). The server +/// broadcasts this whenever a previously-spawned entity's +/// PhysicsState bitmask changes after CreateObject — chiefly +/// when a door opens / closes (server flips ETHEREAL_PS = 0x4) or a +/// spell projectile becomes ethereal post-impact. +/// +/// +/// Wire layout (per +/// references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122, +/// matched by every other acdream parser): +/// +/// +/// u32 opcode — 0xF74B +/// u32 objectGuid +/// u32 physicsState — bitmask (acclient.h:2815 / 2819) +/// u16 instanceSequence — stale-packet rejection +/// u16 stateSequence — stale-packet rejection +/// +/// +/// +/// Total body size: 16 bytes (4-byte opcode + 12-byte payload). +/// +/// +/// +/// Server-side reference: +/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15 +/// — ACE writes the same field order using its UShortSequence.CurrentBytes +/// helper (which calls BitConverter.GetBytes((ushort)value) = 2 bytes per +/// sequence field), so its wire output matches holtburger's 12-byte payload. +/// A one-shot ACDREAM_PROBE_BUILDING hex-dump in +/// 's dispatcher (added in the same commit) emits +/// the first SetState body bytes for runtime confirmation. +/// +/// +/// +/// Named-retail anchor: CPhysicsObj::set_state at +/// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044 +/// describes the runtime state-store on the in-memory object +/// (this->state = arg2). The wire format for this opcode is +/// confirmed by holtburger's SetStateData struct; the named-retail +/// decomp does not cover the deserialization path for this opcode. +/// +/// +public static class SetState +{ + public const uint Opcode = 0xF74Bu; + + public readonly record struct Parsed( + uint Guid, + uint PhysicsState, + ushort InstanceSequence, + ushort StateSequence); + + /// + /// Parse a 0xF74B body. must start with the + /// 4-byte opcode (matches the convention used by VectorUpdate / + /// UpdateMotion / UpdatePosition). Returns null on truncation or + /// opcode mismatch. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + if (body.Length < 16) return null; + try + { + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4)); + if (opcode != Opcode) return null; + + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)); + uint state = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4)); + ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(12, 2)); + ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(14, 2)); + + return new Parsed(guid, state, instSeq, stateSeq); + } + catch + { + return null; + } + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index af7d695..85a571a 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -129,6 +129,19 @@ public sealed class WorldSession : IDisposable /// public event Action? VectorUpdated; + /// + /// Fires when the server broadcasts a SetState (0xF74B) game + /// message — a previously-spawned entity's PhysicsState + /// bitmask changed post-CreateObject. Chiefly doors flipping + /// ETHEREAL_PS = 0x4 on Use (see ACE + /// WorldObjects/Door.cs:127, WorldObject.cs:640-660). + /// Subscribers route the new state into + /// so the + /// existing collision-exemption short-circuit honors the flip on the + /// next resolver tick. + /// + public event Action? StateUpdated; + /// /// Fires when the server sends a PlayerTeleport (0xF751) game message, /// signalling that the player is entering portal space. The uint payload @@ -375,6 +388,10 @@ public sealed class WorldSession : IDisposable /// private bool _loginCompleteSent; + /// L.2g slice 1: one-shot guard so the [setstate-hex] probe + /// emits the first SetState's body bytes only, not 5–10/sec. + private bool _setStateHexDumped; + /// /// Phase B.2: per-session game-action sequence counter. Monotonically /// incremented by and embedded in @@ -750,6 +767,28 @@ public sealed class WorldSession : IDisposable if (parsed is not null) VectorUpdated?.Invoke(parsed.Value); } + else if (op == SetState.Opcode) + { + // L.2g slice 1 (2026-05-12): server broadcasts SetState + // (0xF74B) when an entity's PhysicsState changes + // post-spawn — chiefly doors flipping ETHEREAL on Use. + // Holtburger validated wire format = 16 bytes (opcode + + // guid + state + 2×u16 sequence). One-shot probe-gated + // hex-dump (ACDREAM_PROBE_BUILDING) captures the wire + // bytes for confidence before declaring slice 1 done. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled + && !_setStateHexDumped) + { + _setStateHexDumped = true; + var hex = string.Join(" ", body.Take(Math.Min(body.Length, 32)) + .Select(b => b.ToString("X2"))); + Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}"); + } + + var parsed = SetState.TryParse(body); + if (parsed is not null) + StateUpdated?.Invoke(parsed.Value); + } else if (op == HearSpeech.LocalOpcode || op == HearSpeech.RangedOpcode) { // Phase H.1: local/ranged chat. Standalone GameMessage diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 6b4ea11..fd3673e 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -126,6 +126,47 @@ public sealed class ShadowObjectRegistry t.State, t.Flags); } + /// + /// Update the cached bits for an + /// already-registered entity. Called by the inbound + /// SetState (0xF74B) dispatcher when the server broadcasts a + /// post-spawn PhysicsState change — chiefly doors flipping + /// ETHEREAL_PS = 0x4 on Use, so the + /// short-circuit can honor + /// the new state on the next resolve. + /// + /// + /// Retail equivalent: CPhysicsObj::set_state at + /// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044 + /// — direct write `this->state = arg2`. Retail also fires side-effect + /// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden) + /// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1 + /// scopes to the bare state-write. + /// + /// + /// + /// Implementation: is a value-type record + /// copied into per-cell lists, so we rewrite the copy in each cell the + /// entity occupies. Unregistered entities are a no-op (callers don't + /// have to gate). + /// + /// + public void UpdatePhysicsState(uint entityId, uint newState) + { + if (!_entityToCells.TryGetValue(entityId, out var cellIds)) + return; // not registered — no-op + + foreach (var cellId in cellIds) + { + if (!_cells.TryGetValue(cellId, out var list)) continue; + for (int i = 0; i < list.Count; i++) + { + if (list[i].EntityId == entityId) + list[i] = list[i] with { State = newState }; + } + } + } + /// Remove an entity from all cells it was registered in. public void Deregister(uint entityId) { diff --git a/tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs new file mode 100644 index 0000000..837ccac --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class SetStateTests +{ + [Fact] + public void TryParse_WellFormedBody_ReturnsParsed() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Bu); + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4, 4), 0x000F4244u); // door guid + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8, 4), 0x00000004u); // ETHEREAL bit + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(12, 2), (ushort)355); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(14, 2), (ushort)42); + + var parsed = SetState.TryParse(buf); + + Assert.NotNull(parsed); + Assert.Equal(0x000F4244u, parsed.Value.Guid); + Assert.Equal(0x00000004u, parsed.Value.PhysicsState); + Assert.Equal((ushort)355, parsed.Value.InstanceSequence); + Assert.Equal((ushort)42, parsed.Value.StateSequence); + } + + [Fact] + public void TryParse_Truncated_ReturnsNull() + { + var buf = new byte[10]; + Assert.Null(SetState.TryParse(buf)); + } + + [Fact] + public void TryParse_WrongOpcode_ReturnsNull() + { + var buf = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Cu); + Assert.Null(SetState.TryParse(buf)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 73143d8..f3d7b08 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Numerics; using AcDream.Core.Physics; using Xunit; @@ -252,4 +253,52 @@ public class ShadowObjectRegistryTests Assert.Equal(0u, entry.State); Assert.Equal(EntityCollisionFlags.None, entry.Flags); } + + // ----------------------------------------------------------------------- + // UpdatePhysicsState — L.2g slice 1 (doors flip ETHEREAL post-spawn) + // ----------------------------------------------------------------------- + + [Fact] + public void UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits() + { + var reg = new ShadowObjectRegistry(); + const uint doorId = 0x000F4244u; + reg.Register(doorId, 0x020019FFu, new Vector3(12f, 12f, 50f), + Quaternion.Identity, 1f, OffX, OffY, LbId, + state: 0u, flags: EntityCollisionFlags.None); + + var before = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId); + Assert.Equal(0u, before.State); + + reg.UpdatePhysicsState(doorId, 0x00000004u); + + var after = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId); + Assert.Equal(0x00000004u, after.State); + } + + [Fact] + public void UpdatePhysicsState_UnregisteredEntity_IsNoOp() + { + var reg = new ShadowObjectRegistry(); + reg.UpdatePhysicsState(0xDEADBEEFu, 0x00000004u); + Assert.Equal(0, reg.TotalRegistered); + } + + [Fact] + public void UpdatePhysicsState_EntitySpanningMultipleCells_AllCellsUpdated() + { + var reg = new ShadowObjectRegistry(); + reg.Register(99u, 0x01000099u, new Vector3(24f, 12f, 50f), + Quaternion.Identity, 2f, OffX, OffY, LbId, + state: 0u); + + reg.UpdatePhysicsState(99u, 0x00000004u); + + uint cellA = LbId | 1u; + uint cellB = LbId | (1u*8 + 0 + 1); + var inA = reg.GetObjectsInCell(cellA).Single(e => e.EntityId == 99u); + var inB = reg.GetObjectsInCell(cellB).Single(e => e.EntityId == 99u); + Assert.Equal(0x00000004u, inA.State); + Assert.Equal(0x00000004u, inB.State); + } }