acdream/docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Erik 869677bc88 docs(phys L.2g): slice 1 implementation plan
Step-by-step TDD plan for L.2g slice 1 — dynamic PhysicsState toggling.
Six commits' worth of work + a user-driven visual test at the Holtburg
inn doorway + a ship handoff commit.

Task 1: SetState (0xF74B) wire parser + xUnit tests (TDD).
Task 2: ShadowObjectRegistry.UpdatePhysicsState mutator + tests (TDD).
Task 3: WorldSession dispatcher branch + StateUpdated event.
Task 4: GameWindow subscribes, routes to registry, smoke-tests launch.
Task 5: One-shot hex-dump probe to settle holtburger 12-byte vs ACE
        16-byte payload claim before declaring slice 1 done.
Task 6: Slice 0.5 freebie — extend [entity-source] log with state +
        flags (the handoff's 'slice 1.6' suggestion).
Task 7: User-driven visual verification at Holtburg inn doorway.
Task 8: Ship handoff + CLAUDE.md / plan-of-record updates.

Risk surface: all changes are additive. No resolver edits. No broadphase
edits. No retail-port semantics changes. Per-task revert is safe.

Plan: docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Spec: docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:52:31 +02:00

38 KiB
Raw Blame History

Phase L.2g slice 1 — Dynamic PhysicsState Toggling Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Spec: docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md (committed in 2c10dd4). Branch: claude/gallant-mestorf-3bf2e3 (do all commits here; user merges to main separately).

Goal: Parse inbound GameMessageSetState (opcode 0xF74B) and propagate the new PhysicsState value into ShadowObjectRegistry's cached per-entity record so the existing CollisionExemption.IsExempt(...) short-circuit honors runtime ETHEREAL flips — unblocking the M1 demo's "open the inn door" line.

Architecture: One new wire-message parser (SetState), one new event on WorldSession, one new mutator method on ShadowObjectRegistry (UpdatePhysicsState), one new subscriber in GameWindow. No resolver changes. The existing CollisionExemption.cs short-circuit (cited at acclient_2013_pseudo_c.txt:276782) already handles ETHEREAL; slice 1 just feeds it fresh data.

Tech Stack: C# .NET 10, xUnit for tests, BinaryPrimitives for little-endian reads. Mirror the existing VectorUpdate.cs parser pattern.

Retail anchor (port reference): CPhysicsObj::set_state at docs/research/named-retail/acclient_2013_pseudo_c.txt:283044. The retail implementation: this->state = arg2 (line 283048) plus three side-effect handlers for the changed-bit set (0x800 lighting, 0x20 nodraw, 0x4000 hidden). Slice 1 ports only the state-store half; ETHEREAL (0x4) is not in the side-effect set, so the cosmetic handlers are not on the M1-critical path and stay deferred.


File Structure

File Action Responsibility
src/AcDream.Core.Net/Messages/SetState.cs Create New inbound DTO + TryParse for opcode 0xF74B. Mirrors VectorUpdate.cs.
src/AcDream.Core.Net/WorldSession.cs Modify Add StateUpdated event + dispatch branch for op == SetState.Opcode.
src/AcDream.Core/Physics/ShadowObjectRegistry.cs Modify Add UpdatePhysicsState(uint, uint) method that mutates the cached ShadowEntry.State in every cell the entity occupies.
src/AcDream.App/Rendering/GameWindow.cs Modify Subscribe to _liveSession.StateUpdated, route (guid, newState) to _physicsEngine.ShadowObjects.UpdatePhysicsState(...). Extend [entity-source] log with state= + flags= (slice 0.5 freebie).
tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs Create TryParse byte-level tests (well-formed, truncated, opcode-mismatch).
tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs Modify Add UpdatePhysicsState_FlipsEthereal_NextLookupExempt test using CollisionExemption.ShouldSkip.

No new project references needed — all files live in existing assemblies.


Task 1: Parser DTO + TryParse for SetState (opcode 0xF74B)

Files:

  • Create: src/AcDream.Core.Net/Messages/SetState.cs
  • Create: tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs

Reference template: src/AcDream.Core.Net/Messages/VectorUpdate.cs (read it before writing — same opcode dispatch convention, same body-length check shape, same BinaryPrimitives style).

Wire format (per references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122, matched by every other acdream parser):

offset 0  : u32 opcode  (= 0xF74B)
offset 4  : u32 guid
offset 8  : u32 physics_state   (bitmask; ETHEREAL = 0x4)
offset 12 : u16 instance_sequence
offset 14 : u16 state_sequence
Total: 16 bytes from start of body.
  • Step 1.1: Write the failing TryParse tests

Create tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs with:

using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;

namespace AcDream.Core.Net.Tests.Messages;

public class SetStateTests
{
    [Fact]
    public void TryParse_WellFormedBody_ReturnsParsed()
    {
        // Build a synthetic SetState body: opcode + guid + state + 2×u16 seq.
        var buf = new byte[16];
        BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Bu);
        BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4, 4), 0x000F4244u); // door guid
        BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8, 4), 0x00000004u); // ETHEREAL bit
        BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(12, 2), (ushort)355);
        BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(14, 2), (ushort)42);

        var parsed = SetState.TryParse(buf);

        Assert.NotNull(parsed);
        Assert.Equal(0x000F4244u, parsed.Value.Guid);
        Assert.Equal(0x00000004u, parsed.Value.PhysicsState);
        Assert.Equal((ushort)355, parsed.Value.InstanceSequence);
        Assert.Equal((ushort)42, parsed.Value.StateSequence);
    }

    [Fact]
    public void TryParse_Truncated_ReturnsNull()
    {
        var buf = new byte[10]; // < 16 bytes
        Assert.Null(SetState.TryParse(buf));
    }

    [Fact]
    public void TryParse_WrongOpcode_ReturnsNull()
    {
        var buf = new byte[16];
        BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Cu); // UpdateMotion, not SetState
        Assert.Null(SetState.TryParse(buf));
    }
}
  • Step 1.2: Run tests to verify they fail (RED)

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStateTests" Expected: Compile error — SetState type not defined.

  • Step 1.3: Write the parser

