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>
12 KiB
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 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.
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:
- 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: EnqueueBroadcastMotion(motionOpen)— broadcasts anUpdateMotionto all clients in range, motion =(NonCombat, On). This is the door's animation command.Ethereal = true; EnqueueBroadcastPhysicsState()— broadcasts aGameMessageSetState (0xF74B). The newPhysicsStatevalue has bit0x4(Ethereal) set.- Sets
IsBusy = truefor the duration of the open animation. - Schedules
FinalizeCloseafterResetInterval(default 30s).
Server → client when the auto-close fires
EnqueueBroadcastMotion(motionClosed)—UpdateMotion (NonCombat, Off).- After the close animation completes, server runs
FinalizeClose:Ethereal = false; EnqueueBroadcastPhysicsState()— another0xF74B SetStatewith 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 asETHEREAL_PS=0x4inCollisionExemption.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/— newSetStateMessageparser for opcode0xF74B. 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) — route0xF74Bto the new parser, then forward the(guid, newState)pair to the entity layer.src/AcDream.Core/Physics/ShadowObjectRegistry.cs— newUpdatePhysicsState(guid, newState)method that mutates the cached state bits on the matching shadow entry. The existingCollisionExemptioncheck reads from this cached state, so no resolver changes needed.- Tests — synthetic test in
tests/AcDream.Core.Tests/that constructs a ShadowEntry withEthereal=false, callsUpdatePhysicsStateflipping 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 testgreen.[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
GameMessageSoundforSound.OpenFailDueToLock; verify acdream's audio pipeline plays it via the existing 0xF755 handler). - Bump-AI for creatures (ACE's
Door.OnCollideObjectauto-opens for creatures withAiOptions != 0). This is server-driven; client gets the sameSetStateflow. Probably no-op for the client.
Open questions to resolve in implementation
- Wire-byte width of
0xF74Bsequence fields. Default to holtburger (u16+u16 = 12 bytes total). Confirm via hex-dump in slice- If wrong, widen to ACE's claimed format (u32+u32 = 16 bytes).
- Does
UpdateMotion's existing handler dispatch motion to non- creature entities? Verified in slice 2. If no, one-line fix. - Does ACE's
EnqueueBroadcastPhysicsStateskip 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.mdwhen slice 1+2 land.
Named retail anchors (for slice 1 code citations)
CPhysicsObj::set_state— the retail client's setter. Search byset_state\(in docs/research/named-retail/acclient_2013_pseudo_c.txt.CPhysicsObj::report_collision_with_object— the retail per-object collision-test entry; callsCollisionExemption.IsExempt-equivalent inline.- Header struct:
CPhysicsObjin docs/research/named-retail/acclient.h —statefield is thePhysicsStatebitmask.
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."