# 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."