Create src/AcDream.Core.Net/Messages/SetState.cs:

using System;
using System.Buffers.Binary;

namespace AcDream.Core.Net.Messages;

/// <summary>
/// Inbound <c>SetState</c> GameMessage (opcode <c>0xF74B</c>). The server
/// broadcasts this whenever a previously-spawned entity's
/// <c>PhysicsState</c> bitmask changes after <c>CreateObject</c> — chiefly
/// when a door opens / closes (server flips <c>ETHEREAL_PS = 0x4</c>) or a
/// spell projectile becomes ethereal post-impact.
///
/// <para>
/// Wire layout (per
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122</c>,
/// matched by every other acdream parser):
/// </para>
/// <list type="bullet">
///   <item><b>u32 opcode</b> — 0xF74B</item>
///   <item><b>u32 objectGuid</b></item>
///   <item><b>u32 physicsState</b> — bitmask (acclient.h:2815 / 2819)</item>
///   <item><b>u16 instanceSequence</b> — stale-packet rejection</item>
///   <item><b>u16 stateSequence</b> — stale-packet rejection</item>
/// </list>
///
/// <para>
/// Total body size: 16 bytes from start (opcode + 12-byte payload).
/// </para>
///
/// <para>
/// Server-side reference:
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15</c>
/// (ACE writes the same field order but appears to use <c>uint</c> for the
/// sequence fields; verified against retail format by hex-dump probe in
/// Task 5). Holtburger has been validated against a retail-format server,
/// so its 12-byte payload is the trusted spec.
/// </para>
/// </summary>
public static class SetState
{
    public const uint Opcode = 0xF74Bu;

    public readonly record struct Parsed(
        uint Guid,
        uint PhysicsState,
        ushort InstanceSequence,
        ushort StateSequence);

    /// <summary>
    /// Parse a 0xF74B body. <paramref name="body"/> must start with the
    /// 4-byte opcode (matches the convention used by VectorUpdate /
    /// UpdateMotion / UpdatePosition). Returns null on truncation or
    /// opcode mismatch.
    /// </summary>
    public static Parsed? TryParse(ReadOnlySpan<byte> body)
    {
        if (body.Length < 16) return null;
        try
        {
            uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
            if (opcode != Opcode) return null;

            uint guid       = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
            uint state      = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4));
            ushort instSeq  = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(12, 2));
            ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(14, 2));

            return new Parsed(guid, state, instSeq, stateSeq);
        }
        catch
        {
            return null;
        }
    }
}
  • Step 1.4: Run tests to verify they pass (GREEN)

