diff --git a/CLAUDE.md b/CLAUDE.md index f1b8e6e..8e146de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -618,10 +618,16 @@ acdream's plan lives in two files committed to the repo: approval. **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 -L.2d slice 1 brainstorm / design spec. Cold-start prompt for the next -session: [`docs/research/2026-05-12-l2d-next-session-prompt.md`](docs/research/2026-05-12-l2d-next-session-prompt.md). -Full handoff: [`docs/research/2026-05-12-l2a-shipped-l2d-handoff.md`](docs/research/2026-05-12-l2a-shipped-l2d-handoff.md). +1+2+3 + L.2d slice 1+1.5 shipped 2026-05-12. L.2d closed at the Holtburg +site ("watch-and-wait" — no more slices until a new shape-fidelity bug +shows up elsewhere); doorway blocker identified as a Door entity, not +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.** 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. **Next phase candidates (in rough preference order):** -- **L.2d slice 1 brainstorm + spec** (`docs/research/2026-05-12-l2d-next-session-prompt.md`). - Direct continuation of tonight's L.2a evidence: port `CBuildingObj` collision - + per-cell walkability so doorway gaps are walkable. Unblocks "walk into a - building" + sets up G.3 dungeon streaming. **Note:** triage the 8 pre-existing - test failures first (none introduced by L.2a slices — verified by stash + rerun - — but most touch movement/physics code L.2d will evolve). See the handoff doc's - "Open concerns" section. +- **L.2g slice 1 implementation — dynamic PhysicsState toggling for doors.** + Direct continuation of tonight's L.2d slice 1.5 evidence: parse inbound + `SetState (0xF74B)` wire message, plumb the new `PhysicsState` value into + `ShadowObjectRegistry`'s cached entity state so the existing + `CollisionExemption.IsExempt(...)` check sees up-to-date bits, and verify + the Holtburg inn-door scenario walks through cleanly when the server flips + 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), #4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree), #41 (remote-motion blips) have been open since diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index 7db0325..a4e6f29 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -228,6 +228,44 @@ client sees when observing acdream. - Require conformance notes in tests or research docs for every AC-specific 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 Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 97816be..4a1527f 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -101,9 +101,13 @@ doorway. Open the inn door. Click an NPC and see selection feedback. Pick up an item from the ground. **Phases to ship:** -- **L.2 (all sub-lanes a–f)** — Movement & Collision Conformance. Currently - active; L.2a slices 1+2+3 shipped 2026-05-12. -- **B.4** — `Use` / `UseWithTarget` / `PickUp` outbound messages. +- **L.2 (all sub-lanes a–g)** — Movement & Collision Conformance. Currently + active; L.2a slices 1+2+3 + L.2d slice 1+1.5 shipped 2026-05-12. L.2g + (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:** - L.2 zone (collision, cell ownership, transition parity, wire authority) diff --git a/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md b/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md new file mode 100644 index 0000000..b7c1e7d --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md @@ -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 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."