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