Run: dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStateTests" Expected: 3 passed.

  • Step 1.5: Verify project build still green

Run: dotnet build Expected: Build succeeded, 0 errors, 0 new warnings.

  • Step 1.6: Commit
git add src/AcDream.Core.Net/Messages/SetState.cs tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs
git commit -m "feat(phys L.2g slice 1): inbound SetState (0xF74B) parser

DTO + TryParse for the GameMessageSetState wire message. The server
broadcasts this when an already-spawned entity's PhysicsState changes
post-CreateObject — chiefly when a door's Ethereal bit toggles on Use.

Wire format per holtburger SetStateData (validated against retail-format
servers): u32 opcode + u32 guid + u32 state + u16 instanceSequence + u16
stateSequence = 16 bytes total. Mirrors the existing VectorUpdate.cs
template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: ShadowObjectRegistry.UpdatePhysicsState(guid, newState)

Files:

  • Modify: src/AcDream.Core/Physics/ShadowObjectRegistry.cs (add new method after UpdatePosition, before Deregister)
  • Modify: tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs (append new test)

Design rationale: ShadowEntry is a readonly record struct (value type) stored as copies inside per-cell List<ShadowEntry>. Mutation pattern: find every cell the entity occupies via _entityToCells[entityId], then replace each in-list copy with list[i] = list[i] with { State = newState }.

Retail anchor: CPhysicsObj::set_state at docs/research/named-retail/acclient_2013_pseudo_c.txt:283044. Retail does this->state = arg2 (line 283048) — direct overwrite. Our cached state lives in the registry copy, not the entity, so the equivalent is "overwrite every shadow copy."

  • Step 2.1: Write the failing test

Append to tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs (top-of-file using directives already have AcDream.Core.Physics + xUnit):

    // -----------------------------------------------------------------------
    // UpdatePhysicsState — L.2g slice 1 (doors flip ETHEREAL post-spawn)
    // -----------------------------------------------------------------------

    [Fact]
    public void UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits()
    {
        // Register a door-like entity with State=0 (closed = solid).
        var reg = new ShadowObjectRegistry();
        const uint doorId = 0x000F4244u;
        reg.Register(doorId, 0x020019FFu, new Vector3(12f, 12f, 50f),
                     Quaternion.Identity, 1f, OffX, OffY, LbId,
                     state: 0u, flags: EntityCollisionFlags.None);

        // Sanity: cached state starts at 0 (no ETHEREAL).
        var before = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId);
        Assert.Equal(0u, before.State);

        // Flip ETHEREAL_PS (0x4) — the server's "door is now open" message.
        reg.UpdatePhysicsState(doorId, 0x00000004u);

        // Cached state should now show the new bit.
        var after = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId);
        Assert.Equal(0x00000004u, after.State);
    }

    [Fact]
    public void UpdatePhysicsState_UnregisteredEntity_IsNoOp()
    {
        var reg = new ShadowObjectRegistry();
        // No entity registered. Should not throw.
        reg.UpdatePhysicsState(0xDEADBEEFu, 0x00000004u);
        Assert.Equal(0, reg.TotalRegistered);
    }

    [Fact]
    public void UpdatePhysicsState_EntitySpanningMultipleCells_AllCellsUpdated()
    {
        // Entity at (24,12) with radius=2 spans cells (0,0) and (1,0).
        var reg = new ShadowObjectRegistry();
        reg.Register(99u, 0x01000099u, new Vector3(24f, 12f, 50f),
                     Quaternion.Identity, 2f, OffX, OffY, LbId,
                     state: 0u);

        reg.UpdatePhysicsState(99u, 0x00000004u);

        uint cellA = LbId | 1u;          // cx=0
        uint cellB = LbId | (1u*8 + 0 + 1); // cx=1
        var inA = reg.GetObjectsInCell(cellA).Single(e => e.EntityId == 99u);
        var inB = reg.GetObjectsInCell(cellB).Single(e => e.EntityId == 99u);
        Assert.Equal(0x00000004u, inA.State);
        Assert.Equal(0x00000004u, inB.State);
    }

You may need a using System.Linq; at the top of the test file. Add it if not already present.

  • Step 2.2: Run tests to verify they fail (RED)

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~UpdatePhysicsState" Expected: Compile error — UpdatePhysicsState method not defined.

  • Step 2.3: Implement UpdatePhysicsState

