docs(phys L.2g): design spec for dynamic PhysicsState toggling (doors)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-12 21:00:36 +02:00
parent 9206d1d4e0
commit 2c10dd4d67
4 changed files with 353 additions and 14 deletions

View file

@ -618,10 +618,16 @@ acdream's plan lives in two files committed to the repo:
approval. approval.
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices **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 1+2+3 + L.2d slice 1+1.5 shipped 2026-05-12. L.2d closed at the Holtburg
L.2d slice 1 brainstorm / design spec. Cold-start prompt for the next site ("watch-and-wait" — no more slices until a new shape-fidelity bug
session: [`docs/research/2026-05-12-l2d-next-session-prompt.md`](docs/research/2026-05-12-l2d-next-session-prompt.md). shows up elsewhere); doorway blocker identified as a Door entity, not
Full handoff: [`docs/research/2026-05-12-l2a-shipped-l2d-handoff.md`](docs/research/2026-05-12-l2a-shipped-l2d-handoff.md). 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.** **Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.**
Three commits land the L.2 "make every bad movement outcome explainable" 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. project.
**Next phase candidates (in rough preference order):** **Next phase candidates (in rough preference order):**
- **L.2d slice 1 brainstorm + spec** (`docs/research/2026-05-12-l2d-next-session-prompt.md`). - **L.2g slice 1 implementation — dynamic PhysicsState toggling for doors.**
Direct continuation of tonight's L.2a evidence: port `CBuildingObj` collision Direct continuation of tonight's L.2d slice 1.5 evidence: parse inbound
+ per-cell walkability so doorway gaps are walkable. Unblocks "walk into a `SetState (0xF74B)` wire message, plumb the new `PhysicsState` value into
building" + sets up G.3 dungeon streaming. **Note:** triage the 8 pre-existing `ShadowObjectRegistry`'s cached entity state so the existing
test failures first (none introduced by L.2a slices — verified by stash + rerun `CollisionExemption.IsExempt(...)` check sees up-to-date bits, and verify
— but most touch movement/physics code L.2d will evolve). See the handoff doc's the Holtburg inn-door scenario walks through cleanly when the server flips
"Open concerns" section. 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), - **Triage the chronic open-issue list** in `docs/ISSUES.md`#2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid #4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #50 (stray tree), #41 (remote-motion blips) have been open since coat), #50 (stray tree), #41 (remote-motion blips) have been open since

View file

@ -228,6 +228,44 @@ client sees when observing acdream.
- Require conformance notes in tests or research docs for every AC-specific - Require conformance notes in tests or research docs for every AC-specific
algorithm ported under L.2. 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 ## Named Retail Anchors
Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.

View file

@ -101,9 +101,13 @@ doorway. Open the inn door. Click an NPC and see selection feedback. Pick
up an item from the ground. up an item from the ground.
**Phases to ship:** **Phases to ship:**
- **L.2 (all sub-lanes af)** — Movement & Collision Conformance. Currently - **L.2 (all sub-lanes ag)** — Movement & Collision Conformance. Currently
active; L.2a slices 1+2+3 shipped 2026-05-12. active; L.2a slices 1+2+3 + L.2d slice 1+1.5 shipped 2026-05-12. L.2g
- **B.4**`Use` / `UseWithTarget` / `PickUp` outbound messages. (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:** **Freeze on landing:**
- L.2 zone (collision, cell ownership, transition parity, wire authority) - L.2 zone (collision, cell ownership, transition parity, wire authority)

View file

@ -0,0 +1,286 @@
# Phase L.2g — Dynamic PhysicsState Toggling
**Status:** Design spec, created 2026-05-12 evening after L.2d slice 1+1.5
ship and brainstorm completion.
**Branch:** `claude/gallant-mestorf-3bf2e3`.
**Predecessor:** [docs/research/2026-05-13-l2d-slice1-shipped-handoff.md](../../research/2026-05-13-l2d-slice1-shipped-handoff.md)
identified the Holtburg-doorway blocker as a closed Door entity (Setup
`0x020019FF`), not a building-collision-mesh bug. L.2g is the
sub-phase that handles the door-state work the L.2d handoff deferred.
**Roadmap owner:** new L.2 sub-lane "dynamic state" — the L.2 plan-of-record
([docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md))
explicitly anticipates the L.2g letter (L.2d revised sub-direction
paragraph, lines 195197).
**Milestone:** M1 — Walkable + clickable world. Demo scenario *"open
the inn door"* depends on this slice landing.
---
## TL;DR
After the player Uses a door, ACE broadcasts two messages: an
`UpdateMotion` to play the swing-open animation, and a
`GameMessageSetState (opcode 0xF74B)` to flip the door entity's
`PhysicsState.Ethereal` bit. The client must honor the state flip so
the door's collision cylinder stops blocking the threshold while the
door is open. The auto-close (30s) is a second SetState round-trip;
client just follows.
acdream already parses `PhysicsState` from `CreateObject` and
already short-circuits ETHEREAL targets in
[CollisionExemption.cs](../../../src/AcDream.Core/Physics/CollisionExemption.cs).
**The single missing piece is parsing `0xF74B SetState` and
propagating the new state to `ShadowObjectRegistry`'s cached entity
record.** Everything else already works. Slice 1 is roughly one
commit.
---
## Why L.2g (and not B.4 or "doors only")
Three placement options were considered during the 2026-05-12
brainstorm:
| Option | Verdict |
|---|---|
| **Nest under B.4 interaction** | Rejected. B.4's scope is the *outbound* Use / UseWithTarget / PickUp packet (shipped 2026-04-28). Door state is an inbound + collision-state-toggle problem, not an outbound interaction one. |
| **Special-case Door Setup ID (`0x020019FF`)** | Rejected. Same wire mechanism (`SetState` flipping Ethereal) is also how ACE handles activated traps, opened chests, spell projectiles that become ethereal, and any other server-driven collision-state flip. Specializing on Door Setup ID would leave all those cases broken and re-emerge later as separate bugs. |
| **New L.2 sub-phase "L.2g — Dynamic PhysicsState toggling"** | **Selected.** L.2 already owns "movement & collision conformance"; a door you can't walk through after the server says it's open is a collision-conformance bug. Generic infrastructure (any entity, any state bit) with doors as the verification scenario. |
The L.2 plan-of-record's L.2d revised paragraph already names L.2g
as a possible letter for this work; we're claiming it.
**Lane assignment:** informal sixth lane "dynamic state." Updates the
lane table in the L.2 plan-of-record to include collision-state-toggle
as a first-class concern.
---
## Problem evidence
From the L.2d slice 1.5 trace (Holtburg, 2026-05-12):
```
live: spawn guid=0x7A9B4015 name="Door" setup=0x020019FF
pos=(132.6,17.1,94.1)@0xA9B40029 itemType=0x00000080
[entity-source] id=0x000F4244 entityId=0x000F4244 src=0x020019FF
gfxObj=0x020019FF lb=0xA9B40029 type=Cylinder note=server-spawn-root
```
Five Door entities across Holtburg town (cells `0xA9B40029`,
`0xA9B40154`, `0xA9B40155`); each blocks its building's threshold with a
Cylinder collision. The 121 wall hits the L.2a probe attributed to the
building BSP turned out to be the player **already pushed back by the
Door cylinder** then grazing the doorframe. Slice 1.5's per-tick probe
showed `nObj=3` on every doorway resolve: one Door + two sphere checks
against the building BSP.
The actual blocker is the closed Door, not the building. The blocker
goes away when the Door's PhysicsState gains the Ethereal bit (server
sets this in `Door.Open()`, see
[references/ACE/Source/ACE.Server/WorldObjects/Door.cs:127](../../../references/ACE/Source/ACE.Server/WorldObjects/Door.cs)).
---
## Wire flow
### Server → client when the player Uses a door
ACE's `Door.ActOnUse(player)` runs the following sequence:
1. Check `IsLocked` + behind-test (AC retail allows opening locked doors
from behind). If locked-and-not-behind: broadcast a "door is locked"
chat string + sound effect, no state change. Otherwise:
2. `EnqueueBroadcastMotion(motionOpen)` — broadcasts an
`UpdateMotion` to all clients in range, motion = `(NonCombat, On)`.
This is the door's animation command.
3. `Ethereal = true; EnqueueBroadcastPhysicsState()` — broadcasts a
`GameMessageSetState (0xF74B)`. The new `PhysicsState` value has bit
`0x4` (Ethereal) set.
4. Sets `IsBusy = true` for the duration of the open animation.
5. Schedules `FinalizeClose` after `ResetInterval` (default 30s).
### Server → client when the auto-close fires
1. `EnqueueBroadcastMotion(motionClosed)``UpdateMotion (NonCombat, Off)`.
2. After the close animation completes, server runs `FinalizeClose`:
`Ethereal = false; EnqueueBroadcastPhysicsState()` — another
`0xF74B SetState` with the Ethereal bit cleared.
### The wire format of `0xF74B SetState`
Two sources, **mildly disagreeing on sequence-field width:**
[GameMessageSetState.cs](../../../references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs)
in ACE writes:
```
guid : uint32 (4)
state : uint32 (4)
instance_sequence : uint32 (4) <-- ACE says u32
state_sequence : uint32 (4) <-- ACE says u32
```
[properties.rs](../../../references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs)
in holtburger parses:
```
guid : uint32 (4)
state : uint32 (4)
instance_sequence : uint16 (2) <-- holtburger says u16
state_sequence : uint16 (2) <-- holtburger says u16
```
Holtburger has been validated against a retail-format server in the
wild. ACE's `Writer.Write((uint)sequence)` may or may not be using a
packed-write extension that downsizes to u16 — needs verification. **The
slice 1 implementation will default to holtburger's 12-byte format and
add a startup hex-dump probe to confirm before the parser is
committed.** If the actual payload is 16 bytes, the parser can be
trivially widened.
### `PhysicsState.Ethereal`
Value `0x00000004` (bit 2). Confirmed in:
- ACE: `references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:10`
- acdream: `src/AcDream.Core/Physics/PhysicsBody.cs:30`
- Retail header: `docs/research/named-retail/acclient.h:2819` (cited
as `ETHEREAL_PS=0x4` in `CollisionExemption.cs:43`).
---
## Current acdream state
| Component | State |
|---|---|
| `PhysicsState` enum (Ethereal bit) | ✅ defined in `src/AcDream.Core/Physics/PhysicsBody.cs:30` |
| `CollisionExemption.IsExempt(...)` | ✅ already short-circuits when `(ETHEREAL_PS \| IGNORE_COLLISIONS_PS)` are both set on the target. Cites `acclient_2013_pseudo_c.txt:276782`. |
| `CreateObject` parses `PhysicsState` into the entity's shadow record | ✅ since 2026-04-29 |
| `UpdateMotion` pipeline for remote entities | ✅ works for player remotes; need to confirm it accepts non-creature entities with `(NonCombat, On/Off)` |
| `SetState (0xF74B)` inbound parser | ❌ does not exist |
| Propagating a post-spawn PhysicsState change into `ShadowObjectRegistry`'s cached state | ❌ does not exist |
| `[entity-source]` probe log captures `state` bits | ❌ — handoff's "slice 1.6" suggestion |
---
## Slice plan
### Slice 0.5 (optional prereq, fold into slice 1 if convenient)
Add `PhysicsState` + `EntityCollisionFlags` to the `[entity-source]`
probe log line. Makes ETHEREAL flips observable from launch-log grep.
~5 LOC under the existing `ACDREAM_PROBE_BUILDING` flag.
### Slice 1 — MVP (the M1-blocker slice)
Goal: walk into the Holtburg inn doorway, click Use on the door, walk
through.
Touchpoints:
- `src/AcDream.Core.Net/` — new `SetStateMessage` parser for opcode
`0xF74B`. Default to holtburger's 12-byte format; add a hex-dump
emit on first message receipt to confirm exact byte width before the
parser commits.
- `src/AcDream.Core.Net/WorldSession.cs` (or wherever inbound game-
message dispatch lives) — route `0xF74B` to the new parser, then
forward the `(guid, newState)` pair to the entity layer.
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — new
`UpdatePhysicsState(guid, newState)` method that mutates the
cached state bits on the matching shadow entry. The existing
`CollisionExemption` check reads from this cached state, so no
resolver changes needed.
- Tests — synthetic test in
`tests/AcDream.Core.Tests/` that constructs a ShadowEntry with
`Ethereal=false`, calls `UpdatePhysicsState` flipping it on, and
asserts the next collision query returns "exempt."
- Visual verification — Holtburg inn doorway. Walk in, observe blocked.
Click Use. Observe door swings open AND player can now walk through.
Wait 30 seconds. Observe door closes AND player is blocked again.
Acceptance:
- `dotnet build` + `dotnet test` green.
- `[resolve]` probe shows the Door cylinder no longer firing when the
door is open.
- Visual verification at Holtburg passes.
- Wire-byte width settled by hex-dump evidence; parser uses correct width.
### Slice 2 — animation confirmation
Goal: the door visually swings open and shut, not just becomes
walk-through.
Most likely a no-op: the existing `UpdateMotion` pipeline that runs
`(NonCombat, On/Off)` commands for player remotes should drive any
entity with a MotionTable. Doors have a MotionTable (the same Setup
`0x020019FF`). Slice 2 is **verify, then either declare done or fix
whatever's missing**.
If a fix is needed, the most likely cause is the motion handler
gating on `entity is Creature` somewhere upstream — a one-line removal
or a stance-relaxation in `MotionInterpreter`.
### Deferred — UX polish
These open only if observation demands them:
- Sound on "door is locked" (ACE sends a `GameMessageSound` for
`Sound.OpenFailDueToLock`; verify acdream's audio pipeline plays it
via the existing 0xF755 handler).
- Bump-AI for creatures (ACE's `Door.OnCollideObject` auto-opens for
creatures with `AiOptions != 0`). This is server-driven; client gets
the same `SetState` flow. Probably no-op for the client.
---
## Open questions to resolve in implementation
1. **Wire-byte width of `0xF74B` sequence fields.** Default to
holtburger (u16+u16 = 12 bytes total). Confirm via hex-dump in slice
1. If wrong, widen to ACE's claimed format (u32+u32 = 16 bytes).
2. **Does `UpdateMotion`'s existing handler dispatch motion to non-
creature entities?** Verified in slice 2. If no, one-line fix.
3. **Does ACE's `EnqueueBroadcastPhysicsState` skip the player who
triggered the Use, or include them?** Reading ACE's code, `EnqueueBroadcast(...)`
broadcasts to *everyone in range including self*. Slice 1 verifies the
player's own client receives the SetState (not just observers).
---
## Acceptance for L.2g overall
- All slices marked above as "Acceptance" pass.
- L.2 plan-of-record updated with an L.2g section (matching this spec's framing).
- M1 milestone doc updated: `L.2 (all sub-lanes af)``L.2 (all sub-lanes ag)`.
- CLAUDE.md's "currently in Phase L.2" paragraph updated to point at L.2g as the active sub-phase.
- A short ship handoff doc filed at
`docs/research/2026-05-XX-l2g-shipped-handoff.md` when slice 1+2 land.
---
## Named retail anchors (for slice 1 code citations)
- `CPhysicsObj::set_state` — the retail client's setter. Search by
`set_state\(` in
[docs/research/named-retail/acclient_2013_pseudo_c.txt](../../research/named-retail/acclient_2013_pseudo_c.txt).
- `CPhysicsObj::report_collision_with_object` — the retail per-object
collision-test entry; calls `CollisionExemption.IsExempt`-equivalent
inline.
- Header struct: `CPhysicsObj` in
[docs/research/named-retail/acclient.h](../../research/named-retail/acclient.h)
`state` field is the `PhysicsState` bitmask.
---
## Risk + rollback
Risk is low. Wire-byte width has a fallback path (widen if hex-dump
shows 16 bytes). ETHEREAL plumbing already exists; we're feeding it
fresh data from one new source. No resolver changes. If slice 1 lands
broken, rollback is a single revert of one commit.
The slice does **not** touch the broader L.2 collision path. It does
not change `ResolveWithTransition`, BSPQuery, ShadowObjectRegistry
broadphase, or any movement-prediction code. The change-surface is
strictly "one new wire message + one new mutator on cached state."