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