Insert into src/AcDream.Core/Physics/ShadowObjectRegistry.cs after the UpdatePosition method (around line 127, before the Deregister summary comment):

    /// <summary>
    /// Update the cached <see cref="ShadowEntry.State"/> bits for an
    /// already-registered entity. Called by the inbound
    /// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
    /// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
    /// <c>ETHEREAL_PS = 0x4</c> on Use, so the
    /// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
    /// the new state on the next resolve.
    ///
    /// <para>
    /// Retail equivalent: <c>CPhysicsObj::set_state</c> at
    /// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
    /// — direct write `this->state = arg2`. Retail also fires side-effect
    /// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden)
    /// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1
    /// scopes to the bare state-write.
    /// </para>
    ///
    /// <para>
    /// Implementation: <see cref="ShadowEntry"/> is a value-type record
    /// copied into per-cell lists, so we rewrite the copy in each cell the
    /// entity occupies. Unregistered entities are a no-op (callers don't
    /// have to gate).
    /// </para>
    /// </summary>
    public void UpdatePhysicsState(uint entityId, uint newState)
    {
        if (!_entityToCells.TryGetValue(entityId, out var cellIds))
            return; // not registered — no-op

        foreach (var cellId in cellIds)
        {
            if (!_cells.TryGetValue(cellId, out var list)) continue;
            for (int i = 0; i < list.Count; i++)
            {
                if (list[i].EntityId == entityId)
                    list[i] = list[i] with { State = newState };
            }
        }
    }
  • Step 2.4: Run tests to verify they pass (GREEN)

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~UpdatePhysicsState" Expected: 3 passed.

  • Step 2.5: Verify full test suite still green (no regressions)

Run: dotnet test Expected: All previously-passing tests still pass. Note: ~8 pre-existing failures may be in the baseline (see docs/research/2026-05-13-l2d-slice1-shipped-handoff.md "Open concerns"); ensure the count does not increase. Stash + rerun to confirm if uncertain: git stash && dotnet test 2>&1 | findstr Failed then git stash pop.

  • Step 2.6: Commit
git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
git commit -m "feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState

New mutator that overwrites cached PhysicsState bits on every shadow copy
of the named entity. The existing CollisionExemption.ShouldSkip(...) check
(acclient_2013_pseudo_c.txt:276782) reads the same cached field, so a
post-spawn ETHEREAL flip is now honored on the next resolver tick without
any resolver-path change.

Retail anchor: CPhysicsObj::set_state at acclient_2013_pseudo_c.txt:283044.
Slice 1 scopes to the bare state-write — retail's cosmetic side-effect
handlers (0x800 lighting, 0x20 nodraw, 0x4000 hidden) don't fire for the
ETHEREAL bit and stay deferred.

Three TDD tests cover: ETHEREAL flip from 0->0x4; unregistered-entity
no-op; entity spanning multiple cells gets all copies updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: Wire 0xF74B into WorldSession dispatcher + new event

Files:

  • Modify: src/AcDream.Core.Net/WorldSession.cs (one new event declaration near the existing VectorUpdated/MotionUpdated events; one new else if branch in the inbound dispatcher near op == VectorUpdate.Opcode)

