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: *2459f28feat(phys L.2g slice 1): inbound SetState (0xF74B) parser *d538915feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState *536a608feat(phys L.2g slice 1): WorldSession dispatches SetState + hex probe *108e386feat(phys L.2g slice 1): GameWindow routes SetState + [entity-source] log *2c10dd4docs(phys L.2g): design spec *869677bdocs(phys L.2g): implementation plan *aba6c9adocs(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:
commit
eea9b4d99a
13 changed files with 1882 additions and 20 deletions
51
CLAUDE.md
51
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
241
docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
Normal file
241
docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
Normal 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.
|
||||
899
docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Normal file
899
docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Normal 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: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
|
||||
/// <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 5–10/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 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) <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 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 <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 ~30–60 minutes of work + Task 7 visual test (~10 minutes when ACE + retail client are already running).
|
||||
|
|
@ -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."
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
86
src/AcDream.Core.Net/Messages/SetState.cs
Normal file
86
src/AcDream.Core.Net/Messages/SetState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 5–10/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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
43
tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs
Normal file
43
tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue