Merge branch 'claude/gallant-mestorf-3bf2e3' — Phase L.2g slice 1 + B.4 gap → Phase B.4b

L.2g slice 1 (dynamic PhysicsState toggling for doors) CODE-COMPLETE:

* 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 + hex probe
* 108e386 feat(phys L.2g slice 1): GameWindow routes SetState + [entity-source] log
* 2c10dd4 docs(phys L.2g): design spec
* 869677b docs(phys L.2g): implementation plan
* aba6c9a docs(phys L.2g): slice 1 shipped handoff + B.4 gap discovery

End-to-end inbound: server SetState (0xF74B) -> WorldSession dispatcher
-> StateUpdated event -> GameWindow handler -> ShadowObjectRegistry
mutator -> existing CollisionExemption.ShouldSkip honors ETHEREAL.
Build green, 6 new tests pass, baseline-stable. Per-commit + final
integration code reviews all approved.

Visual verification at Holtburg deferred: while running the visual
test, Phase B.4's outbound Use handler turned out to be unwired (wire
builders, classes, enums, keybindings all exist; GameWindow.OnInputAction
has no case for SelectDblLeft). Filed as ISSUE #57, promoted to Phase
B.4b. Memory file project_interaction_pipeline.md corrected.

Next session: Phase B.4b (~30-50 LOC, 1-2 subagent dispatches, ~30 min).
Subscribe SelectDblLeft -> WorldPicker.Pick -> InteractRequests.BuildUse
-> SendGameMessage. Same Holtburg-doorway visual test verifies both
L.2g slice 1 and B.4b in one pass.

Spec: docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md
Plan: docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Handoff: docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
This commit is contained in:
Erik 2026-05-13 17:13:24 +02:00
commit eea9b4d99a
13 changed files with 1882 additions and 20 deletions

View file

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

View file

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

View file

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

View file

@ -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 af)** — 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 ag)** — 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)

View file

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

View file

@ -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;
/// <summary>
/// Inbound <c>SetState</c> GameMessage (opcode <c>0xF74B</c>). The server
/// broadcasts this whenever a previously-spawned entity's
/// <c>PhysicsState</c> bitmask changes after <c>CreateObject</c> — chiefly
/// when a door opens / closes (server flips <c>ETHEREAL_PS = 0x4</c>) or a
/// spell projectile becomes ethereal post-impact.
///
/// <para>
/// Wire layout (per
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122</c>,
/// matched by every other acdream parser):
/// </para>
/// <list type="bullet">
/// <item><b>u32 opcode</b> — 0xF74B</item>
/// <item><b>u32 objectGuid</b></item>
/// <item><b>u32 physicsState</b> — bitmask (acclient.h:2815 / 2819)</item>
/// <item><b>u16 instanceSequence</b> — stale-packet rejection</item>
/// <item><b>u16 stateSequence</b> — stale-packet rejection</item>
/// </list>
///
/// <para>
/// Total body size: 16 bytes from start (opcode + 12-byte payload).
/// </para>
///
/// <para>
/// Server-side reference:
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15</c>
/// (ACE writes the same field order but appears to use <c>uint</c> 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.
/// </para>
/// </summary>
public static class SetState
{
public const uint Opcode = 0xF74Bu;
public readonly record struct Parsed(
uint Guid,
uint PhysicsState,
ushort InstanceSequence,
ushort StateSequence);
/// <summary>
/// Parse a 0xF74B body. <paramref name="body"/> must start with the
/// 4-byte opcode (matches the convention used by VectorUpdate /
/// UpdateMotion / UpdatePosition). Returns null on truncation or
/// opcode mismatch.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> 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) <noreply@anthropic.com>"
```
---
## 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<ShadowEntry>`. 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
/// <summary>
/// Update the cached <see cref="ShadowEntry.State"/> bits for an
/// already-registered entity. Called by the inbound
/// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
/// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use, so the
/// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
/// the new state on the next resolve.
///
/// <para>
/// Retail equivalent: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// — 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.
/// </para>
///
/// <para>
/// Implementation: <see cref="ShadowEntry"/> 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).
/// </para>
/// </summary>
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) <noreply@anthropic.com>"
```
---
## 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:739752). 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
/// <summary>
/// Fires when the server broadcasts a <c>SetState (0xF74B)</c> game
/// message — a previously-spawned entity's <c>PhysicsState</c>
/// bitmask changed post-CreateObject. Chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use (see ACE
/// <c>WorldObjects/Door.cs:127</c>, <c>WorldObject.cs:640-660</c>).
/// Subscribers route the new state into
/// <see cref="ShadowObjectRegistry.UpdatePhysicsState"/> so the
/// existing collision-exemption short-circuit honors the flip on the
/// next resolver tick.
/// </summary>
public event Action<SetState.Parsed>? 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) <noreply@anthropic.com>"
```
---
## 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
/// <summary>
/// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
/// new <c>PhysicsState</c> bits into ShadowObjectRegistry so the
/// existing <see cref="CollisionExemption.ShouldSkip"/> check honors
/// the flip on the next resolver tick. Chiefly doors:
/// server flips <c>ETHEREAL_PS = 0x4</c> on Use, the door's
/// cylinder collision stops blocking the threshold.
/// </summary>
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) <noreply@anthropic.com>"
```
---
## 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
/// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
/// emits the first SetState's body bytes only, not 510/sec.</summary>
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) <noreply@anthropic.com>"
```
---
## 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 510 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) <noreply@anthropic.com>"
```
---
## 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 <date>" 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 16).
- 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 <date> — doors honor
ETHEREAL flips end-to-end; visual-verified at Holtburg inn doorway." Add
a "**Phase L.2g slice 1 shipped <date>.**" 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 (<date>):" 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) <noreply@anthropic.com>"
```
---
## 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." `<date>` 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 ~3060 minutes of work + Task 7 visual test (~10 minutes when ACE + retail client are already running).