Reference pattern: Read the existing VectorUpdate.Opcode branch first (it's at WorldSession.cs:739752). Copy its shape exactly.

  • Step 3.1: Add the public event declaration

Find the existing public event Action<...>? VectorUpdated; declaration in WorldSession.cs (near line 119, in the events region). Add a sibling:

    /// <summary>
    /// Fires when the server broadcasts a <c>SetState (0xF74B)</c> game
    /// message — a previously-spawned entity's <c>PhysicsState</c>
    /// bitmask changed post-CreateObject. Chiefly doors flipping
    /// <c>ETHEREAL_PS = 0x4</c> on Use (see ACE
    /// <c>WorldObjects/Door.cs:127</c>, <c>WorldObject.cs:640-660</c>).
    /// Subscribers route the new state into
    /// <see cref="ShadowObjectRegistry.UpdatePhysicsState"/> so the
    /// existing collision-exemption short-circuit honors the flip on the
    /// next resolver tick.
    /// </summary>
    public event Action<SetState.Parsed>? StateUpdated;

Place it immediately after the existing VectorUpdated event for grep- findability.

  • Step 3.2: Add the dispatcher branch

In the inbound game-message dispatcher (the chain of else if (op == X.Opcode) branches in the same file), add this branch immediately after the VectorUpdate.Opcode branch:

            else if (op == SetState.Opcode)
            {
                // L.2g slice 1 (2026-05-12): server broadcasts SetState
                // (0xF74B) when an entity's PhysicsState changes
                // post-spawn — chiefly doors flipping ETHEREAL on Use.
                // Holtburger validated wire format = 16 bytes (opcode +
                // guid + state + 2×u16 sequence). ACE
                // GameMessageSetState.cs writes the same field order
                // but appears to use u32 for the sequences; Task 5's
                // hex-dump probe settles the actual byte count.
                var parsed = SetState.TryParse(body);
                if (parsed is not null)
                    StateUpdated?.Invoke(parsed.Value);
            }

The using AcDream.Core.Net.Messages; directive should already be at the top of WorldSession.cs (it's used by every existing parser). Confirm, don't add a duplicate.

  • Step 3.3: Verify build still green

Run: dotnet build Expected: Build succeeded, 0 errors.

  • Step 3.4: Commit
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B)

New StateUpdated event + dispatcher branch routes inbound SetState
messages to subscribers. Mirrors the existing VectorUpdated /
MotionUpdated event pattern. GameWindow will subscribe in the next
commit and feed the parsed (guid, newState) pair to
ShadowObjectRegistry.UpdatePhysicsState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Subscribe in GameWindow and feed ShadowObjectRegistry

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (one new subscription line + one new handler method)

This is the final wiring step. After this commit, the server's "door opened" SetState is end-to-end honored by the collision system.

  • Step 4.1: Add the subscription

Find the block in GameWindow.cs where _liveSession.MotionUpdated += OnLiveMotionUpdated; and _liveSession.PositionUpdated += OnLivePositionUpdated; are wired (around line 1791). Add:

            _liveSession.StateUpdated += OnLiveStateUpdated;

Place it after _liveSession.VectorUpdated += OnLiveVectorUpdated; so the event-subscription order is co-located with its peers.

  • Step 4.2: Add the handler method

Find the existing OnLiveVectorUpdated method body in the same file (grep private void OnLiveVectorUpdated). Add a sibling handler immediately after it:

    /// <summary>
    /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
    /// new <c>PhysicsState</c> bits into ShadowObjectRegistry so the
    /// existing <see cref="CollisionExemption.ShouldSkip"/> check honors
    /// the flip on the next resolver tick. Chiefly doors:
    /// server flips <c>ETHEREAL_PS = 0x4</c> on Use, the door's
    /// cylinder collision stops blocking the threshold.
    /// </summary>
    private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
    {
        _physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState);

        if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
            Console.WriteLine(System.FormattableString.Invariant(
                $"[setstate] guid=0x{parsed.Guid:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
    }
  • Step 4.3: Verify build still green

Run: dotnet build Expected: Build succeeded, 0 errors.

  • Step 4.4: Smoke-test that no regression breaks the launch path

Run a quick non-interactive smoke (do NOT do the full visual test yet — that's Task 7):

$env:ACDREAM_DAT_DIR        = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE           = "0"   # offline; just verify the binary starts
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
  Select-String -Pattern "Exception|FATAL" |
  Select-Object -First 5

Then kill the process. Expected: no startup exception, no FATAL. If anything blows up, the new handler subscription or the registry mutator broke something in the live-session attach path.

  • Step 4.5: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(phys L.2g slice 1): GameWindow routes SetState into registry

End-to-end wiring: WorldSession.StateUpdated fires -> GameWindow
OnLiveStateUpdated -> ShadowObjectRegistry.UpdatePhysicsState -> next
resolver tick sees the updated ETHEREAL bit and CollisionExemption
short-circuits the door cylinder. After this commit the M1 'open the
inn door' scenario is unblocked at the code-path level; visual
verification follows in slice 1's manual test (Task 7).

The handler also emits a [setstate] diagnostic line when
ACDREAM_PROBE_BUILDING is enabled — gives a greppable trail when the
visual test runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: Hex-dump probe for first SetState payload (wire-byte verification)

Files:

  • Modify: src/AcDream.Core.Net/WorldSession.cs (extend the existing else if (op == SetState.Opcode) branch added in Task 3)

Why: ACE's GameMessageSetState.cs:13-14 writes Sequences.GetCurrentSequence(...)

  • Sequences.GetNextSequence(...) as uint calls — potentially 4 bytes each (16-byte total payload) instead of holtburger's 12 bytes. We default to holtburger's spec because it's been validated against live retail-format servers, but we want one-shot evidence on real wire bytes before declaring slice 1 done.

The probe is gated on ACDREAM_PROBE_BUILDING (existing env var from L.2d slice 1) and fires once per SetState message; the body bytes are short enough that this is cheap.

  • Step 5.1: Extend the dispatcher branch with a hex-dump

Update the else if (op == SetState.Opcode) branch from Task 3 to:

            else if (op == SetState.Opcode)
            {
                // L.2g slice 1 (2026-05-12) — see Task 3 above for the
                // event-routing intent. The probe-gated hex-dump here
                // captures the wire bytes one-shot per session so we can
                // confirm holtburger's 12-byte payload format (vs ACE's
                // GameMessageSetState.cs claim of u32 sequences = 16
                // bytes) before declaring slice 1 done.
                if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
                    && !_setStateHexDumped)
                {
                    _setStateHexDumped = true;
                    var hex = string.Join(" ", body.Take(Math.Min(body.Length, 32))
                        .Select(b => b.ToString("X2")));
                    Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}");
                }

                var parsed = SetState.TryParse(body);
                if (parsed is not null)
                    StateUpdated?.Invoke(parsed.Value);
            }

Add the one-shot flag field near the top of the WorldSession class (group with other _dump*Enabled flags — grep private bool _ to find the cluster):

    /// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
    /// emits the first SetState's body bytes only, not 510/sec.</summary>
    private bool _setStateHexDumped;

Note: the body.Take(...) requires using System.Linq; — already present.

  • Step 5.2: Verify build still green

Run: dotnet build Expected: Build succeeded, 0 errors.

  • Step 5.3: Commit
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(phys L.2g slice 1): one-shot hex-dump probe for SetState payload

Probe-gated diagnostic (ACDREAM_PROBE_BUILDING) emits the first inbound
SetState message's body bytes so we can confirm holtburger's 12-byte
payload format vs ACE's GameMessageSetState.cs claim of u32 sequences
(16-byte payload). One-shot via _setStateHexDumped — won't flood the
log when doors auto-close every 30s.

If the hex-dump shows body.len > 16, the parser's body-length gate at
SetState.cs needs widening (and the seq-field reads shifted accordingly).
If it shows 16, we ship as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: Slice 0.5 — extend [entity-source] log with state + flags

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (extend the existing [entity-source] log line — there are 2 sites; modify both for consistency)

Why: The L.2d slice 1 handoff flagged this as a useful "slice 1.6" addendum. It's 5 LOC, fold-into-slice-1-freebie. Makes ETHEREAL flips greppable end-to-end: spawn -> registry update -> resolver effect.

  • Step 6.1: Find both [entity-source] log sites

Grep [entity-source] in src/AcDream.App/Rendering/GameWindow.cs and note the two Console.WriteLine calls (one is around line 2978 from the RegisterLiveEntityForCollision path; the other should be in the landblock-baked static registration path — grep confirms by file). Both need the same suffix addition.

  • Step 6.2: Extend both log lines

For each [entity-source] line, append state=0x{state:X8} flags={flags} to the format string. Example transformation:

Before:

Console.WriteLine(System.FormattableString.Invariant(
    $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root"));

After (note: the local variables state and flags should already be in scope at both sites — they're computed just before the ShadowObjects.Register(...) call; grep upward 510 lines from each log site to confirm):

Console.WriteLine(System.FormattableString.Invariant(
    $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root state=0x{state:X8} flags={flags}"));

If the state or flags variables are scoped differently at one site (e.g. one site is for landblock-baked statics that always have state=0), substitute the literal 0u or EntityCollisionFlags.None and add a comment noting the static-default. Keep the field names identical at both sites so a single regex state=0x([0-9A-F]+) catches every entry.

  • Step 6.3: Verify build still green

Run: dotnet build Expected: Build succeeded, 0 errors.

  • Step 6.4: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(phys L.2g slice 1): extend [entity-source] log with state + flags

5-LOC freebie folded into L.2g slice 1: the [entity-source] probe now
emits the PhysicsState bits + EntityCollisionFlags decoded at
registration. Combined with the new [setstate] handler log line, this
makes door open/close events fully greppable end-to-end:
  spawn  -> [entity-source] guid=... state=0x00000000 ...
  Use    -> [setstate] guid=... state=0x00000004 ...
  close  -> [setstate] guid=... state=0x00000000 ...

Resolves the 'slice 1.6' suggestion from
docs/research/2026-05-13-l2d-slice1-shipped-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: Visual verification at Holtburg inn doorway

Files: None — this is a user-driven test. Document the recipe; report results in the handoff doc (Task 8).

Acceptance:

  1. Walk acdream +Acdream into the Holtburg inn doorway. Expected: blocked at threshold.
  2. Click the door (Use action). Expected: door swings open; [setstate] log line emits with state=0x00000004; walk through clears.
  3. Wait ~30 seconds. Expected: door auto-closes; [setstate] log line emits with state=0x00000000; threshold blocks again.
  4. Inspect the [setstate-hex] line emitted on the first SetState — confirm body.len=16. If it's 20 instead, slice 1 has a bug to file as 1b.
  • Step 7.1: Launch the client with probes enabled

Wait ~5 seconds since the last close (per CLAUDE.md's logout-before-reconnect note) then:

$env:ACDREAM_DAT_DIR        = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE           = "1"
$env:ACDREAM_TEST_HOST      = "127.0.0.1"
$env:ACDREAM_TEST_PORT      = "9000"
$env:ACDREAM_TEST_USER      = "testaccount"
$env:ACDREAM_TEST_PASS      = "testpassword"
$env:ACDREAM_DEVTOOLS       = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE  = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
  Tee-Object -FilePath "launch-l2g-slice1.log"
  • Step 7.2: Manually perform the four-step scenario

(User-driven. See Acceptance list above.)

  • Step 7.3: Inspect the log for the four expected lines

After closing the client window:

Select-String -Path launch-l2g-slice1.log -Pattern "setstate-hex|setstate.*guid|entity-source.*Door"

Expected matches:

  • One [setstate-hex] body.len=16 first-16-bytes: 4B F7 ... line.

  • One [entity-source] ... name=Door ... state=0x00000000 ... (or similar).

  • A [setstate] guid=0x000F.... state=0x00000004 ... after the Use click.

  • A [setstate] guid=0x000F.... state=0x00000000 ... ~30s after the previous.

  • Step 7.4: Decide ship-or-fix

Three outcomes:

  • All four log lines match + door scenario works visually: slice 1 ships. Proceed to Task 8.
  • Log lines correct but visual scenario fails (door visually opens but player still blocked): the resolver is reading stale state from somewhere we haven't found. Stop and file a "slice 1b — find the second cache layer" note.
  • [setstate-hex] body.len=20: ACE's u32 sequence claim is real. Widen SetState.cs body-length gate (16 -> 20) and shift sequence reads to body.Slice(12, 4) + body.Slice(16, 4) (read as uint, cast to ushort if values are small — high bits will be zero per ACE's Sequences design). Re-run from Task 7.1.

Task 8: Ship handoff doc + roadmap update

Files:

  • Create: docs/research/2026-05-XX-l2g-slice1-shipped-handoff.md (replace XX with the actual ship date)

  • Modify: CLAUDE.md (replace the "the natural next step is the L.2g slice 1 implementation" paragraph with a "Phase L.2g slice 1 shipped " paragraph mirroring the L.2a paragraph style)

  • Modify: docs/plans/2026-04-29-movement-collision-conformance.md (under the L.2g section, add a "Current shipped slice" subsection noting slice 1 + its commit hashes)

  • Step 8.1: Write the ship handoff doc

Use the existing handoff at docs/research/2026-05-13-l2d-slice1-shipped-handoff.md as a template. The new doc should cover:

  • TL;DR: what landed, did the visual test pass.

  • What shipped (commit hash + subject per commit from Tasks 16).

  • What the visual test showed (the four log-line samples from Task 7.3).

  • Wire-byte width resolution (12-byte vs 16-byte — whichever the hex-dump showed).

  • Side findings (anything noticed during visual test — door animation flickers, audio not playing, etc — file under "deferred").

  • Next-session candidates (L.2g slice 2 animation confirmation, deferred UX polish, OR pick from CLAUDE.md's now-revised "Next phase candidates" list).

  • Step 8.2: Update CLAUDE.md

Find the "Currently in Phase L.2 (Movement & Collision Conformance)" paragraph. Replace its "the natural next step is the L.2g slice 1 implementation" sentence with "L.2g slice 1 shipped — doors honor ETHEREAL flips end-to-end; visual-verified at Holtburg inn doorway." Add a "Phase L.2g slice 1 shipped ." descriptive paragraph after the L.2a paragraph (mirror the L.2a paragraph's depth).

In the "Next phase candidates" list, demote the current L.2g item out and pick whichever is the next sensible candidate (likely L.2g slice 2 animation confirmation OR a non-L.2 visual-fidelity item — depends on what the visual test in Task 7 showed).

  • Step 8.3: Update the L.2 plan-of-record

In docs/plans/2026-04-29-movement-collision-conformance.md, under the L.2g section, add a "Current shipped slice ():" subsection listing the slice 1 commit hashes + their subjects (use git log to fill in). Mirror the L.2c "Current shipped slice (2026-04-30):" subsection style.

  • Step 8.4: Commit
git add CLAUDE.md docs/plans/2026-04-29-movement-collision-conformance.md docs/research/2026-05-XX-l2g-slice1-shipped-handoff.md
git commit -m "docs(phys L.2g): slice 1 shipped handoff + plan-of-record + CLAUDE.md

Slice 1 visual-verified at Holtburg inn doorway: walking into closed door
is blocked, Use opens it, walk-through clears, auto-close re-blocks at 30s.
Wire-byte width settled (see handoff doc).

L.2g slice 2 (animation confirmation) becomes the next candidate IF the
visual test showed door animation not playing; otherwise slice 2 is a
verify-only no-op and we move to the next phase candidate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Plan self-review

1. Spec coverage check:

Spec section Task
Slice 1 — parse SetState (0xF74B) Tasks 1 + 3 + 5
Slice 1 — plumb new state into ShadowObjectRegistry Tasks 2 + 4
Slice 1 — visual verification at Holtburg Task 7
Slice 0.5 — extend [entity-source] log with state + flags Task 6
Open Q1 — wire-byte width Task 5 (hex-dump probe) + Task 7.4 (decision branch)
Open Q2 — UpdateMotion drives non-creature entities (door swing animation) Deferred to slice 2 (per the spec — animation is verify-only)
Open Q3 — SetState delivered to the player who triggered Use Task 7 visual test verifies (covered implicitly by the four-step scenario)
Acceptance — design spec, plan-of-record, milestones, CLAUDE.md all reference L.2g Already done in 2c10dd4; Task 8 closes the loop with the slice 1 ship handoff
Named retail citation in slice 1 code Task 2.3 cites acclient_2013_pseudo_c.txt:283044; Task 1.3 cites the holtburger struct

2. Placeholder scan: No TBD, TODO, "fill in later." <date> in Task 8 is a deliberate placeholder for the engineer to fill in at ship time — flagged as such in the handoff doc template, not a plan-writing oversight. The Task 8.1 doc filename uses 2026-05-XX for the same reason.

3. Type consistency: SetState.Parsed(Guid, PhysicsState, InstanceSequence, StateSequence) used consistently in Tasks 1, 3, 4, 5. UpdatePhysicsState(uint entityId, uint newState) signature consistent in Tasks 2 + 4. ShadowEntry.State matches the existing struct definition in ShadowObjectRegistry.cs:262-280.

4. Risk surface: All changes are additive. No resolver edits. No broadphase edits. No retail-port semantics changes. If anything goes wrong, single-commit revert per task.


Execution

Plan complete. Two execution options when ready:

1. Subagent-Driven (recommended) — Dispatch a fresh Sonnet subagent per task (Task 1 alone, Tasks 2 + 3 together, Task 4 + 5 + 6 together, Task 7 user-driven, Task 8 docs). Review between dispatches. Each subagent stays bounded to one commit's worth of changes; parent context stays clean.

2. Inline Execution — Drive all tasks in this session using executing-plans. Faster end-to-end but consumes ~4× more parent context.

Total scope estimate: ~6 commits over ~3060 minutes of work + Task 7 visual test (~10 minutes when ACE + retail client are already running).