acdream/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md
Erik 2c10dd4d67 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>
2026-05-12 21:00:36 +02:00

12 KiB
Raw Blame History

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 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) 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. 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).


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 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 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)


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