View file

@ -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 195197).
**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 af)``L.2 (all sub-lanes ag)`.
- 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."

View file

@ -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
}
}
/// <summary>
/// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
/// new <c>PhysicsState</c> bits into ShadowObjectRegistry so the
/// existing <see cref="CollisionExemption.ShouldSkip"/> check honors
/// the flip on the next resolver tick. Chiefly doors:
/// server flips <c>ETHEREAL_PS = 0x4</c> on Use, the door's
/// cylinder collision stops blocking the threshold.
/// </summary>
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++;
}

View file

@ -0,0 +1,86 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>SetState</c> GameMessage (opcode <c>0xF74B</c>). The server
/// broadcasts this whenever a previously-spawned entity's
/// <c>PhysicsState</c> bitmask changes after <c>CreateObject</c> — chiefly
/// when a door opens / closes (server flips <c>ETHEREAL_PS = 0x4</c>) or a
/// spell projectile becomes ethereal post-impact.
///
/// <para>
/// Wire layout (per
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122</c>,
/// matched by every other acdream parser):
/// </para>
/// <list type="bullet">
/// <item><b>u32 opcode</b> — 0xF74B</item>
/// <item><b>u32 objectGuid</b></item>
/// <item><b>u32 physicsState</b> — bitmask (acclient.h:2815 / 2819)</item>
/// <item><b>u16 instanceSequence</b> — stale-packet rejection</item>
/// <item><b>u16 stateSequence</b> — stale-packet rejection</item>
/// </list>
///
/// <para>
/// Total body size: 16 bytes (4-byte opcode + 12-byte payload).
/// </para>
///
/// <para>
/// Server-side reference:
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15</c>
/// — ACE writes the same field order using its <c>UShortSequence.CurrentBytes</c>
/// helper (which calls <c>BitConverter.GetBytes((ushort)value)</c> = 2 bytes per
/// sequence field), so its wire output matches holtburger's 12-byte payload.
/// A one-shot <c>ACDREAM_PROBE_BUILDING</c> hex-dump in
/// <see cref="WorldSession"/>'s dispatcher (added in the same commit) emits
/// the first SetState body bytes for runtime confirmation.
/// </para>
///
/// <para>
/// Named-retail anchor: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// describes the runtime state-store on the in-memory object
/// (<c>this->state = arg2</c>). The wire format for this opcode is
/// confirmed by holtburger's <c>SetStateData</c> struct; the named-retail
/// decomp does not cover the deserialization path for this opcode.
/// </para>
/// </summary>
public static class SetState
{
public const uint Opcode = 0xF74Bu;
public readonly record struct Parsed(
uint Guid,
uint PhysicsState,
ushort InstanceSequence,
ushort StateSequence);
/// <summary>
/// Parse a 0xF74B body. <paramref name="body"/> must start with the
/// 4-byte opcode (matches the convention used by VectorUpdate /
/// UpdateMotion / UpdatePosition). Returns null on truncation or
/// opcode mismatch.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> 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;
}
}
}

View file

@ -129,6 +129,19 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<VectorUpdate.Parsed>? VectorUpdated;
/// <summary>
/// Fires when the server broadcasts a <c>SetState (0xF74B)</c> game
/// message — a previously-spawned entity's <c>PhysicsState</c>
/// bitmask changed post-CreateObject. Chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use (see ACE
/// <c>WorldObjects/Door.cs:127</c>, <c>WorldObject.cs:640-660</c>).
/// Subscribers route the new state into
/// <see cref="ShadowObjectRegistry.UpdatePhysicsState"/> so the
/// existing collision-exemption short-circuit honors the flip on the
/// next resolver tick.
/// </summary>
public event Action<SetState.Parsed>? StateUpdated;
/// <summary>
/// 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
/// </summary>
private bool _loginCompleteSent;
/// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
/// emits the first SetState's body bytes only, not 510/sec.</summary>
private bool _setStateHexDumped;
/// <summary>
/// Phase B.2: per-session game-action sequence counter. Monotonically
/// incremented by <see cref="NextGameActionSequence"/> 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

View file

@ -126,6 +126,47 @@ public sealed class ShadowObjectRegistry
t.State, t.Flags);
}
/// <summary>
/// Update the cached <see cref="ShadowEntry.State"/> bits for an
/// already-registered entity. Called by the inbound
/// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
/// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use, so the
/// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
/// the new state on the next resolve.
///
/// <para>
/// Retail equivalent: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// — 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.
/// </para>
///
/// <para>
/// Implementation: <see cref="ShadowEntry"/> 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).
/// </para>
/// </summary>
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 };
}
}
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
{

View file

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

View file

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