diff --git a/.gitignore b/.gitignore
index af968b2..755511f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,13 +18,18 @@ packages/
Thumbs.db
# Reference repos and retail client (large, not our code, separate licenses)
-references/
+# WorldBuilder is exempt β it's a load-bearing dependency tracked as a git
+# submodule pointing at our fork (Phase N, see docs/architecture/worldbuilder-inventory.md).
+references/*
+!references/WorldBuilder
+!references/WorldBuilder/
# Claude Code session state
.claude/
launch.log
launch-*.log
launch.utf8.log
+n4-verify*.log
# ImGui auto-saved window/docking state (per-user, not source)
imgui.ini
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..c691aa8
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "references/WorldBuilder"]
+ path = references/WorldBuilder
+ url = git@github.com:eriknihlen/WorldBuilder.git
+ branch = acdream
diff --git a/AcDream.slnx b/AcDream.slnx
index 1cf8f24..c004e10 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -13,6 +13,7 @@
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 469b95c..4836d9c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,12 +25,119 @@ single source of truth for how the client is structured. All work must
align with this document. When the architecture doc and reality diverge,
update one or the other β never leave them out of sync.
-**Execution phases:** R1βR8 in the architecture doc. Each phase has clear
-goals, test criteria, and builds on the previous. Don't skip phases.
+**WorldBuilder is acdream's rendering + dat-handling base, integrated
+as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the
+production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher`
+is the production draw path (default-on, see `WbFoundationFlag`). Before
+re-implementing any AC-specific rendering or dat-handling algorithm,
+**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If
+WorldBuilder has it, port from WorldBuilder (or call into our fork via
+the adapter), not from retail decomp. WorldBuilder is MIT-licensed,
+verified to render the world correctly, and uses the same Silk.NET
+stack we target. Re-porting from retail decomp when WB already has a
+tested port is how subtle bugs (the scenery edge-vertex bug, the
+triangle-Z bug) keep slipping in. Retail decomp remains the oracle for
+network, physics, animation, movement, UI, plugin, audio, chat β see
+the inventory doc's π΄ list for the full scope of "we still write this
+ourselves".
-The codebase is organized by layer (see architecture doc). Current phase
-state lives in memory (`memory/project_*.md`), plans in `docs/plans/`,
-research in `docs/research/`.
+**WB integration cribs:**
+- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` β single seam over WB's
+ `ObjectMeshManager`. Owns the WB pipeline, drains its staged-upload
+ queue per frame via `Tick()`, populates `AcSurfaceMetadataTable` with
+ per-batch translucency / luminosity / fog metadata.
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` β production draw
+ path. Groups all visible (entity, batch) pairs, single-uploads the
+ matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance`
+ per group with `BaseInstance` pointing at the slice. Per-entity
+ frustum cull, opaque front-to-back sort, palette-hash memoization.
+- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` /
+ `EntitySpawnAdapter.cs` β bridge spawn lifecycle to WB ref-counts.
+ Atlas tier (procedural) goes via Landblock; per-instance tier
+ (server-spawned, palette/texture overrides) goes via Entity.
+- **Modern path is mandatory as of N.5 ship amendment (2026-05-08).**
+ `WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`
+ are deleted. Missing `GL_ARB_bindless_texture` or
+ `GL_ARB_shader_draw_parameters` throws `NotSupportedException` at
+ startup. There is no legacy fallback.
+- **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh
+ into a single global VAO/VBO/IBO. Each batch references its slice
+ via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO).
+ Honor those offsets when issuing draws β `DrawElementsInstanced`
+ with `indices=0` will draw every entity's first triangle from the
+ global mesh, not the per-batch range. (This is exactly the
+ exploded-character bug we hit during Task 26.)
+- **WB's `ObjectRenderBatch.SurfaceId` is unset** β the actual surface
+ id lives in `batch.Key.SurfaceId` (the `TextureKey` struct).
+- **`ObjectMeshManager.IncrementRefCount` only bumps a counter** β it
+ does NOT trigger mesh loading. You must explicitly call
+ `PrepareMeshDataAsync(id, isSetup)` to fire the background decode.
+ Result auto-enqueues to `_stagedMeshData` which `Tick()` drains.
+ `WbMeshAdapter` does this for you on first registration.
+- **N.5 modern dispatch** (`docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`)
+ uses bindless textures + multi-draw indirect on top of N.4's grouped
+ pipeline. Per frame: three SSBO uploads (`_instanceSsbo` mat4 per
+ instance @ binding=0; `_batchSsbo` `(uvec2 textureHandle, uint layer,
+ uint flags)` per group @ binding=1; `_indirectBuffer`
+ `DrawElementsIndirectCommand[]` opaque-section + transparent-section).
+ Two `glMultiDrawElementsIndirect` calls per frame, one per pass.
+ Total ~12-15 GL calls per frame for entity rendering regardless of
+ scene complexity.
+- **`TextureCache` requires `BindlessSupport`** for the WB modern path.
+ Three `Bindless`-suffixed `GetOrUpload*` methods return 64-bit handles
+ made resident at upload time, backed by parallel Texture2DArray uploads
+ (`UploadRgba8AsLayer1Array`). The legacy `uint`-returning methods stay
+ for Sky / Terrain / Debug / particle paths that still sample via
+ `sampler2D`. After N.6 retires legacy renderers, the legacy upload path
+ + caches can be deleted.
+- **Translucency model is two-pass alpha-test** (matches WB), not
+ per-blend-mode subpasses. Opaque pass discards `Ξ±<0.95`; transparent
+ pass discards `Ξ±β₯0.95` AND `Ξ±<0.05`. Native `Additive` blend renders
+ as alpha-blend on GfxObj surfaces β falsifiable; if a magic-content
+ regression shows up, add a third indirect call with
+ `glBlendFunc(SrcAlpha, One)` per spec Β§6 fallback (~30 min change).
+- **Per-instance highlight (selection blink) is reserved β open
+ backlog, no scheduled phase.** `mesh_modern.vert`'s `InstanceData`
+ struct has a documented hook for `vec4 highlightColor`. Whoever
+ eventually picks it up finds the hook there; the change is localized:
+ extend `InstanceData` stride 64β80 bytes, add the field, mix into
+ fragment color in `mesh_modern.frag`. ~30 min when the time comes.
+- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` β terrain dispatcher
+ on N.5's modern primitives. Mirrors WB's `TerrainRenderManager` pattern
+ (single global VBO/EBO + slot allocator + `glMultiDrawElementsIndirect`)
+ but driven by acdream's `LandblockMesh.Build` so retail's `FSplitNESW`
+ formula is preserved (issue #51 resolved). Atlas handles bound via the
+ uvec2 + `sampler2DArray(handle)` constructor pattern (NOT the direct
+ `uniform sampler2DArray` + `glProgramUniformHandleARB` form, which
+ GL_INVALID_OPERATIONs on at least one driver).
+- **Two-tier streaming architecture (Phase A.5, shipped 2026-05-10).**
+ `src/AcDream.App/Streaming/` owns the full streaming pipeline. Key types:
+ `StreamingRegion` (two-radius Chebyshev window: Nβ=near, Nβ=far; produces
+ `TwoTierDiff` with 5 transition lists per tick), `StreamingController`
+ (render-thread coordinator: routes `TwoTierDiff` to the worker queue and
+ drains completions up to `MaxCompletionsPerFrame` per frame),
+ `LandblockStreamer` (single background worker thread: `LoadFar` = heightmap
+ + mesh only, `LoadNear` = heightmap + `LandBlockInfo` + scenery + mesh,
+ `PromoteToNear` = `LandBlockInfo` + scenery only),
+ `GpuWorldState` (render-thread entity state: `AddEntitiesToExistingLandblock`
+ for promotions, `RemoveEntitiesFromLandblock` for demotions).
+ Default: Nβ=4 (81 near LBs, full detail), Nβ=12 (544 far LBs, terrain
+ only). Quality Preset system (`QualitySettings.From(preset)`) controls
+ both radii and MSAA/anisotropic/A2C/completions-per-frame as a unit.
+ Spec: `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`.
+
+**Execution model:** the active source of truth is the **milestones doc**
+(`docs/plans/2026-05-12-milestones.md`) for "what are we building right
+now" and the **strategic roadmap** (`docs/plans/2026-04-11-roadmap.md`)
+for the per-phase ledger of what's shipped, what's in flight, and what
+comes next. **Ignore the old "R1βR8" sequence** β it was an early refactor
+sketch that no longer matches reality (see the "Roadmap Model" section in
+`docs/architecture/acdream-architecture.md`). Per-phase detailed specs
+live under `docs/superpowers/specs/`.
+
+The codebase is organized by layer (see architecture doc + the **Code
+Structure Rules** section below). Plans live in `docs/plans/`,
+research in `docs/research/`, persistent project memory in `memory/`.
**UI strategy:** three-layer split β swappable backend (ImGui.NET +
`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a, custom retail-look
@@ -46,9 +153,10 @@ time using dat assets); ImGui persists forever as the
`ACDREAM_DEVTOOLS=1` overlay. **All plugin-facing UI targets
`AcDream.UI.Abstractions` β never import a backend namespace from a
panel.** Full design: `docs/plans/2026-04-24-ui-framework.md`.
-Memory cribs: `memory/project_ui_architecture.md` (architecture),
-`memory/project_chat_pipeline.md` (chat pipeline as of Phase I),
-`memory/project_input_pipeline.md` (input pipeline as of Phase K).
+Memory cribs: `memory/project_chat_pipeline.md` (chat pipeline as of
+Phase I), `memory/project_input_pipeline.md` (input pipeline as of
+Phase K). UI architecture full design at
+[`docs/plans/2026-04-24-ui-framework.md`](docs/plans/2026-04-24-ui-framework.md).
**Input pipeline:** `src/AcDream.UI.Abstractions/Input/` (action enum,
`KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope
@@ -62,12 +170,74 @@ click-to-rebind. As of Phase K (2026-04-26), ALL keyboard / mouse
input flows through the dispatcher β no IsKeyPressed polling outside
the per-frame movement queries.
+## Code Structure Rules
+
+These are the structural rules the project commits to. They are
+**process rules** (where code goes, what depends on what), not style
+rules (formatting / naming). They exist to keep the layer split honest
+and to stop `GameWindow.cs` from continuing to grow into a 10k-line
+god object. The full rationale + the extraction sequence we're
+pursuing live in [`docs/architecture/code-structure.md`](docs/architecture/code-structure.md).
+
+1. **No new substantial feature bodies in `GameWindow.cs`.** It is
+ already over 10,000 lines and owns runtime wiring. New runtime
+ work goes into a dedicated controller / sink / orchestrator class
+ in `src/AcDream.App/` (or deeper in `AcDream.Core` when it's pure
+ logic). Adding a handful of fields and a one-paragraph method to
+ wire an extracted class in is fine; adding a new ~200-line feature
+ directly is not. When in doubt, file a small follow-up extraction
+ as part of the change.
+
+2. **`AcDream.Core` must not depend on the window / GL / backend
+ projects, except via documented interop seams.** The only
+ currently-allowed seams are `WorldBuilder.Shared` (stateless helpers:
+ `TerrainUtils`, `TerrainEntry`, `RegionInfo`) and
+ `Chorizite.OpenGLSDLBackend.Lib` (stateless helpers only:
+ `SceneryHelpers`, `TextureHelpers`). New Core code that needs a GL
+ surface must define an interface in Core and let `AcDream.App`
+ implement it β never the reverse. If you need to add a project
+ reference to Core, the change must come with an inventory-doc
+ update explaining why.
+
+3. **UI panels target `AcDream.UI.Abstractions` only.** No panel may
+ import `AcDream.UI.ImGui` or any backend namespace. ViewModels,
+ commands, and the `IPanel` / `IPanelRenderer` contract are the
+ surface; everything else is backend. This is what lets us swap
+ D.2a (ImGui) for D.2b (retail-look) later without rewriting
+ panels.
+
+4. **Startup environment variables enter through a typed options
+ object.** `AcDream.App.RuntimeOptions` is the single source of
+ truth for startup configuration. `Program.cs` reads the
+ environment once into `RuntimeOptions` and passes it into
+ `GameWindow`. Don't sprinkle `Environment.GetEnvironmentVariable`
+ reads through new code paths; add a field to `RuntimeOptions` and
+ pipe it through.
+
+5. **Runtime probes (diagnostic toggles) belong in diagnostic owner
+ classes.** Today `AcDream.Core.Physics.PhysicsDiagnostics` owns the
+ `ACDREAM_PROBE_*` family. The pattern: one static class per
+ subsystem, exposing typed bool/int properties read from env vars
+ once at startup (with optional runtime-toggleable counterparts for
+ the DebugPanel). Per-call-site `Environment.GetEnvironmentVariable`
+ reads in new code are a process smell β if a flag survives one
+ phase, promote it to a diagnostic owner. The dozens of existing
+ `ACDREAM_DUMP_*` reads scattered through `GameWindow` are tech
+ debt; do not add more.
+
+6. **Tests live in the project matching the layer under test.** Core
+ tests in `tests/AcDream.Core.Tests/`, UI tests in
+ `tests/AcDream.UI.Abstractions.Tests/`, network tests in
+ `tests/AcDream.Core.Net.Tests/`. App-layer tests (RuntimeOptions
+ parsing, etc.) belong in `tests/AcDream.App.Tests/`. When adding a
+ new test project, register it in `AcDream.slnx`.
+
## How to operate
**You are the lead engineer AND architect on this project at all times.**
You own the architecture (`docs/architecture/acdream-architecture.md`),
-the execution plan (phases R1βR8), the development workflow, and all
-technical decisions. Stop as little as possible. Drive work autonomously and continuously through full phases and
+the execution plan (milestones doc + strategic roadmap), the development
+workflow, and all technical decisions. Stop as little as possible. Drive work autonomously and continuously through full phases and
across commit boundaries. Do not stop mid-phase for routine progress check-ins,
permission asks on low-stakes design calls, or "should I continue?" confirmations.
The user has repeatedly authorized direct-to-main commits, multi-commit sessions,
@@ -77,6 +247,26 @@ The only thing that genuinely requires stopping is **visual confirmation** β t
user needs to look at the running client and tell you whether it matches
retail. Everything else is your call.
+**No workarounds without explicit approval.** When you spot a bug or
+encounter a behavioral mismatch, fix the underlying cause β do not ship a
+band-aid, suppression flag, grace period, retry loop, or any other "make
+the symptom go away" shortcut, unless the user has explicitly approved
+that shape OR you are building a NEW feature with a different design.
+This rule exists because every workaround creates architectural debt that
+masks the real issue, makes future refactors harder, and erodes the
+codebase's retail-faithfulness. Examples of disallowed shortcuts: an
+`if (problematicState) return early` guard at the symptom site instead of
+investigating why the state happened; a timer-based "settle period" to
+hide a race; a flag like `_suppressXDuringY` to mask a wire-level mistake;
+a `try/catch` swallowing an exception that signals a real problem. If you
+notice a fix is starting to look like a workaround mid-implementation,
+stop, file the proper investigation as an issue with full reproduction
+notes, and either (a) ask the user before shipping the workaround, or
+(b) invest the time to fix the root cause. The user has explicitly
+authorized "spend more time, get it right" over "ship a shortcut and
+file the cleanup." Quote them: "we should have no workarounds unless I
+say so or we want a different feature."
+
**Only stop and wait for the user when:**
- Visual verification is the acceptance test ("does the drudge look right now?")
@@ -87,12 +277,23 @@ retail. Everything else is your call.
files, reverting multiple commits)
- Memory or committed history shows a clear user preference you're about to
diverge from
+- **The request is an investigation, audit, analysis, review, or "report-only"**
+ β no edits, no writes, no diagnostic code drops until you've delivered the
+ report and the user explicitly approves a fix. Use the `/investigate` skill
+ (`.claude/skills/investigate/SKILL.md`) to enter this mode cleanly.
+- **A referenced commit, file path, branch, or doc doesn't exist** where the
+ user said it would. Ask one short question; don't go hunting across branches
+ or worktrees. A 1-line clarification beats 30 minutes of wrong-branch
+ exploration.
**Things you should just do without asking:**
- Continue to the next planned sub-step of a phase after the previous one
lands clean β including immediately starting work on the next phase if the
- current one is done
+ current one is done. **You pick what comes next** per the Milestone
+ discipline section β never present the user a menu like "should we do X
+ or Y?" or ask "what next?". Just choose and announce the choice in one
+ sentence. Work-order selection is Claude's job, not the user's.
- Pick between two roughly equivalent implementations; justify the choice in
the commit message
- Refactor small amounts of surrounding code when genuinely needed to land a
@@ -246,6 +447,14 @@ isn't enough, attach cdb to a live retail client (Step -1).**
context of the existing code it's modifying. The first animation
sequencer integration was done by a subagent that didn't understand
the transform pipeline β it broke everything.
+- **Do not replace working retail-faithful logic with a modern redesign**
+ without explicit user approval. Two campaigns (267 min remote-entity
+ prediction+rubber-band replacing hard-snap; speculative shader edits in
+ the sky-fog work) had to be reverted in full because the redesign
+ regressed behavior the original port had right. When you see "I could
+ simplify this with X" on a retail-port, flag the tradeoff and ask
+ before deleting the existing path. Retail-faithful first; "cleaner"
+ second.
### Phase completion checklist:
@@ -379,6 +588,66 @@ This toolchain was used to settle the L.5 steep-roof investigation:
`set_collide` rate per minute. See commit history around 2026-04-30
for the trace data and the decisions it drove.
+## MCP servers (live tooling)
+
+Two MCP servers extend the static decomp + cdb workflow with live
+introspection. **Ghidra MCP** requires Ghidra to be running with a
+CodeBrowser open in the target project; **WireMCP** auto-loads at
+Claude Code startup.
+
+### Ghidra MCP (LaurieWired v1.4, HTTP)
+
+Starts an HTTP server on **port 8080** (or **8081** if 8080 is
+taken β first-open-wins) when a CodeBrowser tool opens a program.
+Currently serving **`patchmem.gpr`** β the 2013 v11.4186 build with
+full PDB applied, same source as `docs/research/named-retail/`. Use
+this when grep'ing `acclient_2013_pseudo_c.txt` returns too much
+noise and you want the decomp for one specific function or address
+without dumping the whole file into context.
+
+Probe: `curl http://127.0.0.1:8081/methods?limit=3`
+
+Useful endpoints (GET unless noted):
+
+- `/methods?limit=N` β function names
+- `/list_functions?limit=N` β `Name at HHHHHHHH` lines
+- `/decompile_function?address=0xHHHHHHHH` β decompiled C for one function
+- `/function_xrefs?name=...` β callers / callees
+- `/classes`, `/namespaces`, `/strings`
+- POST `/rename_function_by_address`, POST `/set_decompiler_comment`
+
+NO endpoints for: signature setting, namespace setting, script
+execution, save-project. Those still require Ghidra's GUI or
+`analyzeHeadless`. Full endpoint catalog + Ghidra project layout in
+`memory/reference_ghidra_projects.md`.
+
+### WireMCP (stdio, Node, user-scope)
+
+Wraps `tshark` at `C:\Program Files\Wireshark\tshark.exe`
+(auto-detected via the Windows fallback path in `WireMCP/index.js`).
+Direct fit for ACE wire-protocol work β capture loopback
+(`127.0.0.1:9000`) to cross-check inbound message parsing (`0xF61C`
+movement, `0xF74A` pickup despawn, `0xF7DE` chat, etc.) against the
+actual bytes, or diff ACE's outbound vs. the holtburger reference.
+Replaces ad-hoc Wireshark sessions in the conversation.
+
+Tools exposed:
+
+- `capture_packets` β short live capture on an interface, returns JSON
+- `get_summary_stats` β protocol hierarchy stats
+- `get_conversations` β TCP/UDP conversation table
+- `analyze_pcap` β parse a saved `.pcap` file
+- `check_threats`, `check_ip_threats` β URLhaus / threat-feed lookups
+- `extract_credentials` β grep for creds across protocols (rarely relevant)
+
+Installed at `C:\Users\erikn\source\repos\WireMCP\` (clone of
+`0xKoda/WireMCP`). Registered via `claude mcp add wiremcp --scope user`.
+
+**When NOT to use WireMCP:** decoding the AC packet *format* β that
+lives in `holtburger`, ACE, and `Chorizite.ACProtocol`. WireMCP shows
+you the bytes on the wire; the reference repos tell you what they
+mean.
+
## Subagent policy
Subagents are the primary tool for saving parent-context and keeping one
@@ -408,6 +677,89 @@ spec path, the files it should read, the acceptance criteria (build + test
green), and the commit message style. Subagents inherit CLAUDE.md so they
follow the same rules.
+## Milestone discipline
+
+acdream operates at **two altitudes** above the daily commit:
+
+- **`docs/plans/2026-05-12-milestones.md`** β the morale + scope layer.
+ Seven milestones (M0βM7) from "Connect & explore" to "v1.0", each
+ defined by a concrete playable scenario and ~6β10 weeks of work. This
+ is where you orient when the project feels half-built and you're not
+ sure what to work on. Phases are too granular to feel like progress;
+ this doc is the multi-week target.
+- **`docs/plans/2026-04-11-roadmap.md`** β the strategic roadmap (next
+ section). Phase-level index. This is where you orient when you know
+ the milestone and need the next concrete sub-phase.
+
+**M1 landed 2026-05-16** via Phase B.6 (`d640ed7`). L.2 collision +
+B.4 interaction + B.5 pickup + B.6 server-driven auto-walk all
+shipped. The four demo targets work end-to-end: walk Holtburg, open
+inn door, click NPC, pick up item. Freeze list active β M1's phases
+are off-limits until M7 polish. Writeup at top of M1 block in
+`docs/plans/2026-05-12-milestones.md`.
+
+**Currently working toward: M2 β "Kill a drudge."** Equip a sword,
+walk to a drudge, swing, see damage in chat, watch the swing
+animation, drudge dies and drops loot, pick up the loot, open
+inventory and see it. Phases to ship: F.2 (Inventory panel), F.3
+(Combat math + damage flow), F.5a (visible-at-login dev panels β
+Attributes / Skills / Equipped / Inventory list, minimal ImGui),
+L.1c (combat animation wiring), L.1b (command router prereq).
+~6β10 weeks from 2026-05-16.
+
+**Work-order autonomy β the meta-rule.** You decide what to work on
+next, always. **The user does NOT pick between phases, milestones, or
+"what's next?" alternatives.** The milestone discipline + the
+per-milestone phase list + the roadmap IS the work order β drive it.
+Never ask the user "want me to start X or Y?" or present a menu of
+options. If two next steps are genuinely equivalent, state which one
+you picked and why in one sentence and start β don't ask. The user
+retains the right to redirect if they think you're wrong, but the
+default is **Claude drives, user reviews**. The user finds decision
+fatigue from constant work-order choices draining β that's literally
+what triggered the milestones doc on 2026-05-12. Honoring this rule is
+the single biggest morale lever. This is the meta-rule that makes the
+four below actually work.
+
+**The four motivation-keeping rules:**
+
+1. **One active milestone at a time.** Work that isn't on the critical
+ path to M1 gets filed in `docs/ISSUES.md` with a `post-M1` tag and
+ muted. This is the single rule that kills the "jumping between
+ things" feeling. If a phase isn't part of the current milestone, it
+ doesn't get touched β even if it's tempting, even if it would be
+ "quick", even if it would be "while I'm here."
+
+2. **Frozen phases are off-limits.** M0's ~25 shipped phases are frozen
+ until M7's polish pass. Concretely: no rework on streaming, chat,
+ input, the WB rendering migration, sky/lighting, the particle
+ system, or the network handshake. Those are done. Don't revisit them
+ β even if you see something that could be 10% better. Visual
+ nice-to-haves and architecture second-guesses on frozen phases are
+ explicitly post-M7. The freeze list per milestone lives in the
+ milestones doc.
+
+3. **Crossing a milestone is a textual event, not a video event.**
+ When a milestone's demo scenario is functionally complete, update
+ `2026-05-12-milestones.md` with a one-paragraph writeup describing
+ what works end-to-end, flip the freeze list, and update the
+ "currently working toward" line in this CLAUDE.md to the next
+ milestone. Do NOT ask the user to record a demo video β they find
+ it pointless. The milestones doc + the CLAUDE.md flip ARE the
+ milestone artifact. Phases ship; milestones land.
+
+4. **State both altitudes at session start.** First action of any
+ session: "Currently working toward M1 β Walkable + clickable world.
+ Current phase: L.2. Next concrete step: [whatever]." This keeps the
+ high-level orientation visible alongside the immediate task and
+ makes mid-session drift obvious.
+
+When reality and the milestones diverge β a phase grows beyond the
+milestone's scope, a demo scenario turns out to be unreachable without
+a new sub-phase, the order needs reshuffling β update the milestones
+doc in the same session you discover the divergence. Same rule as the
+roadmap.
+
## Roadmap discipline
acdream's plan lives in two files committed to the repo:
@@ -424,6 +776,196 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user
approval.
+**Indoor walking Phase 2 β Portal-based cell tracking shipped
+2026-05-19.** Six commits:
+- `1969c55` β CellBSP + Portals wired into CellPhysics (`PortalInfo` struct, `VisibleCellIds`)
+- `aad6976` β `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`; `ResolveCellId` rename
+- `069534a` β `BuildingPhysics` + `CheckBuildingTransit` for outdoorβindoor entry via `BldPortalInfo`
+- `702b30a` β code-review polish (DRY cell-id derivation, `PortalFlags.ExactMatch` enum, docs)
+- `3ffe1e4` β critical fix: pass foot-sphere center (`GlobalSphere[0].Origin`) not `CheckPos` to `ResolveCellId`
+- `eb0f772` β `TryFindIndoorWalkablePlane` synthesizes indoor walkable plane from cell floor poly
+
+**#86** (click selection penetrates walls) β **CLOSED** (Phase 1 Cluster A).
+**#84** (blocked by air indoors) β **FULLY CLOSED.** Spawn-in-building variant
+closed by Phase 1 (Phase D AABB containment). Wall-block-from-inside variant
+closed by Phase 2 (portal-graph traversal).
+**#85** (pass through walls outsideβin) β **CLOSED** by Phase 2.
+`CheckBuildingTransit` promotes CellId via the building-shell portal graph
+on outdoorβindoor entry; indoor-BSP collision fires from both sides.
+**#87** (indoor portal-based cell tracking) β **CLOSED** by Phase 2.
+**#88** (indoor static objects vibrate) β **FILED** (pre-existing, Medium).
+**#89** (port `BSPQuery.SphereIntersectsCellBsp`) β **FILED** (Low, documented
+approximation in `CheckBuildingTransit`).
+Diagnostic infrastructure: `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
+`[check-bldg]` probes all stay in place.
+Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md).
+Phase 1 handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](docs/research/2026-05-19-cluster-a-shipped-handoff.md).
+
+**Next phase is Claude's choice** per work-order autonomy. Candidates:
+M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b β kill-a-drudge demo);
+or the pre-existing "next phase candidates" list below.
+
+**Previously in Phase L.2 (Movement & Collision Conformance).** L.2a slices
+1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
+**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13;
+**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
+2026-05-14. The M1 demo target *"pick up an item"* is met for the
+close-range path β single-click a ground item to select, walk within
+~0.6 m of it, press F, and the item is removed from the world and added
+to the player's inventory. Wire chain: `InteractRequests.BuildPickUp`
+sends `PutItemInContainer (0xF7B1/0x0019)`; ACE despawns the item with
+`GameMessagePickupEvent (0xF74A)` (NOT `0xF747 DeleteObject` β the
+distinction surfaced during visual testing and is fixed by the new
+`PickupEvent.cs` parser routed through the shared `EntityDeleted`
+event). The M1 demo target *"open the inn door"* remains met from B.4b
++ B.4c. Issue #57 (B.4 handler gap) is closed. Issue #58 (door swing
+animation) is closed by B.4c. Issues #61 (linkβcycle boundary flash),
+#62 (PARTSDIAG null-guard), **#63 (server-initiated MoveToObject
+auto-walk not honored β blocks out-of-range pickup / Use)**, and **#64
+(local-player pickup animation does not render)** are filed as
+M1-deferred follow-up.
+
+**B.5 ship handoff:** [`docs/research/2026-05-14-b5-shipped-handoff.md`](docs/research/2026-05-14-b5-shipped-handoff.md)
+β full evidence for the 5 commits across InteractRequests / GameWindow / WorldSession + the bonus `PickupEvent (0xF74A)` wire-handler fix that closes the despawn gap.
+**B.4c ship handoff:** [`docs/research/2026-05-13-b4c-shipped-handoff.md`](docs/research/2026-05-13-b4c-shipped-handoff.md)
+β full evidence for the 4 commits + 2 bonus discoveries (stance-value wrong
+`0x01` vs `0x3D` causing underground doors; linkβcycle boundary flash).
+**B.4b ship handoff:** [`docs/research/2026-05-13-b4b-shipped-handoff.md`](docs/research/2026-05-13-b4b-shipped-handoff.md)
+β full evidence for the 9 commits + 4 bonus discoveries (double-click dead
+code, DoubleClick gate, CollisionExemption, ServerGuidβId translation).
+**L.2g slice 1 ship handoff:** [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.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).
+
+**Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.**
+Three commits land the L.2 "make every bad movement outcome explainable"
+diagnostic foundation. Slice 1 (`ebef820`) adds runtime-toggleable
+`ACDREAM_PROBE_RESOLVE` (one `[resolve]` line per
+`PhysicsEngine.ResolveWithTransition` call) + `ACDREAM_PROBE_CELL` (one
+`[cell-transit]` line per `PlayerMovementController.CellId` change),
+both backed by a new `AcDream.Core.Physics.PhysicsDiagnostics` static
+class and mirrored as DebugPanel checkboxes. Slice 2 (`e0c08bc`) extends
+the `[resolve]` line with `obj=0x...` attribution. Slice 3 (`a068292`)
+populates the previously-stub `CollisionInfo.CollideObjectGuids` /
+`LastCollidedObjectGuid` (declared in `TransitionTypes.cs` but never
+written anywhere) at the per-object iteration in `FindObjCollisions`,
+so the slice-2 promise is now actually delivered. Visual-verified at
+the Holtburg Town doorway: probes captured 140 wall hits attributed to
+`obj=0xA9B47900` (landblock-baked static = the building itself,
+**NOT** a door entity), confirming L.2d sub-direction as **port
+`CBuildingObj` collision + per-cell walkability** rather than door-
+state-toggle. Plus a definitive L.2e finding: player `CellId` tracked
+as bare low byte (`0x00000029`) with no landblock prefix.
+
+**Phase C.1.5b (per-part PES transforms + dat-hydrated entity DefaultScript)
+shipped 2026-05-12.** Closes issue #56. `SetupPartTransforms.Compute(setup)`
+walks `PlacementFrames[Resting]` β `[Default]` β first-available and
+returns one `Matrix4x4` per Setup part; `ParticleHookSink.SpawnFromHook`
+now transforms each `CreateParticleHook.Offset` through
+`partTransforms[PartIndex]` before applying entity rotation, so
+multi-emitter scripts distribute across mesh parts instead of collapsing
+to entity root. The `EntityScriptActivator.OnCreate` `ServerGuid==0`
+guard was relaxed: it now keys by `entity.ServerGuid` when non-zero, else
+`entity.Id` (the `0x40xxxxxx` interior-entity range is collision-free
+with server guids, so no synthetic-ID scheme is needed). `GpuWorldState`
+fires the activator from 4 new sites β `AddLandblock` +
+`AddEntitiesToExistingLandblock` (FarβNear promotion) for OnCreate,
+`RemoveLandblock` + `RemoveEntitiesFromLandblock` (NearβFar demotion)
+for OnRemove β so dat-hydrated EnvCell statics (inn fireplaces, building
+decorations) and exterior stabs (cottage chimneys) now activate their
+`Setup.DefaultScript` automatically. **Reality discovery during design
+(folded into spec Β§3):** EnvCell `StaticObjects` are already hydrated as
+`WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming`
+with stable `entity.Id` in `0x40xxxxxx` β the handoff's Β§4 Q1/Q2
+(synthetic ID scheme, separate walker class) were mooted by this.
+**Visual-verified 2026-05-12** at Holtburg Town network portal (no
+ground-burial, distributed swirl), Inn fireplace flames, cottage
+chimney smoke, and a spell cast on `+Acdream`. Plan archived at
+[`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](docs/superpowers/plans/2026-05-13-phase-c1.5b.md).
+
+**Phase C.1.5a (portal PES wiring) shipped 2026-05-11** (merge `88bda12`).
+Server-spawned `WorldEntity` entities fire their `Setup.DefaultScript`
+through `PhysicsScriptRunner` on enter-world via the
+`EntityScriptActivator` ([src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs](src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs)).
+Visual-verified at the Holtburg Town network portal: 10-hook portal
+script fires end-to-end with correct color, persistence, orientation,
+multi-emitter dispatch. Filed #56 for per-part transform handling
+(resolved in C.1.5b above). Plan archived at
+[`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
+
+**Phase N.6 slice 1 (gpu_us fix + radius=12 perf baseline) shipped
+2026-05-11** (merge `9b447d4`). Fixed `gpu_us` double-buffering in
+`WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite,
+vendor-neutral). Captured authoritative perf baseline at Holtburg radii
+4 / 8 / 12. **Conclusion: CPU dominates GPU by 30β50Γ at every radius**;
+GPU sits at 3.6% of frame budget; per-LB walk is the next bottleneck.
+Baseline-doc recommendation: do C.1.5 next, then a reduced-scope slice 2
+(atlas + persistent-mapped buffers dropped from slice-2 scope). Baseline
+at [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](docs/plans/2026-05-11-phase-n6-perf-baseline.md).
+Plan archived at [`docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`](docs/superpowers/plans/2026-05-11-phase-n6-slice1.md).
+Issue #55 filed (static-entity slow path reports ~1.45M `meshMissing`
+per 5s at r4 standstill β diagnostic, not a visible regression).
+
+**Post-A.5 polish phase complete 2026-05-11.** All three post-A.5
+issues closed: #52 (lifestone, `e40159f`), #54 (JobKind, `bf31e59`),
+#53 (Tier 1 entity cache, `f928e66`). Phase A.5 + post-A.5 polish
+together comprise the streaming + rendering perf foundation for the
+project.
+
+**Next phase candidates (in rough preference order):**
+- **"Click an NPC" verification spike (M1 critical path).** B.4b's
+ `WorldPicker` + `BuildUse` is already wired. The question is whether ACE
+ NPCs respond to a Use message from our testaccount and what they broadcast
+ back (TalkDirect? MoveToObject?). Spike: stand near a Holtburg NPC,
+ double-click, read what ACE sends back. If ACE responds with recognizable
+ packets, wire the handlers; if it is silent, investigate ACE's NPC handler
+ configuration. ~30 min spike, outcome determines whether NPC interaction
+ needs a full phase or is a one-commit fix.
+- **Phase B.6 β Client-side MoveToObject auto-walk handling (closes #63).**
+ ACE auto-walks the player to out-of-range Use / Pickup targets via
+ `CreateMoveToChain` + `EnqueueBroadcastMotion(MoveToObject)`, but our client
+ doesn't honor the inbound motion broadcast β character drifts toward the
+ target and snaps back, ACE's chain times out. Reference implementation
+ exists in `references/holtburger/crates/holtburger-core/src/client/simulation.rs`
+ (the `approximate_move_to_object_projection_target` + `MoveToObject` case).
+ Unlocks double-click pickup, F-key pickup from any distance, Use on
+ out-of-range NPCs / corpses. Probably 1-2 commits + visual verification.
+- **Triage the chronic open-issue list** in `docs/ISSUES.md` β #2 (lightning),
+ #4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
+ coat), #41 (remote-motion blips) have been open since April/early-May and
+ keep getting deferred. Either link each to a future phase or downgrade.
+ ~1 hour, surfaces what's chronic vs. linked-to-a-phase.
+- **More Phase C visual-fidelity work** (C.2 dynamic point lights, C.3
+ palette tuning, C.4 double-sided translucent polys) closing the
+ "world reads as old / broken vs. retail" backlog.
+- **N.6 slice 2** at reduced scope (atlas opportunities only β persistent-
+ mapped buffers and other slice-2 items dropped per slice-1 baseline doc).
+- **Perf tiers 2/3** (`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`)
+ only if user wants sustained 500+ FPS. With Tier 1 dispatcher at ~1.2 ms
+ the project comfortably hits 200-400 FPS at radius=12 standstill;
+ escalation is optional from here.
+- **Issue #61 β AnimationSequencer linkβcycle boundary flash** (M1-deferred
+ polish). Brief flap at end of door-swing animations. Low severity; does
+ not block M1 demo. Address before milestone demo record if distracting.
+- **Issue #62 β PARTSDIAG null-guard** (trivial latent fix). One-line
+ null-coalescing guard in `GameWindow.TickAnimations`. Address any time a
+ diagnostic-related PR is open nearby.
+
+**Earlier rendering + streaming arc (2026-05-08 β 2026-05-10).**
+Phases **N.4 β N.5 β N.5b β A.5** shipped the modern rendering
+pipeline + two-tier streaming foundation: WB `ObjectMeshManager` as
+production mesh path (N.4); bindless + `glMultiDrawElementsIndirect`
+for entities (N.5, ~12-15 GL calls/frame) and terrain via Path C
+preserving retail's `FSplitNESW` formula (N.5b, closes #51); two-tier
+streaming Nβ=4 / Nβ=12 + QualityPreset system (A.5). Modern path is
+mandatory as of N.5 ship amendment β `InstancedMeshRenderer`,
+`StaticMeshRenderer`, `WbFoundationFlag` all deleted; missing bindless
+throws at startup. Detail + decomp anchors + plan archives in roadmap
+shipped-table rows 63β66 at `docs/plans/2026-04-11-roadmap.md`.
+Engineering gotchas (bindless Dispose order, texture target lock-in,
+`uvec2` sampler-handle pattern, WB-vs-retail formula divergence)
+documented inline at the relevant call sites and in
+`feedback_wb_migration_*.md` memory entries.
+
**Rules:**
1. Before starting a new phase or sub-piece, re-read the roadmap and the
@@ -512,20 +1054,50 @@ for the window to close.
### Logout-before-reconnect
-**ACE keeps your last session alive briefly after a disconnect.** If you
-relaunch the client within a few seconds of the last close, the handshake
-fails with `live: session failed: CharacterList not received` and the
-process exits with code 29. Wait ~3β5 seconds between launches, or explicitly
-kill stale processes:
+**ACE keeps your last session alive after a disconnect, and the duration
+depends on HOW the client exited.** Two cases:
+
+1. **Graceful close (client sent logout packet to ACE):** session clears
+ in ~3β5 seconds. Wait briefly between launches.
+2. **Hard kill (Stop-Process, crash, force-close):** no logout packet
+ reached ACE. ACE keeps the session marked logged-in until its own
+ timeout β observed in practice at ~3+ minutes. Subsequent relaunches
+ fail with `live: session failed: CharacterList not received` (exit 29)
+ the entire time. **There is no admin command available to us to kick
+ the stale session.** Either wait it out, or use the graceful path
+ below.
+
+**Prefer the graceful close path when ending a launch.** PowerShell's
+`Stop-Process` is a hard kill β it bypasses the client's shutdown hook
+which is where the logout packet would have been sent. The graceful
+alternative sends WM_CLOSE so the window's close handler runs:
```powershell
-Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
+$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
+if ($proc) {
+ $proc.CloseMainWindow() | Out-Null
+ if (-not $proc.WaitForExit(5000)) {
+ # Fell through to hard-kill β session WILL be stuck on ACE.
+ $proc | Stop-Process -Force
+ }
+}
Start-Sleep -Seconds 3
# ... then launch ...
```
-The user has repeatedly confirmed this β don't treat exit-29-after-rapid-relaunch
-as a code bug. It's a server-side session-cleanup delay.
+If `WaitForExit(5000)` returns false (the client didn't exit in 5 seconds
+after WM_CLOSE), the client is unresponsive and a hard kill is the only
+option β accept that ACE will be unhappy for a few minutes.
+
+**When recovering from a hard-killed session that ACE still considers
+active:** the only honest answer is to wait. Don't bother retrying every
+30 seconds β make a single retry attempt ~3 minutes after the kill, and
+if it still fails wait another 2 minutes before trying again. The user
+will likely volunteer when ACE has cleared the session if you ask.
+
+The user has repeatedly confirmed this β don't treat exit-29-after-relaunch
+as a code bug. It's a server-side session-cleanup delay whose duration is
+governed by whether the previous shutdown was graceful or forced.
### Test character
@@ -552,6 +1124,18 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
+- `ACDREAM_PROBE_RESOLVE=1` β L.2a slice 1+2+3 (2026-05-12). One
+ `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call:
+ input + target + output position/cell, ok-vs-partial, grounded-in,
+ contact-plane status, wall normal if hit, **responsible entity
+ guid** (post-slice-3 attribution plumbing), env flag, walkable
+ polygon valid. Heavy (~30 Hz Γ every entity). Runtime-toggleable
+ via the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
+- `ACDREAM_PROBE_CELL=1` β L.2a slice 1 (2026-05-12). One
+ `[cell-transit]` line per `PlayerMovementController.CellId`
+ change: old β new cell, world position, reason tag
+ (`resolver` / `teleport`). Low volume β only fires on actual cell
+ crossings. Runtime-toggleable via the same DebugPanel section.
- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an
env-var gate on an experimental per-tick remote motion path. L.3 M2
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +
@@ -608,13 +1192,19 @@ already-running ACE session via the handshake race.
stop transitions, and keep their visual position tracked smoothly
between the 5β10 Hz UpdatePosition bursts (dead-reckoning).
-## Reference repos: check ALL FOUR, not just one
+## Reference repos: cross-check the relevant ones
-When researching a protocol detail, dat format, rendering algorithm, or
-any "how does AC do X" question, **check all four of the vendored
-references in `references/`** before committing to an approach. Do not
-settle on the first hit and move on β cross-reference at least two of
-these, ideally all four:
+The `references/` tree holds **six** vendored projects (ACE, ACViewer,
+WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in
+some areas and disagree in others. Before committing to an approach,
+**cross-reference at least two of them** for the domain you're working
+in β the per-domain hierarchy in the next section tells you which to
+read first. A single reference can be misleading; the intersection of
+the relevant references is almost always the truth. The user has
+repeatedly had to remind me about this when I narrowly searched one ref
+and missed obvious answers in another.
+
+The six references:
- **`references/ACE/`** β ACEmulator server. Authority on the wire
protocol (packet framing, ISAAC, game message opcodes, serialization
@@ -625,11 +1215,22 @@ these, ideally all four:
for the palette-indexed formats. See
`ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical
subpalette overlay algorithm.
-- **`references/WorldBuilder/`** β C# + Silk.NET dat editor. Exact-stack
- match to acdream for rendering approaches: terrain blending, texture
- atlases, shader patterns. Most useful for "how do I do this GL thing
- with Silk.NET on net10 idiomatically?" Less useful for protocol or
- character appearance (dat editor, not game client).
+- **`references/WorldBuilder/`** β **acdream's rendering + dat-handling
+ base.** WorldBuilder is not just a reference: as of Phase N.4 (shipped
+ 2026-05-08), `ObjectMeshManager` is the production mesh pipeline,
+ `WbMeshAdapter` is the seam, and `WbDrawDispatcher` is the production
+ draw path. The modern path (`N.5`) is **mandatory** β missing bindless
+ throws at startup, there is no legacy fallback. **Before re-porting
+ any rendering or dat-handling algorithm from retail decomp, read
+ `docs/architecture/worldbuilder-inventory.md` first.** The inventory
+ tells you what WB covers (terrain, scenery, static objects, EnvCells,
+ portals, sky, particles, texture decoding, mesh extraction,
+ visibility/culling) and what we still write ourselves (the π΄ list:
+ network, physics, animation, movement, UI, plugin, audio, chat).
+ WorldBuilder is MIT-licensed and exact-stack with us (Silk.NET +
+ .NET); the divergences we've documented (e.g. WB's terrain split
+ formula vs retail's `FSplitNESW`) are called out in the inventory
+ doc.
- **`references/Chorizite.ACProtocol/`** β clean-room C# protocol
library generated from a protocol XML description. Useful sanity check
on field order, packed-dword conventions, type-prefix handling. The
@@ -684,12 +1285,15 @@ decompiled client code and would have fixed it in minutes.
| Domain | Primary Oracle | Secondary | Notes |
|--------|---------------|-----------|-------|
-| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." |
-| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** β decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. |
-| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. |
-| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** β includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. |
-| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **ACME `TextureHelpers.cs`** | ACViewer `Render/TextureCache.cs` (palette overlay = `IndexToColor`) | For subpalette overlay specifically, ACViewer's `IndexToColor` is the canonical algorithm. |
-| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **ACME `EnvCellManager.cs`** β portal traversal, mixed landblock detection, collision cache | ACViewer `Physics/Common/EnvCell.cs` | ACME is significantly more complete than original WorldBuilder for dungeons. |
+| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." Use for everything in the π΄ list (network, physics, animation, movement, UI, plugin, audio, chat). |
+| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **WorldBuilder `TerrainGeometryGenerator.cs` + `TerrainUtils.cs`** | retail decomp for cross-check | WB is acdream's terrain base. ACME's port is older/SUPERSEDED by WB. |
+| **Terrain blending** (texture atlas, alpha masks, road overlays) | **WorldBuilder `LandSurfaceManager.cs`** | ACME `LandSurfaceManager.cs` (same algo, less complete) | WB is acdream's blending base. |
+| **Scenery** (procedural placement: trees, bushes, rocks, fences) | **WorldBuilder `SceneryRenderManager.cs` + `SceneryHelpers.cs`** | retail decomp `CLandBlock::get_land_scenes` | WB is acdream's scenery base. Re-porting from retail decomp is what caused the edge-vertex bug. |
+| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **WorldBuilder `StaticObjectRenderManager.cs` + `ObjectMeshManager.cs`** | ACME `StaticObjectManager.cs` (includes CreaturePalette, GfxObjRemapping, HiddenParts β useful for character appearance which WB doesn't cover) | WB for static objects, ACME for character appearance. |
+| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **WorldBuilder `TextureHelpers.cs`** | ACME `TextureHelpers.cs`; ACViewer's `IndexToColor` is canonical for subpalette overlay | WB is acdream's decode base. |
+| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. |
+| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. |
+| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | β | WB. |
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. |
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. |
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. |
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 87c7b2d..bae6f7c 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,13 +46,1373 @@ Copy this block when adding a new issue:
# Active issues
-## #49 β Scenery (X, Y) placement drifts from retail at some landblocks
+---
+
+## #87 β Drop WB fork patch by switching to PrepareEnvCellGeomMeshDataAsync
**Status:** OPEN
-**Severity:** MEDIUM (visible misplacement; species-specific or per-cell, not a global offset)
+**Severity:** MEDIUM (band-aid removal; not user-visible)
+**Filed:** 2026-05-19
+**Component:** rendering, WB integration
+
+**Description:** Phase 2 (2026-05-19) shipped a one-line patch in our
+WB fork at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`
+(branch `acdream` on the fork, SHA `34460c4`) to guard a blind
+`TryGet(stab.Id, ...)` call against GfxObj-prefixed ids. That
+patch fixes the symptom (missing floors) but is structurally a
+band-aid β per CLAUDE.md's no-workarounds rule we should retire it.
+
+The proper fix: switch our EnvCell rendering from
+`PrepareMeshDataAsync(envCellId, ...)` (general-purpose entry that
+also iterates static-object parts + emitters we don't need) to WB's
+narrower `PrepareEnvCellGeomMeshDataAsync(geomId, environmentId, cellStructure, surfaces)`
+at [`ObjectMeshManager.cs:386`](../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:386).
+That function only builds the cell room mesh (floor / walls / ceiling),
+which is the only piece we actually use from WB for cells β we already
+hydrate static objects as separate `WorldEntity` instances in
+`BuildInteriorEntitiesForStreaming`, and we run particle scripts via
+our own `EntityScriptActivator` (Phase C.1.5b).
+
+**Root cause / status:** Misuse of WB's general-purpose API for a
+geometry-only need. The general-purpose path triggers static-object
+iteration that has a bug (TryGet without type check) AND that
+does work we throw away. Both problems disappear if we use the
+geometry-only entry point WB already exposes for exactly this purpose
+(it's what WB's own `EnvCellRenderManager` uses internally).
+
+**Trade-offs:**
+
+| | Current (patched WB) | Switch to geom-only API |
+|---|---|---|
+| WB fork divergence | One-line patch | Zero |
+| Future WB upstream merges | Conflicts | Clean |
+| Performance | Slightly worse (wasted iteration) | Slightly better |
+| Risk to other functionality | None (working today) | Needs re-verification |
+
+**Files (the change):**
+
+- `src/AcDream.App/Rendering/GameWindow.cs` around line 5367-5378
+ (cell-entity hydration β change `MeshRefs[0].GfxObjId` from `envCellId`
+ to `envCellId | 0x100000000UL`, the synthetic geom id with bit 32 set).
+- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` β add a new method
+ `PrepareEnvCellGeomMesh(ulong geomId, uint environmentId, ushort cellStructure, List surfaces)`
+ that forwards to `_meshManager.PrepareEnvCellGeomMeshDataAsync(...)`,
+ and call it from the streaming path instead of the bare
+ `IncrementRefCount(envCellId)`.
+- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`
+ β revert the type-check guard we added. The function returns to
+ pristine WB state.
+
+**Acceptance:**
+
+- Floors still render in Holtburg Inn (regression check vs Phase 2).
+- `references/WorldBuilder` submodule pointer returns to upstream-clean
+ (no acdream-specific commits in the fork's `acdream` branch β or
+ rather, the `acdream` branch fast-forwards back to match upstream's
+ state for this file).
+- Probe re-capture at Holtburg confirms `[indoor-upload] completed` for
+ all cells previously failing.
+- No `[wb-error]` lines.
+
+**Research:** [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](research/2026-05-19-indoor-cell-rendering-cause.md)
+documents the underlying WB bug.
+
+---
+
+## Indoor walking issue cluster (2026-05-19)
+
+The Phase 2 indoor cell rendering fix (floor now renders inside buildings)
+surfaced nine pre-existing indoor bugs the user observed at Holtburg Inn
+the moment they could walk indoors. None caused by the floor fix β all
+existed before but were unobservable because there was no floor to stand
+on. Filed individually below; #78 + #84 + #85 + #86 likely share a root
+cause (cell BSP / portal-cull plumbing), and #79 + #80 + #81 + #82 share
+the indoor-lighting plumbing.
+
+---
+
+## #78 β Outdoor stabs/buildings visible through the rendered floor
+
+**Status:** OPEN
+**Severity:** HIGH (immediate visual jank now that floors render)
+**Filed:** 2026-05-19
+**Component:** rendering, visibility
+
+**Description:** Standing inside Holtburg Inn looking at the floor or
+walls, the user sees other buildings in the distance at their correct
+world position + scale β but visible THROUGH the floor and walls. As if
+the cell mesh is rendered but doesn't occlude or stencil-cull what's
+behind it.
+
+**Root cause / status:** Two plausible causes:
+1. The `+0.02f` Z bump applied to cell origin at `GameWindow.cs:5362`
+ pushes the floor mesh 2 cm above terrain, so depth test correctly
+ occludes terrain. But OUTDOOR STABS (landblock-baked building geometry)
+ at the same X,Y may have Z values comparable to or higher than the
+ cell-mesh floor, producing z-fighting / see-through.
+2. Outdoor stabs aren't being culled when the player is inside an
+ EnvCell β this is the Phase 1 Task 3 deferred work
+ ("Cull outdoor stabs when indoors via VisibleCellIds"). WB has a
+ `RenderInsideOut` stencil pipeline (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`)
+ that acdream never invokes.
+
+**Files:**
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (per-entity walk β
+ consider gating outdoor stab entities on visible-cell membership).
+- `src/AcDream.App/Rendering/CellVisibility.cs:222+` (`ComputeVisibility`
+ returns `VisibleCellIds`; the dispatcher already filters by
+ `entity.ParentCellId β visibleCellIds` but outdoor stabs have
+ `ParentCellId == null` so they always pass).
+
+**Acceptance:** Standing inside a sealed-interior cell, no outdoor
+geometry is visible through floor/walls. Standing where a cell has a
+real outdoor portal (door open, window) outdoor geometry is correctly
+visible through the portal.
+
+---
+
+## #79 β Indoor lighting: spurious spot lights on walls
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-05-19
+**Component:** lighting
+
+**Description:** Walking around inside Holtburg Inn, the user sometimes
+sees spot-light-like patches on the interior walls that don't correspond
+to retail's lighting.
+
+**Root cause / status:** Point lights from cell static objects (torch
+entities) are being registered via `LightInfoLoader.Load` + `LightingHookSink`
+(Phase 1 verified). Their per-light parameters (position, range, intensity,
+cone) may be wrong β wrong falloff treatment, wrong world-space transform,
+or wrong direction for spot lights. Spec at
+`docs/research/deepdives/r13-dynamic-lighting.md` documents the retail
+LightInfoβLightSource mapping but the live behavior hasn't been verified
+against retail.
+
+**Files:**
+- `src/AcDream.Core/Lighting/LightInfoLoader.cs`
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` β `accumulateLights`
+ spot-cone logic.
+
+**Acceptance:** Side-by-side comparison with retail at the inn shows
+matching torch-light pools.
+
+---
+
+## #80 β Camera on 2nd floor goes very dark
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-05-19
+**Component:** lighting
+
+**Description:** Walking up to the second floor of a building, the
+lighting suddenly goes much darker than retail.
+
+**Root cause / status:** Possible causes:
+1. The `playerInsideCell` lighting trigger (Phase 1 / commit `1024ba3`)
+ uses `CellVisibility.IsInsideAnyCell(playerPos)` which is a brute-force
+ PointInCell scan. The 2nd floor cell may not be in the loaded set OR
+ may have wrong bounds.
+2. The per-cell ambient is currently a flat `(0.20, 0.20, 0.20)` for
+ any indoor cell. Retail has per-cell ambient overrides; ours doesn't
+ read them. A 2nd-floor cell with stairwell shadowing may need a
+ different value.
+
+**Files:**
+- `src/AcDream.App/Rendering/GameWindow.cs:8330+` (`UpdateSunFromSky`,
+ indoor branch).
+
+**Acceptance:** 2nd-floor cells render with similar brightness to
+ground floor; transition is not abrupt.
+
+---
+
+## #81 β Static building stabs don't react to atmospheric lighting changes
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-05-19
+**Component:** lighting, rendering
+
+**Description:** Outside, time-of-day changes (sunrise/sunset/lightning)
+don't visibly affect static building stabs (the inn / cottages). The
+buildings stay statically lit while terrain and scenery shift colors.
+
+**Root cause / status:** Stabs are rendered through `WbDrawDispatcher`
+with `mesh_modern.frag` which DOES consume the `SceneLightingUbo`
+(sun + ambient + fog). Verify the shader is being used for stabs and
+that the UBO is bound at the right binding slot per draw call.
+Possibly a shader-path divergence β terrain uses `terrain_modern.frag`,
+entities use `mesh_modern.frag`, but stabs/scenery may be on a
+different path.
+
+**Files:**
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+
+**Acceptance:** Stabs darken/brighten in sync with terrain + scenery
+across the day/night cycle.
+
+---
+
+## #82 β Some slope terrain lit incorrectly
+
+**Status:** OPEN
+**Severity:** LOW (cosmetic)
+**Filed:** 2026-05-19
+**Component:** rendering, terrain
+
+**Description:** Specific terrain slopes appear lit "wrong" compared to
+retail.
+
+**Root cause / status:** Likely terrain normal calculation or the
+landblock-edge normal-blending divergence between WB and retail (per
+`feedback_wb_migration_formulas.md` β WB's terrain split formula
+differs from retail's `FSplitNESW`).
+
+**Files:**
+- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
+- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
+
+**Acceptance:** Side-by-side comparison with retail at the same Holtburg
+slopes shows matching shading.
+
+---
+
+## #83 β Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck)
+
+**Status:** OPEN β foundation work landed 2026-05-19, root-cause fix deferred to a follow-up investigation phase
+**Severity:** HIGH (blocks vertical indoor traversal + degrades single-floor cases)
+**Filed:** 2026-05-19
+**Component:** physics, movement, resolver
+
+**Description:** Walking UP stairs in single-floor houses works
+(grounded step-up routes through retail-faithful `BSPQuery.FindWalkableInternal`
+via `StepSphereDown`). Walking DOWN into cellars fails ("ground blocking" β
+can't descend). Walking on 2nd floors works partially but intermittently
+gets stuck in the falling animation. "Phantom collisions" / invisible
+obstacles in rooms persist. The original title "Walking up stairs broken"
+was misleading per user's clarification 2026-05-19.
+
+**Partial fix landed 2026-05-19 (6 commits `ff548b9` β `f845b22`).**
+Foundation work: extended `BSPQuery.FindWalkableInternal` to expose the
+hit polygon's dictionary key id; added thin public wrapper
+`BSPQuery.FindWalkableSphere` over the existing retail-faithful BSP
+walkable-finder (acclient_2013_pseudo_c.txt:326211 / :326793); refactored
+`Transition.TryFindIndoorWalkablePlane` to route through that wrapper
+instead of its Phase-2 linear first-match XY scan; added `[indoor-walkable]`
+runtime-toggleable probe line for diagnostic visibility. 5 new unit tests
++ 1 integration test, 9 pre-existing IndoorWalkablePlane tests updated
+to the new signature.
+
+**Foundation work did NOT fix the user-reported bugs.** Visual verification
+2026-05-19: cellar descent FAIL, 2nd-floor walking FAIL (intermittent
+falling-stuck), single-floor cottage REGRESSED to intermittent falling-stuck
+(was stable before), phantom collisions PERSIST. The probe captured 1443
+MISS / 2 HIT over 1445 indoor-walkable calls β the BSP walker correctly
+rejects the foot-sphere-tangent-to-floor case (sphere center is exactly
+at `floorZ + radius` when grounded, so `PolygonHitsSpherePrecise` fails
+the `|dist| > radius - epsilon` check by ~0.0002).
+
+**Root cause (deeper than originally diagnosed):** `Transition.TryFindIndoorWalkablePlane`
+fundamentally exists as a Phase 2 commit `eb0f772` stop-gap to synthesize
+a ContactPlane every frame when the indoor BSP returns OK. Retail doesn't
+do this β retail RETAINS the previous frame's `ContactPlane` when the
+collision dispatcher says "no collision." There is no retail analog of
+`find_walkable` being called as a standing-still query β retail's
+`find_walkable` only runs inside a downward sphere sweep
+(`step_sphere_down`), where the sphere is moving and the overlap test
+is meaningful. In our `TryFindIndoorWalkablePlane` flow, the sphere is
+tangent (grounded), not moving β the algorithm correctly returns "no
+overlap." The single-floor cottage worked previously because the OLD
+linear scan ignored Z and falsely returned HIT for any XY-overlapping
+walkable; the new BSP-walker correctly identifies "no overlap" and
+falls through to the outdoor terrain backstop, which only happens to
+produce sensible Z for single-floor outdoor-adjacent cases.
+
+**Files in the foundation work:**
+- `src/AcDream.Core/Physics/BSPQuery.cs` β `FindWalkableInternal` signature extension, new `FindWalkableSphere` public wrapper
+- `src/AcDream.Core/Physics/TransitionTypes.cs` β `TryFindIndoorWalkablePlane` refactor, `PointInPolygonXY` deletion, `[indoor-walkable]` probe
+- `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` β 4 new unit tests
+- `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` β new integration test
+- `tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs` β 9 tests updated to new signature
+
+**Next investigation phase (deferred):** Port retail's `ContactPlane` retention
+mechanism so the resolver retains the previous frame's contact plane when
+the BSP says "no collision," instead of re-synthesizing it per frame. The
+proper fix likely eliminates `TryFindIndoorWalkablePlane` entirely. Needs
+deep investigation of retail's `CTransition::transitional_insert` /
+`CPhysicsObj::transition` / `LastKnownContactPlane` interactions. Foundation
+work (BSP walker + probe + tests) remains useful regardless of approach.
+
+**Acceptance:** Walk down stairs into a cellar without getting stuck.
+Walk on a 2nd floor without intermittent falling-stuck. Single-floor
+cottage walking remains stable (no regression).
+
+**Handoff:** [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md).
+
+---
+
+## #84 β [DONE 2026-05-19] Blocked by air indoors
+
+**Status:** DONE
+**Closed:** 2026-05-19
+**Severity:** HIGH (blocks indoor navigation)
+**Filed:** 2026-05-19
+**Component:** physics, collision
+
+**Description:** While walking inside buildings, the player sometimes
+collides with invisible obstacles in mid-floor where there's nothing
+visible.
+
+**Root cause / status:** Cell BSP geometry doesn't align with the
+visible cell mesh. Possibilities:
+1. The `cellTransform` applied to physics in
+ `_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)`
+ at `GameWindow.cs:5384` includes the `+0.02f` Z bump, but the BSP
+ geometry may not be lifted with it β physics geometry sits 2cm BELOW
+ render geometry, so invisible "ceilings" at floor-level cause
+ blockage.
+2. CellStruct BSP contains polygons that the cell mesh doesn't include
+ (or vice versa) β the two are derived from different fields.
+
+**Files:**
+- `src/AcDream.App/Rendering/GameWindow.cs:5362-5384` (cellOrigin Z bump
+ + physics cache call).
+
+**Acceptance:** Walking through interior cell space hits collisions
+only where visible walls/furniture exist.
+
+**Resolution (2026-05-19 partial Β· `c19d6fb`):** Phase D of Cluster A
+extended `ResolveOutdoorCellId` in `PhysicsEngine.cs` with an indoor
+cell-containment scan: when the player's world position falls inside any
+cached EnvCell's AABB, `CellId` is promoted to that indoor cell, which
+enables the `FindEnvCollisions` indoor-BSP branch. This resolved the
+"spawn in building and be stuck above the floor" variant of #84 β
+player's CellId now promotes to the interior cell on spawn-in, the floor
+is walkable, and the player can move freely. The "invisible air obstacle"
+symptom for rooms the player walks INTO from outside was tracked under #87
+and required portal-based cell tracking.
+
+**Resolution (2026-05-19 full Β· `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`):**
+Indoor walking Phase 2 replaced AABB containment with portal-graph cell traversal
+(`CellTransit.FindCellList` + `CheckBuildingTransit`). CellId now promotes to indoor
+cells via portals and remains promoted during normal walking through doorways. Indoor
+cell-BSP collision fires consistently. Indoor walkable plane synthesized from floor
+poly (`TryFindIndoorWalkablePlane`) so the resolver tracks walkability correctly when
+the player is standing on an indoor floor. User visually verified at Holtburg cottage:
+walls block from inside, multi-room navigation works, walking outdoors through a door
+works. Issue fully closed.
+
+---
+
+## #85 β [DONE 2026-05-19 Β· 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Pass through walls from outsideβin
+
+**Status:** DONE
+**Closed:** 2026-05-19
+**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
+**Filed:** 2026-05-19
+**Component:** physics, collision
+
+**Resolution (2026-05-19 Β· Indoor walking Phase 2):** The root cause (CellId never promoted
+to the indoor cell during outdoorβindoor walking) was resolved by portal-graph cell
+traversal in `CellTransit.CheckBuildingTransit`. Once `CellId` promotes to the indoor
+cell, the indoor-BSP collision branch in `FindEnvCollisions` fires for approaches from
+both inside and outside. User visually verified walls block from outside (player must
+use the door portal to enter). See #87 and handoff:
+[`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md).
+
+**Original description:** Approaching a building from the outside, the player
+can walk THROUGH walls into the interior β one-directional wall
+collision. From the inside trying to exit, the wall does block.
+
+The root cause was pinned (Cluster A 2026-05-19) as the same failure as
+#84's remaining symptom β `CellId` wasn't promoted to the indoor cell
+during normal outdoorβindoor walking because AABB containment was too
+tight for threshold/doorway cells. Without CellId in the indoor cell,
+the indoor-BSP collision branch in `FindEnvCollisions` never fired
+regardless of approach direction.
+
+---
+
+## #87 β [DONE 2026-05-19 Β· 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Indoor cell tracking uses AABB containment instead of portal traversal
+
+**Status:** DONE
+**Closed:** 2026-05-19
+**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
+**Filed:** 2026-05-19
+**Component:** physics
+
+**Resolution (2026-05-19 Β· Indoor walking Phase 2):** Portal-graph cell traversal
+(`CellTransit.FindCellList` + `CheckBuildingTransit`) replaced the AABB containment
+shortcut. Player CellId now correctly promotes to indoor cells via portals;
+indoor cell-BSP collision branch fires consistently; walls block from inside.
+Outdoorβindoor entry via `BuildingPhysics` + `BldPortalInfo` (`CheckBuildingTransit`)
+wires the building-shell portal graph. Indoor walkable plane synthesized from the
+cell's floor poly so the resolver tracks walkability during indoor movement (`TryFindIndoorWalkablePlane`).
+See handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md).
+
+**Original description:** `PhysicsDataCache.TryFindContainingCell` promotes the
+player's `CellId` to an indoor EnvCell when their world position falls
+inside any cached cell's local AABB. This is too tight to keep `CellId`
+promoted to an indoor cell during normal walking. Threshold/doorway cells
+(the polys that sit at a room boundary) have AABB Z ranges of only ~0.2 m;
+a standing player at local Z=0.46 m is OUTSIDE the AABB and containment
+fails. Because `CellId` drifts back to the outdoor cell, the indoor-BSP
+collision branch in `TransitionTypes.FindEnvCollisions` is gated out for
+most movement, so walls don't block from inside the house and the floor
+physics is unreliable. The retail fix is portal-based cell traversal β
+when the player crosses a cell portal boundary, the cell ownership
+propagates through portal connectivity data in `CEnvCell`.
+
+---
+
+## #88 β Indoor static objects vibrate (bookshelves, open furnaces)
+
+**Status:** OPEN
+**Severity:** MEDIUM (visual jitter; doesn't block gameplay)
+**Filed:** 2026-05-19
+**Component:** rendering, animation
+
+**Description:** Static objects inside cells (bookshelves, open furnaces, possibly other interior props) show per-frame transform jitter / vibration. Pre-existing (user noticed before Phase 2 shipped). Likely candidates:
+
+1. `EntityScriptActivator.OnCreate/OnRemove` firing repeatedly as the player's CellId promotes/demotes near cell boundaries (less likely after Phase 2's portal-based tracking β but worth investigating).
+2. Per-part transforms for cell-static `WorldEntity` instances getting recomputed each frame with floating-point drift.
+3. Particle-emitter offsets accumulating instead of resetting.
+
+**Files to investigate:**
+- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` β OnCreate/OnRemove call patterns
+- `src/AcDream.App/Rendering/GpuWorldState.cs` β entity transform updates per frame
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` β per-batch transform composition
+
+**Acceptance:** Indoor static objects render stable (no per-frame jitter).
+
+---
+
+## #89 β Port BSPQuery.SphereIntersectsCellBsp for retail-faithful CheckBuildingTransit
+
+**Status:** OPEN
+**Severity:** LOW (Phase 2 ships with a documented approximation)
+**Filed:** 2026-05-19
+**Component:** physics
+
+**Description:** Retail's `CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell` β a radius-aware sphere-vs-BSP test that returns Inside/Crossing/Outside. Phase 2's `CellTransit.CheckBuildingTransit` uses `BSPQuery.PointInsideCellBsp` (radius-less, tests only the sphere CENTER). Practical effect: outdoorβindoor entry fires ~sphereRadius (~0.48m) deeper into the doorway than retail. The sphereRadius parameter is plumbed through but currently unused.
+
+**Files:**
+- `src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit` (line ~162)
+- `src/AcDream.Core/Physics/BSPQuery.cs::PointInsideCellBsp` (line ~940) β existing point test to model the new sphere variant after
+
+**Acceptance:** `CellTransit.CheckBuildingTransit` calls a new `BSPQuery.SphereIntersectsCellBsp(node, sphereCenter, sphereRadius)` that returns `Inside`/`Crossing`/`Outside`. Entry timing matches retail visually at the Holtburg cottage door.
+
+---
+
+
+
+**Status:** DONE
+**Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged)
+**Filed:** 2026-05-16
+
+**Resolution (2026-05-16 Β· `0b25df5`):** Step 2 re-attempted with
+`[step2-diag]` traces at every hypothesized fault point. The traces
+showed all four hypotheses were wrong β `session.hashcode` was identical
+through `_liveSession`, `_liveSessionController.Session`, and the
+captured `liveSession` local in the chat-bus lambda, ruling out
+identity mismatches and closure-capture bugs. Doors verified via
+inbound `OnLiveMotionUpdated` round-trip (cmd=0x000B open, cmd=0x000C
+close). Pickup verified via 4 successful `[B.5] pickup` calls. The
+previous broken run was almost certainly a stale ACE session (no other
+code-level explanation survives the diag trace). One small material
+diff: the chat-bus lambda's `var liveSession = _liveSession;` capture
+became `var liveSession = session;` (the non-null parameter) so the
+compiler can statically prove non-null inside the lambda β both pointed
+to the same `WorldSession` instance, only the static analysis changed.
+
+Traces stripped before commit. Walking-range auto-walk bug observed
+during the second verification run is pre-existing (filed as #77, not
+caused by this refactor).
+
+**Description:** A first attempt at Step 2 β extracting `LiveSessionController`
+
+**Description:** A first attempt at Step 2 β extracting `LiveSessionController`
+out of `GameWindow.cs` β was implemented and reverted in the same session
+on the `claude/hungry-tharp-b4a27b` worktree. Visual verification at
+Holtburg revealed:
+
+- Chat input field accepts text + Enter but nothing is sent (no echo, no
+ ACE response).
+- Double-click on doors / NPCs fires `[B.4b] use guid=... seq=N` outbound
+ (verified in `launch.log`) but no visible client-side effect (door doesn't
+ swing, NPC doesn't dialogue).
+- R + click-target produces `[B.4b] use-deferred guid=... seq=N`, the
+ player auto-walks to the target, but the deferred Use does NOT fire on
+ arrival (regresses the Phase B.6 / issue #63 / #75 work).
+
+The Step 1 (`eda936d` RuntimeOptions) and Rule 5 follow-up
+(`32423c2` DumpSteepRoof β PhysicsDiagnostics) commits are NOT affected
+and stay clean.
+
+**Root cause / status:** Unknown. The refactor preserved every event
+subscription line-for-line (verified by `git diff` β only one `_liveSession.X +=`
+line moved, all others present). The new shape:
+
+```
+TryStartLiveSession()
+ β _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents)
+ β new WorldSession(endpoint)
+ β wireEvents(session) // i.e. WireLiveSessionEvents(session)
+ β Chat.OnSystemMessage("connecting...")
+ β _liveSession.Connect(user, pass)
+ β ...character validation + EnterWorld + post-setup...
+```
+
+Looks identical to the original control flow. Hypotheses to test on a
+clean re-attempt:
+
+1. **Timing of `_liveSession` field assignment.** The new code assigns
+ `_liveSession` inside `WireLiveSessionEvents` before subscriptions
+ run, and again after CreateAndWire returns. The original code set
+ `_liveSession` once at the inline `new WorldSession(...)` site. A
+ subtle ordering bug between subscriptions and `_liveSession`'s
+ externally visible state may matter.
+2. **LiveCommandBus closure capture.** The `var liveSession = _liveSession;`
+ capture inside the chat handler block may have been getting a
+ different value than before β though the field IS set by the time
+ the capture happens (line 1 of `WireLiveSessionEvents`).
+3. **Inbound packet ordering.** ACE may be sending the first
+ StateUpdate / spawn stream BEFORE the EnterWorld dance completes in
+ the new flow; if subscriptions are wired but `_liveSession` field
+ is briefly inconsistent, an early handler call could see a partial
+ state. The `_liveSession?.Tick()` route now goes through
+ `_liveSessionController?.Tick()`; verify that's not the difference.
+4. **Some non-subscription side effect** in `WireLiveSessionEvents`
+ that wasn't carried over correctly β over-indentation suggests a
+ diff-friendly intermediate state; full re-indentation may surface
+ the bug.
+
+**Files (in the reverted state β recover from worktree git reflog or
+re-write):**
+- `src/AcDream.App/Net/LiveSessionController.cs` (new, ~115 LOC)
+- `src/AcDream.App/Rendering/GameWindow.cs` β `TryStartLiveSession` split
+ + new `WireLiveSessionEvents` method
+
+**Research:** No memory entry yet. If the re-attempt succeeds, add a
+`feedback_step2_extraction_pitfalls.md` capturing whichever hypothesis
+turned out to be the bug.
+
+**Acceptance:** Step 2 lands when the full M1 demo loop (walk Holtburg,
+double-click inn door + door swings, double-click NPC + NPC dialogues,
+F-key pickup on a ground item) works identically to the pre-refactor
+behavior, AND chat input echoes back through the panel.
+
+---
+
+## #75 β [DONE 2026-05-16 Β· `f035ea3`] Auto-walk should drive body directly, not synthesize player-input
+
+**Status:** DONE
+**Severity:** LOW (functionally correct via grace-period band-aid; architectural cleanup only)
+**Filed:** 2026-05-16
+**Component:** physics / auto-walk
+
+**Resolution (2026-05-16 Β· `f035ea3`):** Refactored `ApplyAutoWalkOverlay` β `DriveServerAutoWalk`. Auto-walk now steps Yaw, sets `_body.set_local_velocity` from runRate, and calls `_motion.DoMotion(WalkForward, speed)` directly β NO `MovementInput` synthesis. `Update` gates the user-input motion + velocity section on `!autoWalkConsumedMotion` to prevent overwrite. The 500ms arrival grace period (band-aid) deleted. The wire-layer `!IsServerAutoWalking` guard at `GameWindow.cs:6419` retained as a semantic statement (user-MoveToState is for user-driven intent only), not as a band-aid for the synthesis leak that no longer exists. Animation cycle plumbed through via `localAnimCmd` / `localAnimSpeed` for both moving-forward and turn-first phases (issue #69 folded in). Walk/run threshold corrected to 1.0m (overrides ACE's wire-supplied 15.0f; matches user-observed retail behaviour + ACE's own physics layer default). `IsPickupableTarget` now checks `BF_STUCK` (`acclient.h:6435`) to correctly block signs/banners that share Misc ItemType with real pickup items.
+
+**Description:** `ApplyAutoWalkOverlay` in `PlayerMovementController`
+synthesizes `Forward+Run` `MovementInput` during inbound `MoveToObject`
+so the existing motion-interpreter pipeline drives the body. The
+synthesis leaks: motion-interpreter sets `MotionStateChanged=true`,
+which would fire an outbound `MoveToState` "user is running"
+packet to ACE β interpreted as user-took-manual-control and cancels
+ACE's `MoveToChain`. We mitigate with a guard
+(`!_playerController.IsServerAutoWalking` at `GameWindow.cs:6410`)
+plus a 500 ms post-arrival grace period to cover ACE's poll race.
+
+Retail's `MoveToManager::HandleMoveToPosition` (decomp 0x0052xxxx)
+steps the body POSITION directly when server `MoveToObject` arrives β
+NO player-input synthesis, NO motion-interpreter involvement, NO
+outbound MoveToState. Holtburger
+([simulation.rs:178-206](references/holtburger/crates/holtburger-core/src/client/simulation.rs))
+follows the same pattern (sets `ServerControlledProjection`, advances
+the body, returns empty).
+
+**Acceptance:** Refactor auto-walk to step `_body.Position` (or
+equivalent) directly from the wire-supplied path data + run rate, NOT
+via synthesized input. Motion state during auto-walk becomes a
+SERVER-DRIVEN state (similar to how remote players' motion is driven
+by inbound MoveToState packets), not a USER-DRIVEN one. The 500 ms
+grace period in `EndServerAutoWalk` becomes unnecessary and can be
+deleted; same for the `IsServerAutoWalking` guard at the wire layer
+(no MoveToState would have been built in the first place).
+
+Animation cycle currently driven by motion-interpreter's
+`MotionStateChanged β SetCycle(RunForward)` would need a separate
+path: probably mirror how remote-player animation is driven by
+inbound motion packets (the sequencer accepts a `SetCycle` directly).
+
+**Files:** `src/AcDream.App/Input/PlayerMovementController.cs`
+(`ApplyAutoWalkOverlay` returns synthesized input today; refactor to
+step body directly + drive animation via `_animationSequencer.SetCycle`
+directly). `src/AcDream.App/Rendering/GameWindow.cs` (delete the
+`!IsServerAutoWalking` guard once the leak is gone).
+
+**Estimated scope:** Medium (~50-100 LOC + careful testing of
+animation cycle continuity). Not blocking M1 β the grace-period
+band-aid produces retail-faithful behaviour empirically.
+
+---
+
+## #74 β [DONE 2026-05-16 Β· `de44358`] AP cadence is per-frame-while-moving, more chatty than retail
+
+**Status:** DONE
+**Severity:** LOW (works; just sends ~60Γ the packets retail would during smooth motion)
+**Filed:** 2026-05-16
+**Component:** physics / net cadence
+
+**Resolution (2026-05-16 Β· `de44358`):** With #75 (MoveToState suppression refactor) closing the MoveToChain-cancellation race, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`): cell/plane change during the sub-interval; cell-or-frame change after the 1s heartbeat. Added `_lastSentContactPlane` field + extended `NotePositionSent(Vector3, uint, Plane, float)` + added `ApproxPlaneEqual` helper + `PlayerMovementController.ContactPlane` public accessor. Effective rates now match retail: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne.
+
+**Description:** The diff-driven AP cadence shipped in Commit B fires
+`HeartbeatDue` on **any** position change each frame while grounded
+on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
+heartbeat when idle. Retail's `ShouldSendPositionEvent`
+(`acclient_2013_pseudo_c.txt:700233`) only sends during the
+sub-interval when cell or contact-plane changes, and only sends the
+1 Hz heartbeat if `(cellId, frame)` changed since `last_sent` β
+truly idle = 0 Hz. So retail during continuous smooth movement is
+effectively 1 Hz (cell crosses + plane changes don't happen every
+frame); we are ~60 Hz.
+
+**Root cause / status:** Deliberate ACE-targeted choice. The
+per-frame cadence is load-bearing for ACE's `WithinUseRadius` poll
+to see the player arrive at a target during local speculative
+auto-walk (issue #63's workaround chain). Going to 1 Hz would
+re-introduce the arrival-lag bug for far-range Use/PickUp.
+
+**Files:** [PlayerMovementController.cs:1240-1275](src/AcDream.App/Input/PlayerMovementController.cs)
+β the `HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed)`
+gate.
+
+**Acceptance:** Either (a) fix issue #63 so we honor ACE's
+`MoveToObject` server-side, removing the need for the per-frame
+cadence, then revert to retail's `cell-or-plane-change || (interval && frame-change)`
+shape (~5 LOC change); or (b) document this as a permanent
+divergence and update commit messages / code comments to match.
+
+**Estimated scope:** Small (~5 LOC + commit-message rewrite) once
+#63 is fixed. Currently blocked by #63.
+
+---
+
+## #73 β Retail-message centralization plan β per-feature string sweeps
+
+**Status:** OPEN
+**Severity:** LOW (per-feature work, not infrastructure)
+**Filed:** 2026-05-16
+**Component:** ui / retail messages
+
+**Description:** Commit A added `AcDream.Core.Ui.RetailMessages` as
+the home for retail-decomp-sourced UI strings (`CannotBeUsed`,
+`CantBePickedUp`, `CannotPickUpCreatures`). The retail decomp has
+~750 more user-facing strings we'll need over time β combat misses,
+spell fizzles, vendor dialogs, "you do not have enough" etc. Rather
+than bulk-port them once, port per-feature as the feature lands:
+when wiring vendor purchase, sweep vendor strings into
+`RetailMessages.Vendor.*`; when wiring spell-cast feedback, sweep
+`RetailMessages.Spell.*`.
+
+**Status:** No infrastructure work pending. Pattern is established;
+new strings get added to `RetailMessages.cs` with retail anchor
+comments at the call site that triggered the need.
+
+**Files:** [RetailMessages.cs](src/AcDream.Core/Ui/RetailMessages.cs)
+β class-level doc comment already describes the per-feature sweep
+pattern.
+
+**Acceptance:** Each phase / feature that adds new user-facing
+strings sweeps its retail-anchor strings into `RetailMessages` and
+calls them by name rather than literal-in-place. Closing condition:
+"all M1 demo strings are in RetailMessages" or similar per-milestone
+gate, decided when M1 ships.
+
+---
+
+## #72 β Confirm Humanoid TurnRight/TurnLeft `omega.z` base rate via cdb
+
+**Status:** OPEN
+**Severity:** LOW (current Β±Ο/2 fallback matches all corroborating
+evidence; cdb probe would settle the open question for good)
+**Filed:** 2026-05-16
+**Component:** physics / rotation / research
+
+**Description:** Commit A's rotation rate uses
+`BaseTurnRateRadPerSec = Ο/2` based on the documented
+`AnimationSequencer.cs:734-741` claim that the Humanoid motion table
+ships TurnRight/TurnLeft with `HasOmega` cleared (forcing the
+convention fallback). The constant has 3 corroborating sources but
+the actual dat content was never dumped β and the run-multiplier
+`run_turn_factor = 1.5` at retail `0x007c8914` from
+`apply_run_to_command` (decomp 0x00527be0) likewise hasn't been
+verified live.
+
+**Acceptance:** Set a cdb breakpoint on `CSequence::set_omega`
+(`acclient_2013_pseudo_c.txt` β find exact symbol address) while
+holding A or D in a retail client. Capture the `omega.z` argument
+value walking, then running. If `Β±Ο/2` walking and `Β±Ο/2 Γ 1.5 β 2.356`
+running, close as confirmed. If different, file as a regression and
+fix the constants in
+[RemoteMoveToDriver.cs](src/AcDream.Core/Physics/RemoteMoveToDriver.cs).
+
+**Estimated scope:** ~30 min cdb session + 1 commit if confirmed,
+or +small fix if different. Not blocking M1.
+
+---
+
+## #71 β WorldPicker Stage B β polygon refine for retail-accurate clicks
+
+**Status:** OPEN
+**Severity:** LOW (Stage A β screen-rect picker β is sufficient for M1)
+**Filed:** 2026-05-16
+**Component:** selection / picker
+
+**Description:** Retail's mouse picker does two-tier sphere-then-polygon
+selection (`acclient_2013_pseudo_c.txt:0x0054c740`
+`Render::GfxObjUnderSelectionRay`):
+1. Per-part sphere reject via `CGfxObj::drawing_sphere`.
+2. Polygon-accurate refine via `CPolygon::polygon_hits_ray` on every
+ visual polygon; closest-t polygon hit wins over any sphere hit.
+
+Commit B's Stage A
+([WorldPicker.cs](src/AcDream.Core/Selection/WorldPicker.cs)) does
+screen-space rect hit-test against the projected
+`Setup.SelectionSphere` (matching the indicator rect, deliberately
+broader than the visible mesh polygons). Stage B would tighten clicks
+to the visible mesh β under-pick what looks like empty space inside
+the rect, catch visible mesh that pokes past the sphere boundary
+(creature outstretched arm, sign edge).
+
+**Acceptance:** Pipe per-part GfxObj visual polygons through a
+`PickPolygonProvider` interface (don't duplicate mesh decoding β
+hook the existing `ObjectMeshManager` cached data). Two-tier in
+`WorldPicker.Pick`: sphere reject β polygon scan β polygon hit
+dominates sphere hit. Acceptance test: visible-mesh accuracy on
+Holtburg sign, Royal Guard outstretched bow arm, inn-door wood
+frame edges.
+
+**Estimated scope:** Medium (~4-6 hours). Defer until visual
+verification surfaces a Stage A miss in real play. The user
+confirmed 2026-05-16 that "I can click on longer ranges now so
+good" β Stage A is enough for M1's "click an NPC" demo.
+
+---
+
+## #70 β Triangle apex/size β final retail-feel UX pass
+
+**Status:** OPEN
+**Severity:** LOW (cosmetic β indicator already retail-anchored, this is final-feel polish)
+**Filed:** 2026-05-16
+**Component:** ui / target indicator
+
+**Description:** Per 2026-05-16 user feedback during the
+`SelectionSphere` indicator ship, the triangle apex direction
+(flipped to point inward at the target) and sprite size (currently
+8 px legs) are heuristic visual choices. Retail uses an actual DAT
+sprite from `UIRegion::GetChild(0x1000003a/3b/3c)` β the bitmap
+shape and size come from the dat, not constants.
+
+**Acceptance:** Extract the retail triangle sprite from the dat
+(probably via `tools/UiLayoutMockup` or a new `DatSpriteProbe`) and
+either (a) blit the exact bitmap, or (b) pick a procedural size +
+shape that matches it pixel-for-pixel at standard zoom.
+
+**Files:** [TargetIndicatorPanel.cs](src/AcDream.App/UI/TargetIndicatorPanel.cs)
+β `TriangleSize` constant + the four `AddTriangleFilled` calls.
+
+**Estimated scope:** Small (~1-2 hours, mostly dat exploration).
+Not blocking M1.
+
+---
+
+## #69 β [DONE 2026-05-16 Β· `f035ea3`] Local player rotation isn't animated (no leg/arm cycle while pivoting)
+
+**Status:** DONE
+**Severity:** LOW (visual polish β rotation works, just looks stiff)
+**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
+**Component:** motion / animation cycle
+
+**Resolution (2026-05-16 Β· `f035ea3`):** Fixed as part of the auto-walk architectural refactor (issue #75). `DriveServerAutoWalk` now records the per-frame rotation direction in `_autoWalkTurnDirectionThisFrame` (+1 / -1 / 0); the animation override at the bottom of `Update` reads that flag and sets `localAnimCmd` to `TurnLeft` / `TurnRight` during the turn-first phase. User confirmed 2026-05-16 that the auto-walk turn-first case (click target, body rotates before walking) now plays the leg-shuffle animation. User-driven A/D rotation was always working β the original issue description was specific to the auto-walk turn-first case.
+
+**Description:** When the auto-walk overlay rotates the local player
+(close-range Use turn-to-face, or turn-first phase of a far-range walk),
+the body's Yaw rotates smoothly but no leg / arm animation plays β
+the body just statue-pivots. Retail played a `TurnLeft` / `TurnRight`
+motion cycle while rotating, visible to observers as the character
+moving their legs / arms to turn.
+
+**Cause:** `ApplyAutoWalkOverlay` synthesises `Forward+Run` input
+during the walking phase (so the motion interpreter emits `RunForward`
+cycle commands), but synthesises nothing during the turn-only phase
+β so the motion interpreter emits no command and the sequencer
+holds whatever cycle was last set (typically Ready / idle).
+
+**Approach:** While turning (`!walkAligned`), synthesise
+`TurnLeft = delta > 0` / `TurnRight = delta < 0` so the motion
+interpreter emits the turn command. Care needed: the existing
+`Update` body also steps Yaw on `TurnLeft`/`TurnRight` input β if
+both apply, the body rotates twice as fast. Cleanest: set the input
+flags AND skip the overlay's own Yaw step (let Update's existing
+handling do the rotation).
+
+**Acceptance:** A retail observer watching `+Acdream` turn to face
+an NPC sees the turning animation play (leg shuffle / arm swing) for
+the duration of the rotation.
+
+**Estimated scope:** Small. ~30 LOC in `ApplyAutoWalkOverlay` plus
+verification that retail's `TurnLeft`/`TurnRight` cycle is in the
+human motion table.
+
+---
+
+## #68 β Remote players don't stop running animation on auto-walk arrival
+
+**Status:** OPEN
+**Severity:** LOW-MEDIUM (visual only β server-side action completes correctly)
+**Filed:** 2026-05-15 (B.7 visual verification)
+**Component:** motion / remote dead-reckoning / animation cycle
+
+**Description:** Observing a retail player from acdream as they approach
+an NPC at a distance: the remote body's run animation keeps cycling
+even after the body has visibly stopped at the NPC. Retail-side the
+character stopped; the action (dialogue) fired; but our client's
+animation never transitioned RunForward β Ready.
+
+**Suspected:** `RemoteMoveToDriver` detects arrival via
+`DriveResult.Arrived`, but the consumer site (per-tick loop in
+`GameWindow.TickAnimations` or wherever the remote body's cycle is
+driven) doesn't flip the animation cycle back to Ready on arrival.
+Alternatively the cycle persists because ACE doesn't broadcast a
+follow-up `UpdateMotion(Ready)` β relying on the client to detect
+arrival from the wire's distance threshold instead.
+
+**Files (likely):**
+- `src/AcDream.App/Rendering/GameWindow.cs` β wherever per-tick motion
+ for remote entities reads `RemoteMoveToDriver`'s state. Need to
+ call `SetCycle(NonCombat, Ready)` on arrival.
+
+**Acceptance:** Retail player observed running up to an NPC visibly
+stops running animation at arrival distance, transitions to idle.
+
+---
+
+## #67 β [DONE 2026-05-15 Β· `301281d`] Door Use action doesn't complete after auto-walk arrival
+
+**Status:** DONE β fixed by `301281d` (10 Hz heartbeat during motion).
+With ACE seeing our position in near-real-time, its `CreateMoveToChain`
+converges normally for doors as well as NPCs. Root cause was 1 Hz
+position sync on our side, not anything door-specific. User confirmed
+doors work after the heartbeat bump.
+
+---
+
+## #66 β Local + remote rotation: player flips back, NPCs don't turn
+
+**Status:** OPEN
+**Severity:** LOW-MEDIUM (visual feedback β interaction works,
+just looks wrong)
+**Filed:** 2026-05-15 (B.7 visual verification)
+**Component:** motion / rotation
+
+**Description:** Two related visual rotation bugs surfaced together:
+
+1. **Local player flips back.** Observing acdream's `+Acdream` from
+ retail: when our auto-walk completes and the body has rotated to
+ face the target, the broadcast position has the new rotation β
+ then the next frame the player snaps back to whatever the camera
+ yaw was. Likely cause: after `EndServerAutoWalk`, the synthesised
+ input stops and `Update`'s next pass applies the user's real
+ `MouseDeltaX` (which may be 0 but other paths might be
+ overriding `Yaw`).
+2. **NPCs don't turn to face the player.** ACE broadcasts
+ `MovementType=8 TurnToObject` when an NPC starts a Use response
+ that requires facing. Our `OnLiveMotionUpdated` handles
+ MovementType=6 (MoveToObject) but not 8. The NPC's body stays
+ at whatever heading the spawn / last motion left it.
+
+**Acceptance:**
+- After auto-walk arrival, local player's facing toward the target
+ is preserved (no flip-back observed from a retail client).
+- NPCs (Tirenia, guards, vendors) rotate to face the player when
+ using them.
+
+**Files (likely):**
+- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` β extend parser
+ for MovementType=8 payload (target guid + final-heading flag).
+- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
+ β route MovementType=8 for the local player to a new
+ `BeginServerTurnToObject` controller method; route for remote
+ guids into the remote-dead-reckon state (extending
+ `RemoteMoveToDriver` or adding a sibling driver).
+- `src/AcDream.App/Input/PlayerMovementController.cs` β add the
+ turn driver that holds Yaw against user-input overrides until
+ aligned.
+
+**Replaces / supersedes:** #65 (local-player turn-to-face on
+close-range Use). This issue covers both directions and is the
+broader retail-faithful rotation handling phase.
+
+**Estimated scope:** Medium β ~80β120 LOC + tests.
+
+---
+
+## #65 β Local player doesn't turn to face target on close-range Use
+
+**Status:** OPEN
+**Severity:** LOW (functional β Use still completes β but visually awkward)
+**Filed:** 2026-05-15 (B.6/B.7 visual verification)
+**Component:** physics / movement / inbound MoveTo handling
+
+**Description:** When the local player has a target selected and is
+already within ACE's `WithinUseRadius` (close-range branch in
+`CreateMoveToChain` at `Player_Move.cs:66`), ACE skips the auto-walk
+chain and just calls `Rotate(target)` server-side. The Use action
+completes, but the local player's body doesn't visibly turn to face
+the target β the character stays at whatever heading the user was
+looking when they clicked.
+
+**User-visible:** Stand behind an NPC, click them, press R. Dialogue
+appears, but the character keeps facing away from the NPC. In retail
+the character would have turned to face the NPC before / during the
+Use.
+
+**Root cause:** ACE's close-range path sends a `TurnTo` motion
+(MovementType=8 TurnToObject, decomp `0x005241b3` switch case 8).
+Our `OnLiveMotionUpdated` doesn't currently handle MovementType=8 β
+it falls into the locomotion path and ignores the rotation.
+
+**Acceptance:** When the user uses an in-range target while facing
+away, the character rotates to face the target before / as the Use
+action fires. No regression on close-range pickup (item still picks
+up cleanly).
+
+**Files (likely):**
+- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` β extend parser for MovementType=8 TurnToObject payload.
+- `src/AcDream.App/Input/PlayerMovementController.cs` β add a `BeginServerTurnToObject(targetWorld, useFinalHeading)` method that rotates Yaw at TurnRateRadPerSec each frame until aligned, then clears the state.
+- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated` β when inbound motion is MovementType=8 and the guid is `_playerServerGuid`, install the turn on the controller.
+
+**Estimated scope:** Small β ~50 LOC plus tests. Pairs naturally with
+B.6 (already does turn-then-walk for far targets via RemoteMoveToDriver's
+heading correction; this is the close-range cousin).
+
+---
+
+## #64 β Local-player pickup animation does not render
+
+**Status:** OPEN
+**Severity:** LOW (visual feedback only β pickup completes correctly)
+**Filed:** 2026-05-14 (B.5 visual verification)
+**Component:** motion / animation routing for local player
+
+**Description:** When `+Acdream` picks up an item (B.5 close-range
+path), retail observers see the character play the pickup animation
+correctly, but the local view shows no pickup animation. The item
+despawns, the inventory updates, but the character's own
+bend-down-and-grab animation is missing.
+
+**Root cause / hypothesis:** ACE broadcasts `Motion(MotionCommand.Pickup)`
+via `Player_Inventory.AddPickupChainToMoveToChain` (line 711β713,
+`EnqueueBroadcastMotion(motion)`), which arrives as a normal
+`UpdateMotion (0xF74D)` packet. Retail observers route it through
+their remote-creature animation pipeline and render the pickup. For
+the local player, our `OnLiveMotionUpdated` likely filters self-echoes
+(local player drives its own motion via prediction, not server
+echoes) and drops the pickup motion. The pickup is a one-shot
+animation initiated by the server, so the prediction path has no
+trigger β and the echo path is filtered.
+
+**Acceptance:** When `+Acdream` picks up an item, the local view shows
+the same pickup animation retail observers see. Probably resolved by
+either (a) admitting server-initiated one-shot motions through the
+local-player motion filter, or (b) generating the pickup animation
+locally on send (mirroring retail's client behavior).
+
+**Files:** `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
+(motion routing); the self-echo filter is somewhere along this path.
+
+**Estimated scope:** Small-to-medium. Mostly investigation +
+1β2 commits.
+
+---
+
+## #63 β [DONE 2026-05-16 Β· `f035ea3`] Server-initiated auto-walk (MoveToObject) not honored
+
+**Status:** DONE
+**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
+works fine)
+**Filed:** 2026-05-14 (B.5 visual verification)
+**Component:** motion / inbound MoveToObject handling
+
+**Resolution (2026-05-16):** Closed in two parts:
+1. **B.6 slice 2 (2026-05-14):** inbound MoveToObject parsing + `BeginServerAutoWalk` wiring at `GameWindow.cs:3389` β body auto-walks toward the server-supplied destination.
+2. **B.6 #75 refactor (`f035ea3`, 2026-05-16):** `ApplyAutoWalkOverlay β DriveServerAutoWalk` drives the body directly from path data, no input synthesis. The `MoveToState` leak that previously cancelled ACE's `MoveToChain` callback is gone; the chain runs uninterrupted and `TryUseItem` / `TryPickUp` fires server-side on arrival. No client-side retry needed. Walk/run threshold corrected to 1.0m (matches retail-observed; overrides ACE's wire-default 15m).
+
+Visual-verified end-to-end: far-range Use on NPCs / doors / spell components / corpses all complete via ACE's server-side callback. The far-range retry workaround from Task 1's first iteration (`c61d049`'s `_pendingPostArrivalAction` arming) was deleted as part of #75 (`f035ea3`).
+
+**Description:** When the player triggers a Use or PutItemInContainer
+on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
+runs server-side auto-walk via `CreateMoveToChain` β
+`PhysicsObj.MoveToObject` + `EnqueueBroadcastMotion(Motion(MoveToObject, target))`.
+Our client receives the `UpdateMotion(MoveToObject)` broadcast for
+the player but doesn't honor it: the character either visually
+drifts a bit toward the target and snaps back, or just stands still.
+ACE's MoveToChain then times out, the `success: false` path
+broadcasts `InventoryServerSaveFailed (ActionCancelled)`, and the
+pickup/use never completes.
+
+**User-visible symptom:** Double-click a ground item from any
+distance, or F-key it from > 0.6 m: character partially walks toward
+the item, then flips back to original position. No pickup.
+
+**Reference:** [holtburger simulation.rs:33β41 + 178β191](references/holtburger/crates/holtburger-core/src/client/simulation.rs)
+already implements client-side `MoveToObject` motion projection +
+auto-walk handling. That's the shape of the fix.
+
+**Root cause:** Our `OnLiveMotionUpdated` has no handler for the
+`MoveToObject` motion type; the broadcast is silently dropped.
+
+**Acceptance:** Double-click a ground item from 2β5 m away. Character
+auto-walks to within use radius, ACE's MoveToChain confirms success,
+pickup completes (including the existing PickupEvent despawn). Same
+behavior for Use on out-of-range NPCs.
+
+**Files:** `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
+(routing); likely a new `MoveToObjectMotion` handler in the motion /
+prediction layer + a server-acked position-update echo so ACE sees the
+player has reached the target.
+
+**Estimated scope:** Medium. Probably its own phase (B.6 or similar);
+not a one-commit fix. Compose from holtburger's pattern.
+
+---
+
+## #62 β [DONE 2026-05-14 Β· `ec9fd52`] PARTSDIAG null-guard for sequencer-driven entities
+
+**Status:** DONE
+**Severity:** LOW (latent crash; not reachable for doors today β see notes)
+**Filed:** 2026-05-13 (code-quality review of B.4c Task 1)
+**Component:** diagnostic / `GameWindow.TickAnimations` PARTSDIAG block
+
+**Description:** The PARTSDIAG block at `GameWindow.cs:7657` reads
+`ae.Animation.PartFrames.Count` without a null-guard. B.4c introduced
+`Animation = null!` for sequencer-driven door entities (per the same
+pattern at line 7857). Today this is safe: doors never enter
+`_remoteDeadReckon` (ACE never sends UpdatePosition for them), and
+`_remoteDeadReckon` membership is one of the outer guards on the
+PARTSDIAG block. The diagnostic never fires for doors.
+
+**Risk:** Future code that admits more non-creature entities via the
+B.4c branch β or extends ACE to send UpdatePosition for doors β would
+make `_remoteDeadReckon` membership reachable for null-Animation
+entities. The next time someone enables `ACDREAM_REMOTE_VEL_DIAG=1`
+and that scenario occurs, the diagnostic crashes the tick.
+
+**Acceptance:** PARTSDIAG block tolerates null `ae.Animation`. One-line
+fix:
+```csharp
+int animFrame0Parts = ae.Animation?.PartFrames.Count > 0
+ ? ae.Animation.PartFrames[0].Frames.Count
+ : -1;
+```
+
+**Files:** `src/AcDream.App/Rendering/GameWindow.cs:7657` (one-line null-coalescing change).
+
+**Estimated scope:** Trivial. One-line edit + a build verification.
+
+---
+
+## #61 β [DONE 2026-05-18 Β· `9f069e1`] AnimationSequencer linkβcycle boundary flash on one-shot motion (door swing)
+
+**Status:** DONE β fixed by `9f069e1` (also widened scope: same bug
+manifested as the local-player run-stop twitch β user-observed during
+the M2 anim-pass session). Root cause was `BuildBlendedFrame` wrapping
+`nextIdx` to `rangeLo` unconditionally at the high-frame boundary β
+correct for looping cycles (idle/run/walk loops), wrong for one-shot
+links. During the ~30 ms fractional tail of any link, the renderer
+blended `frame[end]` with `frame[0]`, producing the flash through the
+anim's starting pose. Fix: gate the wrap on `curr.IsLooping`. Pinned by
+the new `Advance_LinkTailDoesNotBlendIntoLinkFrame0` regression test.
+Visual-verified by the user end-to-end on 2026-05-18.
+
+**Severity:** LOW (visual polish β animation works, brief one-frame flash through prior pose at end of swing)
+**Filed:** 2026-05-13 (visual test of B.4c)
+**Component:** animation / `AcDream.Core.Physics.AnimationSequencer` link+cycle transition
+
+**Description:** When a door receives `UpdateMotion(NonCombat, On)` via the
+B.4c spawn-time-registered sequencer, the swing-open animation plays
+correctly but exhibits a brief one-frame flash through the closed pose
+at the END of the swing before settling at the open pose. Same flash on
+close (settles at closed pose after one-frame flash through open).
+
+**Root cause hypothesis:** `AnimationSequencer.SetCycle` enqueues a
+transition link (the swing motion) followed by the target cycle (likely
+a single-frame static rest pose). If the link's last frame and the
+cycle's frame 0 don't match exactly, the renderer reads one frame of
+the cycle's start pose before the cycle's natural rest. Cumulative
+effect: link plays ClosedβOpen over N frames β cycle's frame 0 is
+Closed β cycle resets to frame 0 for one render β cycle advances to
+its single rest frame which IS the open pose. Visible as a flap.
+
+**Acceptance:** Door open / close cycles play cleanly with no closed/open
+pose flash at the linkβcycle transition. Test: in Holtburg, double-click
+inn door, watch swing animation rest at open pose with no intermediate flash.
+
+**Files (likely):**
+- `src/AcDream.Core/Physics/AnimationSequencer.cs` β link+cycle queue boundary handling
+- (read the link node's last-frame extraction + the cycle's frame-0 evaluation)
+
+**Estimated scope:** Moderate. Requires understanding the sequencer's link-vs-cycle queue semantics and possibly the underlying MotionTable's cycle data shape for doors. Could be a one-line fix (e.g. "preserve last link frame as cycle rest pose") or a deeper sequencer behavior change.
+
+**Workaround:** None needed for M1 β the flash is brief enough that doors are usable.
+
+---
+
+## #60 β `obstruction_ethereal` retail downstream path not ported (M2 combat-HUD impact)
+
+**Status:** OPEN
+**Severity:** LOW for M1 (no observable defect); MEDIUM for M2 (combat contact reporting on ethereal creatures will be wrong)
+**Filed:** 2026-05-13 (final-review surfaced from B.4b)
+**Component:** physics / `CollisionExemption.ShouldSkip` + downstream movement contact handling
+
+**Description:** B.4b's L.2g slice 1b widened `CollisionExemption.ShouldSkip` to exempt
+on `ETHEREAL_PS` alone (cite `src/AcDream.Core/Physics/CollisionExemption.cs:62-79`). Retail's
+`acclient_2013_pseudo_c.txt:276782` requires both `ETHEREAL_PS && IGNORE_COLLISIONS_PS` to wrap
+the entire `FindObjCollisions` body β ETHEREAL alone takes the deeper path at line 276795 which
+sets `sphere_path.obstruction_ethereal = 1` and lets downstream movement allow passage WHILE
+STILL REPORTING THE CONTACT. We do not port that downstream path; we just exempt entirely.
+
+**M2 impact:** Combat HUD work that relies on physics-contact reporting for ethereal creatures
+(ghosts, partially-phased monsters, spell projectiles with ETHEREAL set) will see no contact at
+all instead of "soft contact with obstruction_ethereal=1". The user will not be able to target
+or interact with such entities via the contact path.
+
+**Acceptance:** Port the retail deeper path so `obstruction_ethereal=1` flows through movement +
+collision-reporting layers. Tests should cover: ETHEREAL creature target β contact reported but
+passage allowed; ETHEREAL+IGNORE_COLLISIONS target (door, retail-style) β full exempt.
+
+**Estimated scope:** Moderate. Touches `CollisionExemption.cs`, transition/movement layer, and
+sphere-path state propagation. Visible test through a spawned ethereal creature in ACE.
+
+---
+
+## #59 β [DONE 2026-05-15 Β· `5e29773`] `WorldPicker` 5m fixed-radius could over-pick at tight thresholds (M1-deferred polish)
+
+**Status:** OPEN
+**Severity:** LOW (cosmetic β picker grabs the right entity in Holtburg-tested scenarios)
+**Filed:** 2026-05-13 (final-review surfaced from B.4b)
+**Component:** selection / `AcDream.Core.Selection.WorldPicker.Pick`
+
+**Description:** `WorldPicker.Pick` uses a hardcoded 5m sphere around every candidate's
+`Position` regardless of the entity's actual size (`src/AcDream.Core/Selection/WorldPicker.cs:82`).
+This matches `WorldEntity.DefaultAabbRadius` and is sufficient for M1 acceptance: in tight
+doorways, every server-keyed candidate has correct sphere coverage and the closest-wins logic
+plus `ServerGuid==0` skip filter the wrong picks. But the invariant "non-clickable geometry has
+`ServerGuid==0`" is load-bearing β if L.2d ever ports `CBuildingObj` as a server-keyed entity,
+the picker may mis-target buildings. Per-entity `Setup.Radius` would be tighter.
+
+**Acceptance:** Either (a) tighten picker to read per-entity Setup.Radius / CylSphere bounds,
+or (b) document the invariant in `WorldPicker.cs` and add a regression test asserting
+`ServerGuid==0` entities never reach the per-candidate hit test.
+
+**Estimated scope:** Quick (~1 hour) β wire `Setup.Radius` lookup into the picker and update
+the 6 existing picker tests with realistic radii.
+
+---
+
+## #58 β [DONE 2026-05-13] Door swing animation: UpdateMotion not wired for non-creature entities
+
+**Status:** DONE
+**Closed:** 2026-05-13
+**Severity:** MEDIUM (was M1 demo cosmetic β doors functioned but didn't visually animate)
+**Filed:** 2026-05-13
+**Component:** animation / `UpdateMotion (0xF74D)` routing for non-creature entities
+
+**Closure:** Closed by Phase B.4c on branch `claude/phase-b4c-door-anim`
+(4 implementation commits). The complete animation round-trip for door entities
+is now wired and visual-verified at the Holtburg inn doorway: double-click a
+closed door β swing-open animation plays β player walks through β ~30s later
+ACE broadcasts `UpdateMotion (NonCombat, Off)` β swing-close animation plays.
+
+Implementation: spawn-time `AnimationSequencer` registration for door entities
+in `GameWindow.OnLiveEntitySpawnedLocked` (Task 1, commit `9053860`), with
+initial state seeded from `spawn.PhysicsState` so closed doors initialize to
+the `Off` cycle and open doors initialize to the `On` cycle. A `[door-cycle]`
+diagnostic line in `OnLiveMotionUpdated` (Task 2, commit `b89f004`) confirms
+each `UpdateMotion` is processed. A shared `IsDoorName` predicate (Task 2
+review, commit `8a9b15e`) eliminates duplication. A stance-value fix (bonus,
+commit `454d88e`) corrected `NonCombat = 0x3D` (not `0x01`), which was causing
+doors to render halfway underground due to empty sequencer frames.
+
+Two follow-up items were filed: issue #61 (linkβcycle boundary flash β brief
+visual flap at end of swing animation; low severity) and issue #62 (PARTSDIAG
+null-guard for sequencer-driven entities; latent, not currently reachable).
+
+See [`docs/research/2026-05-13-b4c-shipped-handoff.md`](research/2026-05-13-b4c-shipped-handoff.md)
+for the full evidence trail, log output, and bonus-discovery narrative. M1
+demo target "open the inn door" now has full visual feedback.
+
+**Files (what shipped):**
+- `src/AcDream.App/Rendering/GameWindow.cs` β `IsDoorSpawn` / `IsDoorName` helpers, spawn-time `AnimationSequencer` registration branch in `OnLiveEntitySpawnedLocked`, `_doorSequencers` dict, `[door-cycle]` diagnostic in `OnLiveMotionUpdated`, `TickAnimations` loop extended to advance door sequencers.
+- `src/AcDream.Core/Physics/AnimationSequencer.cs` β no changes required; existing link+cycle API was sufficient.
+
+---
+
+## #57 β [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
+
+**Status:** DONE
+**Closed:** 2026-05-13
+**Severity:** HIGH (was M1 blocker)
+**Filed:** 2026-05-12
+**Component:** input / interaction / `GameWindow.OnInputAction`
+
+**Closure:** Closed by Phase B.4b on branch `claude/compassionate-wilson-23ff99`
+(9 implementation commits, Tasks 1-4 per plan + 4 bonus fixes). The
+full round-trip β double-click door β `WorldPicker.BuildRay` + `Pick` β
+`InteractRequests.BuildUse` β ACE `SetState` reply β `ShadowObjectRegistry`
+mutation (via fixed ServerGuidβentity.Id translation) β `CollisionExemption.ShouldSkip`
+exempts (widened to ETHEREAL-alone) β player walks through β was
+visual-verified at the Holtburg inn doorway 2026-05-13. Four bonus
+discoveries were required beyond the original plan: (1) `InputDispatcher`
+had no double-click detection, (2) `OnInputAction` gate blocked
+`DoubleClick` activations, (3) `CollisionExemption` required both
+ETHEREAL+IGNORE_COLLISIONS while ACE sends only ETHEREAL, (4)
+`OnLiveStateUpdated` passed server GUID to a local-entity-ID-keyed
+registry. M1 demo target "open the inn door" met. See
+[docs/research/2026-05-13-b4b-shipped-handoff.md](research/2026-05-13-b4b-shipped-handoff.md)
+for full evidence and rationale.
+
+**Files (what shipped):**
+- `src/AcDream.Core/Selection/WorldPicker.cs` (new; formerly zero callers, now wired)
+- `src/AcDream.App/Rendering/GameWindow.cs` β `OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `OnLiveStateUpdated` ServerGuidβId translation; `_entitiesByServerGuid` reverse-lookup dict
+- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` β double-click detection
+- `src/AcDream.Core/Physics/CollisionExemption.cs` β widened to ETHEREAL-alone
+
+---
+
+## #55 β Static-entity slow path reports ~1.45M `meshMissing` per 5s at r4 standstill
+
+**Status:** OPEN
+**Severity:** LOW (no visible regression β affects a diagnostic counter, not rendered output)
+**Filed:** 2026-05-11
+**Component:** rendering / `WbDrawDispatcher` static-entity classification path
+
+**Description:** During the Phase N.6 slice 1 baseline measurement (`docs/plans/2026-05-11-phase-n6-perf-baseline.md` Β§2),
+the radius=4 standstill scenario reported `meshMissing β 1,450,000` per 5-second
+`[WB-DIAG]` window. The same scenario while walking drops to near-zero (`meshMissing = 0`
+in the steady state) as new landblocks stream in and previously-missing meshes resolve.
+This suggests the static-entity slow path's mesh-load lifecycle has some delay before
+populating for newly-streamed content but eventually catches up; the standstill case
+keeps re-counting the same set of entities-with-unresolved-meshes for the duration of
+the run. The counter is per-frame so the absolute number scales with FPS β at the
+measured ~150 FPS that's ~290K reports/s, or ~1900 entities each reported each frame.
+
+**Root cause / status:** Not investigated. Hypothesis: an entity classification path
+counts mesh-missing on every frame for static entities whose `MeshRef` resolution races
+the streaming loader. The Tier 1 cache (#53) populates only for entities whose
+classification succeeded, so persistently-failing entities run the slow path every frame
+forever and bump `meshMissing` every time. If true, the fix is either (a) cache the
+"this entity's mesh genuinely doesn't exist" result so we stop re-checking, or (b)
+deferred-classify the entity once its `MeshRef` resolves.
+
+**Files:** `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (the slow path that
+increments `_meshesMissing`), `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
+(the Tier 1 cache β likely needs to learn about "permanently missing" entries).
+
+**Acceptance:** `meshMissing` should drop to near-zero within ~5 seconds of streaming
+settle at any radius/motion combination, not stay at ~1.45M/5s indefinitely at standstill.
+
+---
+
+## #50 β [DONE 2026-05-11 Β· accepted WB divergence] Road-edge tree at 0xA9B1 visible in acdream but not retail
+
+**Status:** DONE
+**Closed:** 2026-05-11
+**Severity:** LOW (cosmetic; one spawned tree near the road in Holtburg)
+**Filed:** 2026-05-08
+**Component:** scenery placement / Phase N (WorldBuilder rendering migration)
+
+**Resolution:** Same disposition as #49 β accepted as WB-upstream
+divergence from retail. The earlier fix attempt (`e279c46`, ACME-style
+per-vertex road check) successfully removed this specific tree but
+over-suppressed scenery elsewhere; revert at `677a726` stood. Without
+a coherent port of ACME's full per-vertex filter set, piecemeal
+patching is net-negative. Left as a documented WB divergence.
+
+---
+
+**Original investigation (kept for reference):**
+
+**Description:** With `ACDREAM_USE_WB_SCENERY=1` (default since commit `b84ecbd`),
+a tree at landblock 0xA9B1 around `(lx=85.08, ly=190.97)` appears in acdream but
+neither retail nor ACME WorldBuilder render it. Upstream Chorizite/WorldBuilder
+DOES render it, so our migration to WB's helpers (Phase N.1) inherited this
+discrepancy from upstream.
+
+**Root cause (suspected):** ACME WorldBuilder includes a per-vertex road check that
+skips the entire vertex when its road bit is set (see
+`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074`).
+The current vertex (4,8) has a road bit set in the dat. ACME skips it;
+Chorizite/WorldBuilder doesn't; we don't.
+
+**Fix attempt that didn't work:** commit `e279c46` added the per-vertex road check
+directly to our `GenerateViaWb` (and legacy `Generate` for parity). It successfully
+removed the offending tree but over-suppressed scenery in other landblocks (visual
+regressions during user testing). Reverted in commit `677a726`. ACME's check likely
+interacts with other factors (per-vertex building check, or something else in ACME's
+pipeline) that we'd need to port together, not the road check alone.
+
+**Next steps:**
+1. Investigate ACME's full per-vertex filter set (road + building + anything else)
+ and port them as a coherent unit, not piecemeal.
+2. OR upstream the per-vertex road check to Chorizite/WorldBuilder (which is now our
+ submodule fork) so it lands as a generic ACME-conformance improvement.
+3. OR consider switching fork target from Chorizite/WorldBuilder to ACME WorldBuilder
+ for future phases (N.2+).
+
+Visually undetectable to most users; one extra tree at one landblock. Defer until
+other Phase N work catches a similar issue and a coherent fix becomes obvious.
+
+**Files:**
+- `src/AcDream.Core/World/SceneryGenerator.cs` β `GenerateInternal` is the active path
+- `src/AcDream.Core/World/WbSceneryAdapter.cs` β adapter used by `GenerateInternal`
+- `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074` β ACME's per-vertex road filter
+
+---
+
+## #49 β [DONE 2026-05-11 Β· accepted WB divergence] Scenery (X, Y) placement drifts from retail at some landblocks
+
+**Status:** DONE
+**Closed:** 2026-05-11
+**Severity:** LOW (minor cosmetic placement difference)
**Filed:** 2026-05-06
**Component:** scenery placement / `SceneryGenerator`
+**Resolution:** Accepted as WB-upstream divergence from retail. Since
+the N.1 phase (WorldBuilder-backed scenery, see roadmap), acdream
+defers scenery placement math to the WB fork; retail and WB diverge
+slightly here on some landblocks. Piecemeal patching against WB
+upstream would create a maintenance burden disproportionate to the
+visible impact (a handful of trees positioned a few meters off across
+the world). Left as-is; revisit only if WB upstream patches the
+divergence or if a coherent ACME-style filter port (see issue body
+below) becomes worthwhile.
+
+The original investigation plan (cdb trace of retail's
+`CLandBlock::get_land_scenes` for diff against acdream's
+`SceneryGenerator` output) is preserved below for historical
+reference if anyone picks this up.
+
+---
+
+**Original investigation (kept for reference):**
+
**Description:** While verifying the `#48` Z fix at Holtburg
landblock `0xA9B30001`, the user spotted a scenery tree placed at
the **wrong (X, Y)** in acdream relative to retail at the same
@@ -253,8 +1613,8 @@ regression on the species that already render correctly.
## #39 β RunβWalk cycle transition not visible on observed player remotes (acdream-as-observer)
-**Status:** OPEN
-**Severity:** MEDIUM (visible animation desync; not a correctness/wire bug)
+**Status:** OPEN β VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test)
+**Severity:** LOW (most cases now visibly correct after the 2026-05-06 fix sequence; remaining unverified cases are direction-flip β believed to work via direct UM but not explicitly exercised)
**Filed:** 2026-05-03
**Component:** physics / motion / animation
@@ -569,6 +1929,7 @@ the local terrain normal, not the actor's facing.
**Severity:** LOW (within retail's own DesiredDistance / MinDistance tolerances; visible only on close inspection)
**Filed:** 2026-05-05
**Component:** physics / motion / animation (per-tick remote prediction)
+**Phase:** L.2 (Movement & Collision Conformance) β inbound-motion fidelity sub-piece. Blocked on cdb-trace of `CSequence::velocity` for Humanoid running cycle, then porting `add_motion @ 0x005224b0`'s `style_speed Γ MotionData.velocity` chain.
**Description:** With the L.3 M3 path live (queue catch-up + animation
root motion fallback), observed player remotes chase server position
@@ -817,13 +2178,35 @@ collision fixes.)
still resolve correctly)
- Observer view from a parallel retail client unchanged
-## #37 β Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
+## #37 β [DONE 2026-05-11 Β· resolved by `0bd9b96`] Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
-**Status:** OPEN
+**Status:** DONE
+**Closed:** 2026-05-11
+**Commit:** `0bd9b96` (the #47 humanoid degrade-resolver fix, 2026-05-06)
**Severity:** LOW (cosmetic; doesn't affect gameplay)
**Filed:** 2026-05-01
**Component:** rendering / clothing / textures
+**Resolution:** Closed by the same mesh-fidelity work that resolved #47.
+The `GfxObjDegradeResolver` (commit `0bd9b96`, 2026-05-06) swapped
+humanoid parts to their higher-detail `Degrade[0].Id` meshes (e.g.
+upper arm `0x01000055 β 0x01001795`, lower arm `0x01000056 β 0x0100178F`).
+The higher-detail meshes include the coat-collar polygons that the
+low-detail meshes were missing β which is what was exposing the
+skin-toned palette indices in the upper-coat region. With the
+correct mesh resolution, those polygons cover the previously-visible
+"skin stub". User confirmed visually 2026-05-11.
+
+The original 2026-05-01/2026-05-04 investigation work (palette range
+analysis, SubPalette overlay tracing) is preserved below for
+historical reference; it was a correct read of *what* was rendering,
+but the root cause was the missing collar polygons, not the palette
+gap.
+
+---
+
+**Original investigation (kept for reference):**
+
**Description:** Every humanoid character (player + NPCs) wearing a coat
shows a visible skin-colored region at the top of the coat where retail
shows continuous coat fabric. From the back view: hair β skin stub β
@@ -897,8 +2280,8 @@ If the coat texture's UVs at the upper region map to texel-bytes whose palette i
**Files (diagnostic env vars committed for next-session reuse):**
-- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
- β `ACDREAM_NO_CULL` env var
+- ~~`src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
+ β `ACDREAM_NO_CULL` env var~~ (file deleted in N.5 ship amendment)
- `src/AcDream.App/Rendering/GameWindow.cs` β `ACDREAM_HIDE_PART=N`
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
@@ -1167,13 +2550,29 @@ in under 5 minutes by following the CLAUDE.md workflow.
---
-## #36 β Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
+## #36 β [DONE 2026-05-11 Β· promoted to Phase C.1.5c] Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
-**Status:** OPEN
+**Status:** DONE (promoted to Phase C.1.5c)
+**Closed:** 2026-05-11
+**Promoted to:** Phase C.1.5c (Sky-PES dispatch chain) β see roadmap `docs/plans/2026-04-11-roadmap.md`
**Severity:** MEDIUM (aesthetic feature-parity, but addresses a cluster of bugs)
**Filed:** 2026-04-30
**Component:** sky / weather / particles
+**Resolution:** Promoted to a roadmap phase (C.1.5c) β the work is
+multi-commit (decomp dive + persistent-emitter creation + PES timeline
+driver + PES script execution + live-trace verification) and warrants
+a named phase rather than living forever as an "open issue." The
+decomp anchors, live-trace evidence (24,576-frame `GameSky::Draw`
+trace), and 6-step implementation outline in the body below remain
+the authoritative implementation reference; the roadmap phase entry
+is the schedule/scope tracker. **Issues #2 (lightning), #28 (aurora),
+and #29 (cloud thinness) auto-close when C.1.5c ships.**
+
+---
+
+**Original investigation (kept as implementation reference):**
+
**Description:** Three open sky bugs (#2 lightning, #28 aurora, #29 cloud
density) all trace back to the same missing infrastructure: retail's
sky-PES (Particle Effect Script) dispatch chain. We have it now from a
@@ -1318,6 +2717,7 @@ one live creature case no longer use the single-cylinder fallback.
**Severity:** MEDIUM
**Filed:** 2026-04-25
**Component:** net / sky
+**Chore tag:** Single-commit fix β well-scoped ~10-line wiring. `WorldTimeService.SyncFromServer(double)` already exists; just needs `WorldSession` to detect header-flag `0x1000000` and call it. Pickup at any opportunistic session.
**Description:** Our `WorldTimeService.DayFraction` syncs with the server once at login via `ConnectRequest + TimeSync`, then advances from the local wall-clock. Retail receives periodic `TimeSync` refreshes (header flag `0x1000000`) carrying a fresh `PortalYearTicks double` and re-anchors its clock. Without those, acdream's keyframe state drifts from retail's over 10+ minutes β observed during the 2026-04-24 sky-color debug sessions where retail was at DayFraction 0.976 while acdream was at 0.634.
@@ -1333,29 +2733,6 @@ one live creature case no longer use the single-cylinder fallback.
---
----
-
-## #13 β PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
-
-**Status:** OPEN
-**Severity:** LOW (no current user-visible bug; future panels will need the data)
-**Filed:** 2026-04-25
-**Component:** net / player-state
-
-**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that β Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped β is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
-
-**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` β a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
-
-**Files:**
-- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` β extend `Parsed` record + walker.
-- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` β add fixtures per section.
-- `src/AcDream.Core.Net/GameEventWiring.cs` β route `parsed.Inventory` + `Equipped` to ItemRepository.
-
-**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
-
-**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
-
----
---
@@ -1370,6 +2747,8 @@ one live creature case no longer use the single-cylinder fallback.
**Root cause / status:** Three competing hypotheses, none pinned down: (a) retail uses a **different** fog range for sky than terrain; (b) retail applies fog with an **elevation-angle** weighting rather than linear distance; (c) retail's sky meshes **don't participate** in the global fog and the "horizon glow" comes from a different atmospheric-scatter path. Need to identify retail's actual sky-fog behaviour before re-enabling with correct parameters.
+**Not in the Phase C.1.5c (Sky-PES) cluster.** Unlike #2/#28/#29 β all PES-driven sky visuals consolidated under the C.1.5c phase via former issue #36 β this is a fragment-shader fog-mix problem. Addressing C.1.5c will NOT resolve #4, and #4 should NOT be bundled into Phase C.1.5c scope. The fix likely needs its own decomp dive into retail's sky-fog math + shader work.
+
**Files:**
- `src/AcDream.App/Rendering/Shaders/sky.frag` β line ~55, `rgb = mix(uFogColor.rgb, rgb, vFogFactor)` currently commented out
- `src/AcDream.App/Rendering/Shaders/sky.vert` β lines 109-114, `vFogFactor` computation
@@ -1600,6 +2979,7 @@ retail show matching silhouette and shape definition.
**Severity:** MEDIUM (degrades external perception of acdream-driven characters)
**Filed:** 2026-05-06
**Component:** net / motion (acdream's outbound path: `PlayerMovementController` β `MoveToState` (0xF61C) / `AutonomousPosition` heartbeat β ACE β retail observer)
+**Phase:** L.2 (Movement & Collision Conformance) β outbound-motion fidelity sub-piece. Counterpart to #41 (which is the inbound side); both are L.2 conformance work. If outbound fidelity grows into multi-commit work, consider carving "L.2e β Outbound motion fidelity" as a named sub-piece on the roadmap.
**Description:** When viewing acdream's local +Acdream character through a parallel retail acclient.exe, the retail observer sees the character's movement as visibly blippy and laggy β position appears to step in discrete jumps rather than translating smoothly. The local acdream view of the same character looks fine, and acdream observing a retail-driven character (after #39 / #45) also looks fine. The degradation is specifically on the **outbound** side: what acdream sends to ACE for relay to other clients.
@@ -1656,6 +3036,296 @@ Unverified. The likely culprits, ranked by suspected probability:
# Recently closed
+## #86 β [DONE 2026-05-19 Β· 3764867 + 4e308d5] Click selection penetrates walls
+
+**Closed:** 2026-05-19
+**Commits:** `3764867` β fix(picker): Cluster A #86 β cell-BSP ray occlusion in WorldPicker; `4e308d5` β test(picker): Cluster A #86 β screen-rect cell-occlusion tests
+**Component:** input, interaction
+
+**Resolution:** `WorldPicker.Pick` now accepts a `cellOccluder` callback
+(`CellBspRayOccluder`). Before returning a hit, both `Pick` overloads
+consult the occluder's `NearestWallT` value; any candidate entity whose
+ray parameter exceeds the nearest-wall intersection is filtered out.
+The occluder is wired from `GameWindow` using the loaded `PhysicsDataCache`
+cell structs. Entities behind walls from the camera's perspective are no
+longer selectable. Screen-rect occlusion tests verify the filter across
+several hit/miss scenarios.
+
+---
+
+## #77 β [DONE 2026-05-18 Β· 3be7000] Auto-walk doesn't engage at walking range; pickup at walking range overshoots and snaps back
+
+**Closed:** 2026-05-18
+**Commit:** `3be7000` β fix(physics): close #77 β auto-walk honors ACE CanCharge bit; zero velocity in turn-in-place
+**Component:** physics / `PlayerMovementController` / `GameWindow.OnLiveMotionUpdated` / `CreateObject.ServerMotionState`
+
+**Resolution.** Two coupled bugs sharing a root in
+`PlayerMovementController.DriveServerAutoWalk` + `BeginServerAutoWalk`.
+
+1. **Walk-vs-run misclassification (the user-visible "always runs at walk range" half).**
+ `BeginServerAutoWalk` decided `_autoWalkInitiallyRunning = (initialDist β
+ distanceToObject) >= 1.0f`, forcing run at any chase past ~1.6 m.
+ ACE's wire-level walk-vs-run answer is the MovementParameters
+ **CanCharge** bit (0x10), which `Creature.SetWalkRunThreshold`
+ sets when server-side playerβtarget distance β₯ `WalkRunThreshold/2`
+ (= 7.5 m default). Retail's `MovementParameters::get_command`
+ (decomp `0x0052aa00`, `acclient_2013_pseudo_c.txt:307946+`) gates
+ the run path on CanCharge first; the inner walk_run_threshold
+ check practically always walks given ACE's 15 m default. The
+ hardcoded 1.0 m threshold pushed run into the 3-5 m walk-range the
+ user reported should walk.
+
+2. **Velocity leak in turn-in-place phase (the user-visible "overshoots
+ and snaps back" half).** When the auto-walked body crossed the
+ destination, `desiredYaw` flipped ~180Β°, `walkAligned` dropped to
+ false, and the `if (!moveForward) return true;` branch returned
+ without zeroing body velocity. The body kept the prior frame's
+ running velocity (`RunAnimSpeed Γ runRate β 11 m/s`) and slid 4-5 m
+ past the target before the turn-around rotation completed.
+
+**Changes:**
+- `CreateObject.ServerMotionState.CanCharge`: new bool prop reading
+ bit 0x10 of `MoveToParameters`. Cross-ref ACE
+ `MovementParams.CanCharge = 0x10`.
+- `PlayerMovementController.BeginServerAutoWalk`: replaces the unused
+ `walkRunThreshold` parameter with `bool canCharge`; sets
+ `_autoWalkInitiallyRunning = canCharge`.
+- `PlayerMovementController.DriveServerAutoWalk` turn-in-place branch:
+ calls `_motion.DoMotion(Ready, 1.0)` and zeros body horizontal
+ velocity (preserving Z for gravity). No-op for initial-turn with a
+ stationary body; fixes overshoot-recovery and settling cases.
+- `GameWindow.OnLiveMotionUpdated`: passes
+ `update.MotionState.CanCharge` through; `[autowalk-begin]` trace
+ now shows `canCharge=` instead of `walkRunThresh=`.
+- `GameWindow.InstallSpeculativeTurnToTarget`: predicts ACE's
+ CanCharge from local distance using ACE's exact 7.5 m rule, so the
+ speculative install agrees with the wire-triggered overwrite that
+ arrives moments later.
+
+**Verification.** Build green; all targeted test projects pass cleanly
+(Core.Net 294/294, UI.Abstractions 419/419, App 10/10; Core 1073 passed
+/ 8 pre-existing failures unchanged). Visual-verified at Holtburg
+2026-05-18: walk-range NPC click walks + Use fires + dialogue appears,
+walk-range F-key pickup walks + no overshoot + item enters inventory,
+far-range pickup (8-10 m+) still runs.
+
+**Lesson archived:** `memory/feedback_autowalk_cancharge_bit.md`. When
+ACE already encodes a decision on the wire (CanCharge IS the walk-vs-run
+answer), relay it β don't reinvent the bucket with a locally-computed
+threshold.
+
+---
+
+## #56 β [DONE 2026-05-12 Β· 8735c39] `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root
+
+**Closed:** 2026-05-12
+**Commit chain (newest first):**
+- `8735c39` β feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities (4 new fire-sites + 5 integration tests; also picks up EnvCell statics & exterior stabs as a side-effect of the activator-guard relaxation)
+- `5ca5827` β feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms (resolver returns `ScriptActivationInfo(ScriptId, PartTransforms)`; keys by ServerGuid OR entity.Id; GameWindow resolver lambda upgraded; 4 existing + 3 new tests)
+- `11521f4` β fix(vfx #56): `ParticleHookSink` applies `CreateParticleHook.PartIndex` transform (new `_partTransformsByEntity` side-table; `SpawnFromHook` transforms offset through `partTransforms[PartIndex]` before applying entity rotation; 2 new tests + 2 existing pass)
+- `f3bc15e` β feat(vfx #C.1.5b): `SetupPartTransforms` helper for per-part anchor transforms (walks `PlacementFrames[Resting]` β `[Default]` β first-available; 4 tests)
+- `1e3c33b` β docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
+
+**Component:** vfx / `ParticleHookSink` + `EntityScriptActivator` + `GpuWorldState` + `SetupPartTransforms`
+
+**Resolution.** Two-slice fix that also folded in slice 2 of the C.1.5 phase work. **Slice A (the #56 fix proper)**: precomputed per-part `Matrix4x4` array at activator-spawn time via the new `SetupPartTransforms.Compute(setup)` helper, threaded through `EntityScriptActivator` β `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` (mirrors the existing `_rotationByEntity` side-table pattern), applied inside `SpawnFromHook` as `partLocal = Transform(offset, partTransforms[PartIndex])` before the existing world-rotation step. Backwards-compatible: entities without registered part transforms fall through to identity (pre-fix behavior). **Slice B (folded in same phase, makes the fix matter for slice 2 visual gates)**: dropped the activator's `ServerGuid==0` early-return guard. Activator now keys by `entity.ServerGuid` when non-zero, else `entity.Id` β collision-free because dat-hydrated entity IDs live in the `0x40xxxxxx` (interior) / `0x80xxxxxx` (scenery) / `0xC0xxxxxx` ranges, all disjoint from server guids. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (FarβNear promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (NearβFar demotion) for OnRemove. Live entities are filtered out by `ServerGuid != 0` on the `AddLandblock` path so pending-bucket merges don't double-fire OnCreate.
+
+**Reality discovery folded into spec Β§3:** the handoff doc's Β§4 Q1/Q2 (synthetic-ID scheme + new walker class) were mooted by finding that `GameWindow.BuildInteriorEntitiesForStreaming` already hydrates EnvCell `StaticObjects` as `WorldEntity` instances with stable `entity.Id`. No new walker, no synthetic IDs.
+
+**Verification.** Build green. 77 Vfx+Meshing+Activator+Streaming tests pass (4 new for SetupPartTransforms + 2 new for ParticleHookSink + 4 updated + 3 new for activator + 5 new for GpuWorldState integration). 8 pre-existing Physics/Input failures unchanged (verified by stash-and-rerun on Task 4). **Visual verification 2026-05-12**: Holtburg Town network portal (entity `0x7A9B405B`, script `0x3300126D`) β swirl no longer ground-buried, emitters distributed across the arch; Holtburg Inn fireplace flames over the firebox; cottage chimney smoke; spell cast on `+Acdream` cast-anim particles β all match retail.
+
+**Acceptance reproducer:** the C.1.5a verification log captured portal A entity `0x7A9B405B` swirl compressed to a partly-ground-buried point. Post-fix at the same portal, the swirl extends through the arch in retail-matching shape.
+
+## #53 β [DONE 2026-05-11 Β· f928e66] A.5/tier1-redo: entity-classification cache retry
+
+**Closed:** 2026-05-11
+**Commit chain (newest first):**
+- `f928e66` β incomplete-entity flag must persist across same-entity tuples (mid-list null-renderData)
+- `c55acdc` β skip cache populate when classification is incomplete (drudge fix)
+- `95ebbf3` β key cache by `(entityId, landblockHint)` tuple to defeat ID collision
+- `71d0edc` β namespace stab Ids globally (`0xC0LLBB01..`) for Tier 1 cache safety
+- `4df1914` β clarify `DebugCrossCheck`'s wiring status
+- `f16604b` β DEBUG cross-check + tripwire + 2 tests
+- `489174f` β wire `InvalidateLandblock` callback at LB demote/unload
+- `1d1afcd` β wire `InvalidateEntity` at live-entity despawn
+- `f7e38c2` β cache-hit fast path must fire per-entity, not per-tuple
+- `0cbef3c` β cache-hit fast path + dispatcher integration tests
+- `00fa8ae` β cache `Populate` must flush at entity boundary, not per-MeshRef tuple
+- `2f489a8` β cache-miss populate on first frame for static entities
+- `28513ea` β optional `CachedBatch` collector + `restPose` param on `ClassifyBatches`
+- `a65a241` β inject `EntityClassificationCache` into `WbDrawDispatcher`
+- `60fbfce` β plumb `landblockId` through `_walkScratch`
+- `a171e70`, `aea4460`, `694815c`, `773e970` β cache `InvalidateLandblock` / `InvalidateEntity` / `Populate` / skeleton+first test
+- `c02405c` β extract `GroupKey` to namespace-scope `internal`
+- `2f8a574` β implementation plan
+- `4abb838` β mutation audit + cache design spec
+
+**Component:** rendering / `WbDrawDispatcher` / `EntityClassificationCache` / `LandblockLoader`
+
+**Resolution.** New `EntityClassificationCache` keyed by `(entityId, landblockHint)` tuple in `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`. The dispatcher routes static entities (NOT in `_animatedEntities`) through the cache β first-frame slow-path populates flat `CachedBatch[]` (one entry per (partIdx, batchIdx) with the part-relative `RestPose` and resolved `BindlessTextureHandle`); subsequent-frame cache hits skip classification entirely and append `cached.RestPose * entityWorld` to each matching group. Animated entities bypass. Invalidation fires from `RemoveLiveEntityByServerGuid` (per-entity, `0xF747`/`0xF625`) and `RemoveEntitiesFromLandblock` (per-LB, NearβFar demote + unload).
+
+**Perf result.** Entity dispatcher cpu_us **median ~1200 Β΅s, p95 ~1500 Β΅s** at horizon-safe + High preset on AMD Radeon RX 9070 XT @ 1440p. Pre-Tier-1 baseline was ~3500m / ~4000p95. ~66% reduction in median, ~63% in p95. Well under the A.5 spec budget (median β€ 2.0 ms, p95 β€ 2.5 ms). No `BUDGET_OVER` flag observed.
+
+**Verification.** Build green; full suite 1711 passed / 8 pre-existing physics/input failures unchanged; N.5b sentinel 112/112; visual gate confirmed via `+Acdream` test character (NPCs animate, lifestone renders, multi-part buildings + scenery + Nullified Statue of a Drudge on top of the Foundry all render fully β no airborne geometry, no Z-fighting, no missing parts, no wrong textures).
+
+**Lessons surfaced during implementation (4 bug-fix iterations):**
+
+1. **Audit must verify ID uniqueness for cache keys.** The original mutation audit verified `Position`/`Rotation`/`MeshRefs` stability post-spawn but didn't verify `entity.Id` was globally unique. Stabs from `LandblockLoader.BuildEntitiesFromInfo` restarted at `nextId = 1` per landblock β cross-LB collisions. Scenery (`0x80LLBB00 + localIndex`) and interior (`0x40LLBB00 + localCounter`) overflow at >256 items/LB. Cache key collision produced "buildings up in the air with wrong textures." Fixed by namespacing stab Ids (`71d0edc`) then by changing cache key to `(entityId, landblockHint)` tuple (`95ebbf3`) β defensive against ALL future hydration paths.
+
+2. **Per-tuple iteration with per-entity cache state is a recurring trap.** Three separate bugs caught by code review or visual gate hit this same root cause:
+ - Populate fired per-tuple β multi-MeshRef entities lost all but the last MeshRef's batches (`00fa8ae`).
+ - Cache hit fired per-tuple β multi-MeshRef entities drew NΓ copies, severe Z-fighting (`f7e38c2`).
+ - Incomplete-flag reset fired per-tuple β mid-list null-MeshRef trees populated partial cache, branches never rendered (`f928e66`).
+
+ The fix pattern in all three: track previous entity Id (`prevTupleEntityId` / `lastHitEntityId`); execute per-entity logic only on actual entity-change detected against that tracker, not unconditionally per tuple.
+
+3. **Async mesh loading interacts with cache populate.** WB's `ObjectMeshManager.PrepareMeshDataAsync` decodes meshes off the main thread. If a MeshRef's GfxObj is still decoding at first-frame visibility, `TryGetRenderData` returns null and the slow path skips it. Without the drudge fix (`c55acdc`), the cache populated a partial classification and cache hits served it forever β even after the missing mesh loaded. With the fix, the dispatcher tracks `currentEntityIncomplete` per entity and drops the populate scratch when any MeshRef returned null; the slow path retries every frame until all meshes load.
+
+4. **A/B diagnostic env-var paid for itself.** `ACDREAM_DISABLE_TIER1_CACHE=1` forces every static entity through the slow path. Used twice during debugging to instantly differentiate "bug is in the cache" vs "bug is elsewhere entirely." Kept in tree (read once in `WbDrawDispatcher` ctor) for future cache investigations.
+
+**Memory.** See `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_tier1_cache.md` for the audit-gap and per-tuple-vs-per-entity pattern documented for future cache work.
+
+---
+
+## #54 β [DONE 2026-05-10 Β· bf31e59] A.5/jobkind-plumbing: far-tier worker loads full entity layer then strips
+
+**Closed:** 2026-05-10
+**Commits:** `bf31e59` (factory signature change to 2-arg + back-compat overload + far-tier early-out)
+**Component:** streaming / LandblockStreamer
+
+**Resolution.** `LandblockStreamer.cs` primary ctor now takes `Func` so the factory can branch on the job kind. A back-compat overload preserves the old single-arg signature for existing test code (5 ctor sites in `LandblockStreamerTests.cs` resolved to the overload with no test changes). `BuildLandblockForStreaming(uint, JobKind)` in `GameWindow.cs` early-outs for `LoadFar` with a heightmap-only path (`_dats.Get(landblockId)` + `Array.Empty()`); near-tier path is unchanged. The Bug A post-load entity strip in `LandblockStreamer.HandleJob` is retained as a `Debug.Assert` + Release safety net. Per-LB worker cost on far-tier dropped from ~tens of ms (LandBlockInfo + scenery + interior) to ~sub-ms (single LandBlock dat read).
+
+**Verification.** Build green; 1688/1696 tests pass (8 pre-existing physics/input failures unchanged); 30 streaming-targeted tests (LandblockStreamer + StreamingController + StreamingRegion) all green via the back-compat overload.
+
+---
+
+## #52 β [DONE 2026-05-10 Β· e40159f] A.5/lifestone-missing: Holtburg lifestone not rendering
+
+**Closed:** 2026-05-10
+**Commits:** `e40159f` (alpha-test discard removal + cull state restoration + uDrawIDOffset uniform)
+**Component:** rendering / WbDrawDispatcher / shaders
+
+**Resolution.** Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit `dcae2b6`, 2026-05-08). The original ISSUE #52 hypothesis (Bug A far-tier strip catching the lifestone) was wrong β the lifestone is server-spawned (WCID 509, Setup `0x020002EE`) and never goes through the far-tier strip. Real causes:
+
+1. **Alpha-test discard.** `mesh_modern.frag` transparent pass discarded fragments with `Ξ± >= 0.95`. The lifestone crystal core surface `0x080011DE` decoded with Ξ±β₯0.95 across its visible surface, so 100% of the crystal's fragments were discarded β invisible. The original N.5 Β§2 rationale ("high-Ξ± belongs in opaque pass") doesn't hold for surfaces dat-flagged transparent: those pixels can't reach the opaque pass at all. Fix: remove the high-Ξ± discard from the transparent pass; keep `Ξ± < 0.05` as a fragment-cost optimization.
+
+2. **Cull state regression.** Legacy `StaticMeshRenderer` had Phase 9.2's `Enable(CullFace) + Back + CCW` setup at the top of its translucent pass (commit `6f1971a`, 2026-04-11) β fix for "lifestone crystal one face missing" reported at the time. When `dcae2b6` deleted the legacy renderer, the new `WbDrawDispatcher` never inherited that GL state, so closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's exact setup at the top of Phase 8.
+
+3. **`uDrawIDOffset` indexing bug.** `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect` call. The transparent pass starts at byte offset `_opaqueDrawCount * stride` in the indirect buffer, but the vertex shader read `Batches[gl_DrawIDARB]` directly β so transparent draws read from `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[opaqueCount..end)`. The lifestone crystal's apparent texture flickered to whatever opaque batch sorted to index 0 each frame; with the player character in view, this often appeared as a lifestone wearing the player's body / face textures. Fix: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, change `Batches[gl_DrawIDARB]` to `Batches[uDrawIDOffset + gl_DrawIDARB]`, and set the uniform per-pass in `WbDrawDispatcher` (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WorldBuilder's `BaseObjectRenderManager.cs:845`.
+
+**Verification.** User-confirmed visually via `+Acdream` test character at the Holtburg outdoor lifestone (Z=94 platform). Tests 1688/1696 passing (8 pre-existing physics/input failures unchanged). N.5b conformance sentinel 94/94 clean.
+
+**Lesson.** The WB rendering migration's "lift legacy state into the new dispatcher" was incomplete in two non-obvious ways: (a) GL state setup that lived inside legacy per-pass blocks, and (b) shader uniforms that the legacy per-draw flow didn't need but the multi-draw-indirect flow does. Future WB-migration work should systematically diff the legacy renderer's GL setup + shader I/O against the new dispatcher's. The `uDrawIDOffset` bug was particularly hidden because it only manifested for entities that mixed transparent draws with the visible opaque sort order β single-pass content (pure opaque or pure transparent) was unaffected.
+
+---
+
+## #13 β [DONE 2026-05-10 Β· d3b58c9..078919c] PlayerDescription trailer past enchantments
+
+**Closed:** 2026-05-10
+**Commits:** `d3b58c9` (scaffold) β `6587034` (rename nit) β `becbde6` (OptionFlags+Options1) β `9a0dfe0` (TrailerTruncated + diag) β `f7a5eea` (Shortcuts) β `8cbb991` (HotbarSpells) β `75e8e26` (DesiredComps) β `b17dc3b` (SpellbookFilters) β `98eebef` (Options2) β `d9a5e40` (strict Inventory+Equipped) β `91693ea` (heuristic GAMEPLAY_OPTIONS walker) β `58095d8` (combined fixture test) β `078919c` (ItemRepository wiring)
+**Component:** net / player-state
+**Plan:** [`docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md`](../docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md)
+
+**Resolution.** `PlayerDescriptionParser` now walks every trailer
+section through Inventory + Equipped, ported faithfully from holtburger
+`events.rs:503-625` + `shortcuts.rs:13-34`. The trickiest piece β
+`gameplay_options` β uses a 4-byte-aligned forward heuristic
+(`TryHeuristicInventoryStart`) that probes candidate offsets with a
+strict `(inventory + equipped consume to EOF)` test, mirroring
+holtburger's `find_inventory_start_after_gameplay_options`.
+
+The trailer walk is wrapped in its own inner try/catch (separate from
+the outer parse-wide catch) so a malformed trailer cannot destroy the
+already-extracted attribute / skill / spell / enchantment data. A new
+`Parsed.TrailerTruncated` flag lets callers distinguish a clean parse
+from a graceful-degradation parse (set true if the inner catch fires;
+log under `ACDREAM_DUMP_VITALS=1`).
+
+`GameEventWiring`'s `PlayerDescription` handler now registers each
+inventory entry with `ItemRepository.AddOrUpdate(...)` and applies
+`MoveItem(...)` for equipped entries so paperdoll picks up
+`CurrentlyEquippedLocation` at login. The acceptance criterion
+"`ItemRepository.Count` after login > 0" is now exercised by
+`PlayerDescription_RegistersInventoryEntries_InItemRepository` in
+`GameEventWiringTests`.
+
+12 tasks, 13 commits, +9 PD parser tests + 1 wiring test (20 PD tests
+total, 282 Net.Tests pass). Code-review nits during the run produced
+two refactor commits: `Shortcut β ShortcutEntry` rename to avoid a
+homograph with the `CharacterOptionDataFlag.Shortcut` flag bit
+(`6587034`); `TrailerTruncated` flag + diagnostic logging
+(`9a0dfe0`).
+
+Forward-looking notes (low priority, no follow-up issues filed):
+
+- `WeenieClassId = inv.ContainerType` for inventory entries is a
+ placeholder; `CreateObject` overwrites it with the real weenie class
+ later in the login sequence.
+- The 10,000 count cap throws `FormatException` on validation failure,
+ which the inner catch treats the same as truncation. If a future
+ diagnostic UI needs to distinguish "EOF mid-section" from "garbage
+ count rejected", split `TrailerTruncated` into two flags. For now
+ the `ACDREAM_DUMP_VITALS=1` log message gives the developer enough
+ signal.
+
+Files: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`,
+`src/AcDream.Core.Net/GameEventWiring.cs`,
+`tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`,
+`tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`.
+
+---
+
+## #51 β [DONE 2026-05-09 Β· da56063 + N.5b SHIP] WB's terrain-split formula diverges from retail's `FSplitNESW`
+
+**Closed:** 2026-05-09
+**Commit:** `da56063` (black-terrain fix; landed within Phase N.5b β see
+`docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md` for the
+ship commit chain)
+**Component:** terrain math / Phase N.5b
+
+**Resolution: Path C.** Phase N.5b lifted terrain rendering onto the
+modern path (bindless atlas + `glMultiDrawElementsIndirect`) WITHOUT
+adopting WB's `TerrainUtils.CalculateSplitDirection`. The pre-implementation
+divergence test (`tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`)
+confirmed the two formulas disagree on **49.98%** of sweep cells β
+fundamentally incompatible with our shared physics + visual mesh, which
+both rely on retail's `FSplitNESW` (constants `0x0CCAC033` / `0x421BE3BD` /
+`0x6C1AC587` / `0x519B8F25`).
+
+Path C: keep retail's `FSplitNESW` formula via `LandblockMesh.Build` β
+`TerrainBlending.CalculateSplitDirection`; mirror WB's `TerrainRenderManager`
+architectural pattern (single global VBO/EBO + slot allocator + bindless
+atlas + multi-draw indirect) but feed it acdream's mesh. Modern dispatcher
+(`TerrainModernRenderer`) replaces `TerrainChunkRenderer` (deleted in T9
+along with `TerrainRenderer` + `terrain.vert/.frag`).
+
+Path A (substitute WB's formula) was killed by the divergence test.
+Path B (fork-patch WB's renderer to use retail's formula) was rejected
+for permanent maintenance burden. Path C ships the architectural
+pattern while preserving retail-formula compliance.
+
+Visual mesh and physics both still consume retail's `FSplitNESW`; they
+remain in lockstep, no triangle-Z hover. The N.6 / N.7 sequencing
+implication this issue carried (substitute physics math only when the
+visual mesh migrates) is moot β neither side ever switches to WB's
+formula.
+
+**Files added:**
+- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
+- `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs`
+- `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`
+- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
+- `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs` (the
+ test that killed Path A)
+
+**Files deleted (T9):**
+- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs`
+- `src/AcDream.App/Rendering/TerrainRenderer.cs`
+- `src/AcDream.App/Rendering/Shaders/terrain.vert`
+- `src/AcDream.App/Rendering/Shaders/terrain.frag`
+
+---
+
## #43 β [DONE 2026-05-05 Β· 9e4772a] Slope staircase on observed player remotes (anim-only fallback ignored slope)
**Closed:** 2026-05-05
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 5f134da..2ada332 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -95,8 +95,8 @@ designed 2026-04-24. Full design: `docs/plans/2026-04-24-ui-framework.md`.
The backend is pluggable; ViewModels / Commands / `IPanelRenderer` are
stable across the swap. ImGui persists forever as the
`ACDREAM_DEVTOOLS=1` devtools overlay regardless of which backend owns
-the game UI. See `memory/project_ui_architecture.md` for the session
-crib-sheet version.
+the game UI. The full UI design lives in
+`docs/plans/2026-04-24-ui-framework.md`.
---
diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md
new file mode 100644
index 0000000..288efd0
--- /dev/null
+++ b/docs/architecture/code-structure.md
@@ -0,0 +1,376 @@
+# acdream β code structure & extraction sequence
+
+**Status:** Living document. Created 2026-05-16 as the companion to the
+"Code Structure Rules" section in `CLAUDE.md`.
+**Purpose:** Describe the desired structural state of the App layer,
+explain the rules we've adopted, and lay out the safe extraction
+sequence from today's reality (one 10,304-line `GameWindow.cs`) to the
+target (thin `GameWindow`, small focused collaborators).
+**Companion to:** [`acdream-architecture.md`](acdream-architecture.md)
+(the layered architecture) and
+[`worldbuilder-inventory.md`](worldbuilder-inventory.md) (what we take
+from WB vs port ourselves).
+
+---
+
+## 1. The structural problem we're solving
+
+The layered architecture works: `AcDream.Core` is GL-free, the network
+layer is wire-compatible, the UI has a stable contract, plugins load.
+The structural debt is concentrated in **one file**:
+
+```
+src/AcDream.App/Rendering/GameWindow.cs 10,304 lines
+```
+
+`GameWindow` is the single object that:
+
+- Owns the GL context, the window, input, and shaders.
+- Reads ~40 different environment variables across its lifetime.
+- Hosts the live network session (`WorldSession`) and the offline
+ pre-login state.
+- Owns parallel dictionaries for entity lookup (`_entitiesByServerGuid`,
+ the per-landblock entity lists in `GpuWorldState`, plus the player
+ controller's own state).
+- Drives selection / interaction (`WorldPicker`, `SendUse`,
+ `SendPickUp`).
+- Drives per-frame render orchestration (sky β terrain β opaque mesh β
+ transparent mesh β particles β debug lines β UI).
+- Wires up every plugin hook sink, every diagnostic, every panel.
+
+Almost every M1 / M2 bug touches this file. Every new feature adds a
+field plus a method plus a wiring call. It is not getting better on its
+own.
+
+The fix is **not** "rewrite `GameWindow` in one pass" β that's a
+high-risk change that would block M2. The fix is to **extract one
+collaborator at a time**, verify behavior is unchanged, ship, and
+move on. This document defines that sequence.
+
+---
+
+## 2. Code Structure Rules β the discipline
+
+Recap of the rules from `CLAUDE.md` with the rationale:
+
+### Rule 1: No new substantial feature bodies in `GameWindow.cs`
+
+**Why:** Every line we add to `GameWindow` makes the eventual decomposition
+harder. New features that "live in" `GameWindow` instead of being
+extracted are the reason the file is 10k lines.
+
+**How to apply:** A new feature gets its own class under
+`src/AcDream.App//` (or deeper in `AcDream.Core` if it's pure
+logic). `GameWindow` owns a field and a wiring call, nothing more. If
+you find yourself adding a 200-line method to `GameWindow`, stop and
+extract.
+
+**Exemption:** Trivial wiring that *must* stay in `GameWindow` because
+it touches GL state during `OnLoad` is acceptable, but should still
+delegate to a collaborator for the substance.
+
+### Rule 2: `AcDream.Core` must not depend on window / GL / backend projects
+
+**Why:** Core is the GL-free, testable layer. The moment Core imports
+a GL or windowing namespace, we've lost the ability to test it without
+a graphics context, and the layer split becomes fiction.
+
+**How to apply:** The only currently-allowed seams from Core into the
+WB / GL world are:
+
+- `WorldBuilder.Shared` β stateless helpers (`TerrainUtils`,
+ `TerrainEntry`, `RegionInfo`).
+- `Chorizite.OpenGLSDLBackend.Lib` β stateless helpers
+ (`SceneryHelpers`, `TextureHelpers`).
+
+Both are leaf namespaces with no GL state. If you need a new seam (e.g.
+WB's `ObjectMeshManager` needs to be visible from Core), the change
+**must** come with an inventory-doc update justifying it and ideally a
+slim interface in Core that the App layer implements.
+
+**Current reality:** `src/AcDream.Core/AcDream.Core.csproj` references
+`Chorizite.OpenGLSDLBackend` (not just `OpenGLSDLBackend.Lib`). This is
+historical. Two Core files import from it:
+- `World/SceneryGenerator.cs` β `Chorizite.OpenGLSDLBackend.Lib`
+- `Textures/SurfaceDecoder.cs` β `Chorizite.OpenGLSDLBackend.Lib`
+
+Both use the stateless `Lib` namespace only. The full project reference
+is wider than it needs to be; tightening it to just `WorldBuilder.Shared`
++ a stateless-helpers shim is a candidate future cut, but not urgent.
+
+### Rule 3: UI panels target `AcDream.UI.Abstractions` only
+
+**Why:** This is the one rule that keeps D.2b (the future retail-look
+backend) viable. Every panel that imports `ImGuiNET` directly is a panel
+we'd have to rewrite when the backend swaps.
+
+**How to apply:** A panel's `using` block must mention
+`AcDream.UI.Abstractions.*` and nothing from `AcDream.UI.ImGui`. The
+panel writes against `IPanelRenderer`. The `ImGuiPanelRenderer`
+translates those calls to ImGui at runtime. Plugin-facing UI follows the
+same rule.
+
+### Rule 4: Startup env vars enter through `RuntimeOptions`
+
+**Why:** Environment variables are global mutable state. Reading them
+at random call sites means (a) duplicated `Environment.GetEnvironmentVariable`
+boilerplate, (b) no single place to see "what flags does the client
+respond to?", (c) impossible to unit-test parsing.
+
+**How to apply:** `src/AcDream.App/RuntimeOptions.cs` is the typed
+options object. `Program.cs` builds it once from args + env and passes
+it to `GameWindow`. New startup flags add a field to `RuntimeOptions`
+and a parser in `RuntimeOptions.FromEnvironment`. They don't add
+`Environment.GetEnvironmentVariable` reads.
+
+**Scope:** `RuntimeOptions` is for **startup-time** configuration β
+things that don't change once the window is up. Runtime diagnostic
+toggles are Rule 5's domain.
+
+### Rule 5: Runtime diagnostic toggles live in diagnostic owner classes
+
+**Why:** Diagnostic flags (`ACDREAM_DUMP_MOTION`, `ACDREAM_PROBE_*`,
+etc.) need to be both env-readable at startup *and* runtime-toggleable
+from the DebugPanel. Per-call-site env reads can't be runtime-toggled.
+
+**How to apply:** Today's template is
+`src/AcDream.Core/Physics/PhysicsDiagnostics.cs` β one static class with
+typed `Probe*` properties read from env vars once at startup, plus
+runtime setters that the DebugPanel binds. New diagnostic flags follow
+this shape, not the per-call-site pattern that dominates `GameWindow.cs`.
+
+**Cleanup direction:** The dozens of existing `ACDREAM_DUMP_*` reads
+inside `GameWindow.cs` are tech debt. We do NOT bulk-migrate them as
+part of this refactor β they're working, they're scattered, and
+moving them carries risk without a current acceptor. We migrate them
+opportunistically: when a `GameWindow` extraction lands and a diagnostic
+moves with it, route it through the new owner's diagnostic class.
+
+### Rule 6: Tests live in the project matching the layer
+
+**Why:** Test discoverability + dependency hygiene. A test for a Core
+class belongs next to other Core tests; a test for an App class belongs
+in an App test project. Co-locating tests across layers makes the
+dependency graph dishonest.
+
+**How to apply:** One test project per source project that has tests.
+Today:
+
+- `tests/AcDream.Core.Tests/` β `src/AcDream.Core/`
+- `tests/AcDream.Core.Net.Tests/` β `src/AcDream.Core.Net/`
+- `tests/AcDream.UI.Abstractions.Tests/` β `src/AcDream.UI.Abstractions/`
+
+`AcDream.App` does **not** yet have a test project. The RuntimeOptions
+extraction is the trigger to create `tests/AcDream.App.Tests/`. Future
+App-layer tests (LiveSessionController, SelectionInteractionController,
+etc.) go there.
+
+---
+
+## 3. Target structure of the App layer
+
+The end state β not what we're shipping in one pass, but the shape
+we're aiming at.
+
+```
+src/AcDream.App/
+βββ Program.cs # parse args + env β RuntimeOptions, build GameWindow
+βββ RuntimeOptions.cs # typed startup options (Rule 4)
+βββ Rendering/
+β βββ GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
+β βββ RenderFrameOrchestrator.cs # per-frame draw order (sky β terrain β opaque β trans β particles β debug β UI)
+β βββ TerrainModernRenderer.cs # (already exists)
+β βββ TextureCache.cs # (already exists)
+β βββ ParticleRenderer.cs # (already exists)
+β βββ Sky/ # (already exists)
+β βββ Wb/ # (already exists β WB seam)
+β βββ Vfx/ # (already exists)
+βββ Net/
+β βββ LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
+βββ World/
+β βββ LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuidβentity.Id translation
+βββ Interaction/
+β βββ SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
+βββ Streaming/ # (already exists)
+βββ Input/ # (already exists)
+βββ Audio/ # (already exists)
+βββ Plugins/ # (already exists)
+```
+
+What `GameWindow` keeps:
+
+- `IWindow` / `GL` / `IInputContext` lifecycle (constructor + `OnLoad` +
+ `Run` + `OnClosing`).
+- `RuntimeOptions` reference (the typed startup config).
+- One field per collaborator (`_liveSessionController`,
+ `_liveEntityRuntime`, `_selectionInteraction`,
+ `_renderFrameOrchestrator`).
+- The Silk.NET event-handler stubs that delegate to collaborators.
+
+What `GameWindow` loses:
+
+- The 7 startup-time env var fields β moved into `RuntimeOptions`.
+- `TryStartLiveSession` + the post-login network drain β moved into
+ `LiveSessionController`.
+- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuidβId
+ translation β moved into `LiveEntityRuntime`.
+- `WorldPicker` + `_selectedGuid` + `SendUse` / `SendPickUp` β moved
+ into `SelectionInteractionController`.
+- Per-frame draw orchestration β moved into `RenderFrameOrchestrator`.
+
+The eventual `GameEntity` aggregation (target state described in
+`acdream-architecture.md` Β§"GameEntity: The Unified Entity") happens
+**after** `LiveEntityRuntime` is the single owner of entity state.
+Until then, the parallel-dicts problem is bounded inside one class
+instead of spread across `GameWindow`.
+
+---
+
+## 4. Extraction sequence β safest first
+
+Each step is **one PR-sized refactor**. Each must build clean, all
+tests pass, and visual verification at Holtburg looks identical to
+the previous step. Don't bundle two steps.
+
+### Step 1 β `RuntimeOptions` (this PR)
+
+**Scope:** Replace startup-time env var reads with a typed options
+object built once in `Program.cs`.
+
+**Behavior change:** None. Same env vars, same defaults, same effects.
+
+**Risk:** Low. Mechanical substitution at ~10-15 call sites in
+`GameWindow.cs` + one constructor signature change.
+
+**Test:** Unit tests for `RuntimeOptions.FromEnvironment` parsing (the
+new `tests/AcDream.App.Tests/` project).
+
+**Verification:** `dotnet build` + `dotnet test` green. Visual launch
+verifies live mode + dat dir resolution still work.
+
+### Step 2 β `LiveSessionController`
+
+**Scope:** Extract `TryStartLiveSession` + the WorldSession ownership +
+the post-EnterWorld drain (`OnLiveStateUpdated`, `OnLiveEntityDeleted`,
+etc.) into a controller class.
+
+**Behavior change:** None. Same wire behavior, same handshake.
+
+**Risk:** Medium. WorldSession lifecycle is load-bearing β every
+session-state crash would surface here. The change is a class
+extraction with the same event subscriptions, not a rewrite.
+
+**Test:** Existing `AcDream.Core.Net.Tests` already cover the wire
+layer. The controller itself gets a smoke test that verifies it can be
+constructed without a live socket (offline mode).
+
+**Verification:** Visual login + Holtburg traversal + door interaction
+identical to pre-extraction.
+
+### Step 3 β `LiveEntityRuntime` (or `EntityRuntimeRegistry`)
+
+**Scope:** Centralize the parallel dictionaries β `_entitiesByServerGuid`,
+the streaming entity lists, the player controller's player entity β
+into one owner. Surface the ServerGuidβentity.Id translation as a
+single API (eliminating the trap from L.2g slice 1c).
+
+**Behavior change:** None.
+
+**Risk:** Medium-high. Entity lookup is in every hot path. The change
+is structural (one owner instead of three) but the lookup semantics
+must be byte-identical.
+
+**Test:** Entity-spawn / despawn / lookup tests in
+`tests/AcDream.App.Tests/`. Existing visual verification at Holtburg
+catches any drift in interaction.
+
+**Verification:** Walk Holtburg, click NPC, open door, pick up item.
+All four M1 demo targets must still work.
+
+### Step 4 β `SelectionInteractionController`
+
+**Scope:** Extract `WorldPicker`, `_selectedGuid`, `SendUse`,
+`SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
+`SelectionPickUp` switch cases into one controller. Depends on Step 3
+(uses `LiveEntityRuntime`).
+
+**Behavior change:** None.
+
+**Risk:** Low-medium. Selection state is local to interactions; the
+network outbound side is well-defined (`InteractRequests.BuildUse` /
+`BuildPickUp`).
+
+**Test:** Selection state machine tests in `tests/AcDream.App.Tests/`.
+
+**Verification:** Click-to-select, double-click-to-Use, F-key pickup
+all still work.
+
+### Step 5 β `RenderFrameOrchestrator`
+
+**Scope:** Extract the per-frame draw sequence (sky β terrain β
+opaque mesh β translucent mesh β particles β debug β UI) into a
+dedicated orchestrator that `GameWindow.OnRender` delegates to.
+
+**Behavior change:** None. Same draw order, same GL state.
+
+**Risk:** Medium. GL state management is touchy; the orchestrator
+must hand the GL context to the same renderers in the same order with
+the same per-pass state setup.
+
+**Test:** Visual verification only. Render orchestration is hard to
+unit-test without a GL context.
+
+**Verification:** Holtburg at radius 4, radius 8, radius 12 looks
+identical across all four quality presets.
+
+### Step 6 β `GameEntity` aggregation (the big one)
+
+**Scope:** Consolidate `WorldEntity` + `AnimatedEntity` + the per-entity
+state in `LiveEntityRuntime` into one `GameEntity` class (the target
+described in `acdream-architecture.md`). Every entity in the world β
+player, NPC, monster, door, item β becomes a single `GameEntity`.
+
+**Behavior change:** None at the wire / visual level; substantial at
+the call-site level (everyone moves to the new entity API).
+
+**Risk:** High. Touches every system that reads entity state.
+
+**Test:** All existing tests + the new `AcDream.App.Tests` suite. Visual
+verification at every M1 / M2 scenario.
+
+**Verification:** Full M2 demo loop (equip sword, kill drudge, pick up
+loot, open inventory) works identically.
+
+---
+
+## 5. Rules of the road during the extraction
+
+1. **One step at a time.** A PR that ships Step 1 ships only Step 1.
+ Bundling steps makes failures hard to isolate.
+2. **Behavior preservation is the acceptance criterion.** Every step
+ must build clean, all tests pass, and visual verification at the
+ appropriate M1 / M2 scenarios must succeed. We're moving code, not
+ changing it.
+3. **No new features during an extraction step.** If you spot a real
+ bug while extracting, file it in `docs/ISSUES.md` and address it in
+ a separate commit (before or after the extraction, not folded into
+ it).
+4. **Diagnostic toggle migrations are opportunistic.** When a method
+ moves to a new owner, the diagnostic flag inside it can move to a
+ diagnostic class as part of the same commit. We do not do a bulk
+ diagnostic-cleanup pass.
+5. **Update this document when the plan changes.** If Step 3 turns out
+ to need a different shape than described above, update Β§4 in the
+ same session you discover the divergence.
+
+---
+
+## 6. What this document is **not**
+
+- **Not a full rewrite plan.** The point is the *opposite* β small
+ steps, verified at each boundary.
+- **Not blocking M2.** Step 1 is small enough to ship without
+ disrupting M2 work. Later steps interleave with M2 / M3 phases as
+ the corresponding code paths come into focus.
+- **Not a substitute for the milestones / roadmap.** Those drive the
+ feature work. This drives the structural work that runs underneath.
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
new file mode 100644
index 0000000..68144c9
--- /dev/null
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -0,0 +1,250 @@
+# WorldBuilder Inventory β what we take, adapt, or leave
+
+**Status:** load-bearing reference. As of 2026-05-08 acdream's strategy is
+to **rely heavily on WorldBuilder** for rendering and dat-handling rather
+than re-port retail algorithms ourselves. WorldBuilder is MIT-licensed, is
+verified by visual inspection to render the AC world correctly (terrain,
+scenery, slabs, dungeons, slopes, particles), and uses the same Silk.NET
++ .NET stack we already target.
+
+**Integration model:** **fork upstream WorldBuilder** at
+`github.com/Chorizite/WorldBuilder`, depend on our fork, delete editor-only
+code, expose hooks for our network state to feed scene data in. Sync with
+upstream via merge so we inherit fixes. This document tells you, before
+you write code, whether the thing you're about to port already exists in
+WorldBuilder.
+
+**Workflow change:** Before re-implementing any AC-specific rendering or
+dat-handling algorithm, **check this inventory first**. If WorldBuilder
+has it, port from WorldBuilder (or call into our fork once it's wired
+up), not from retail decomp. Retail decomp remains the oracle for things
+WorldBuilder lacks β animation, motion, physics collision, networking.
+
+---
+
+## Repo layout (as of cloned snapshot under `references/WorldBuilder/`)
+
+- **`Chorizite.OpenGLSDLBackend/`** β full OpenGL renderer (Silk.NET).
+- **`WorldBuilder.Shared/`** β data models, dat parsers, landscape module.
+- **`WorldBuilder/`** β Avalonia desktop app shell (NOT taken).
+- **`WorldBuilder.{Windows,Linux,Mac}/`** β platform entry points (NOT taken).
+- **`WorldBuilder.Server/`** β collab editing backend (NOT taken).
+- **`Tests/` + `WorldBuilder.Shared.Benchmarks/`** β test harness (study).
+
+**Upstream NuGet dependencies** (these stay as NuGet packages, we don't
+vendor them):
+
+| Package | Version | Purpose |
+|---|---|---|
+| `Chorizite.Core` | 0.0.18 | Plugin framework β contains `Chorizite.Core.Lib.BoundingBox`, `Chorizite.Core.Render.*` interfaces used by every render manager |
+| `Chorizite.DatReaderWriter` | 2.1.x | dat parsing (we already use 2.1.7) |
+| `Chorizite.DatReaderWriter.Extensions` | 1.1.x | extra dat helpers |
+| `BCnEncoder.Net` | 2.2.x | DXT decode (we already use) |
+| `SixLabors.ImageSharp` | 3.1.x | image loading |
+| `Silk.NET.OpenGL` + `Silk.NET.SDL` | 2.23.x | GL + windowing (we use Silk's own windowing, they use SDL) |
+| `MP3Sharp` | 1.0.5 | MP3 decode |
+
+---
+
+## π’ RENDERING β take wholesale or adapt
+
+These are what makes WB "perfect". Anything in this section, we should
+use from WB rather than re-implement.
+
+### Terrain
+
+| Component | What it does |
+|---|---|
+| `TerrainRenderManager` | Full pipeline (per-chunk GPU buffers, draw orchestration) |
+| `LandSurfaceManager` | Texture blending atlas (palCode, alpha masks, road overlays) |
+| `TerrainGeometryGenerator` | Heightmap β mesh, normals, OnRoad, GetHeight, GetNormal |
+| `TerrainChunk` | 16Γ16 landblock chunk geometry |
+| `TextureAtlasManager` | Texture atlas builder |
+| `VertexLandscape` | Terrain vertex format |
+
+### Scenery (procedural placement: trees, bushes, rocks, fences)
+
+| Component | What it does |
+|---|---|
+| `SceneryRenderManager` | Generate + render per-vertex scenery |
+| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope |
+| `SceneryInstance` | Per-spawn instance data |
+
+### Static objects (buildings, slabs, props β Setup + GfxObj + ObjDesc)
+
+| Component | What it does |
+|---|---|
+| `StaticObjectRenderManager` | Master pipeline for static objects |
+| `ObjectRenderManagerBase` + `BaseObjectRenderManager` | Common render base |
+| `ObjectMeshManager` | Mesh extraction from Setup/GfxObj, ObjDesc application |
+
+### Dungeons / interiors
+
+| Component | What it does |
+|---|---|
+| `EnvCellRenderManager` | Dungeon interior cell geometry |
+| `PortalRenderManager` | Portal traversal / visibility |
+
+### Sky + atmosphere
+
+| Component | What it does |
+|---|---|
+| `SkyboxRenderManager` | Skybox rendering |
+| `ParticleEmitterRenderer` + `ParticleBatcher` + `ActiveParticleEmitter` | Particle systems (sky particles, weather, magic) |
+
+### Visibility / culling
+
+| Component | What it does |
+|---|---|
+| `VisibilityManager` + `VisibilitySnapshot` | Frustum + cell visibility |
+| `Frustum` | Frustum-cull math |
+
+### Other rendering helpers
+
+| Component | What it does |
+|---|---|
+| `MinimapRenderer` | Top-down minimap |
+| `GlobalMeshBuffer` | Shared GPU mesh buffer |
+| `GpuResourceManager` | GPU resource lifecycle |
+| `InstanceData` | Instanced draw data |
+| `TextureHelpers` | INDEX16, P8, BGRA, DXT decode + alpha (canonical port) |
+| `DebugRenderer` + `DebugRendererLineDrawer` + `EdgeLineBuilder` | Debug primitives |
+
+### Shaders (22 total)
+
+Located at `Chorizite.OpenGLSDLBackend/Shaders/`:
+
+`Landscape.{vert,frag}` Β· `StaticObject.{vert,frag}` Β· `StaticObjectModern.{vert,frag}` Β· `Particle.{vert,frag}` Β· `PortalStencil.{vert,frag}` Β· `Outline.{vert,frag}` Β· `Simple3D.{vert,frag}` Β· `InstancedLine.{vert,frag}` Β· `Text.{vert,frag}` Β· `UI.{vert,frag}` Β· `Gizmo.{vert,frag}` (editor-only)
+
+---
+
+## π’ LOW-LEVEL GL / FRAMEWORK β take or replace with our own
+
+Either take WB's wrappers wholesale, or keep our own and adapt the
+render managers to use ours. These wrappers are stateless or
+near-stateless and are the easiest to swap.
+
+| Component | What it does |
+|---|---|
+| `OpenGLGraphicsDevice` | Silk.NET.OpenGL wrapper |
+| `OpenGLRenderer` | Render orchestration |
+| `GLSLShader` | Shader compile/link/uniforms |
+| `GLHelpers` + `GLStateScope` | GL state utility |
+| `ManagedGLFrameBuffer` / `ManagedGLIndexBuffer` / `ManagedGLTexture` / `ManagedGLTextureArray` / `ManagedGLUniformBuffer` / `ManagedGLVertexArray` / `ManagedGLVertexBuffer` | GL resource wrappers |
+| `TextureParameters` | Sampler config |
+| `GpuMemoryTracker` | Memory tracking |
+| `Camera2D` / `Camera3D` / `CameraBase` / `ICamera` / `CameraController` | Camera primitives |
+| `GameScene` + `SingleObjectScene` + `SceneData` + `ModernRenderData` + `RenderPass` | Scene / pass structures |
+
+---
+
+## π’ GEOMETRY / MATH UTILS β take wholesale
+
+| Component | File |
+|---|---|
+| `TerrainUtils` (OnRoad, GetNormal, GetHeight, GetRoad, palCode) | `WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs` |
+| `TerrainCacheManager` | `β¦/Lib/TerrainCacheManager.cs` |
+| `TerrainRaycast` | `β¦/Lib/TerrainRaycast.cs` |
+| `GeometryUtils` | `WorldBuilder.Shared/Lib/GeometryUtils.cs` |
+| `RaycastingUtils` (ray-vs-sphere/AABB/triangle) | `WorldBuilder.Shared/Lib/RaycastingUtils.cs` |
+| `DoubleNumerics` (double-precision Vector/Matrix) | `WorldBuilder.Shared/Lib/DoubleNumerics.cs` |
+| `DatUtils` | `WorldBuilder.Shared/Lib/DatUtils.cs` |
+| `BoundingBoxExtensions` | `Chorizite.OpenGLSDLBackend/Lib/BoundingBoxExtensions.cs` |
+
+---
+
+## π’ DATA MODELS β take selectively
+
+| Component | What it does |
+|---|---|
+| `RegionInfo` | Landblock metadata wrapper (LandblockSizeInUnits, CellSizeInUnits, etc.) |
+| `TerrainEntry` | Per-vertex terrain (Type/Scenery/Road/Height) |
+| `MergedLandblock` | Merged dat data |
+| `CellSplitDirection` | SW-NE vs NE-SW |
+| `Cell` | Generic cell wrapper |
+| `ObjectId` | Object identifier |
+| `Position` | World position |
+| `ACEnums` | AC-specific enums |
+| `WbBuildingPortal` / `WbCellPortal` | Portal structures |
+| `BuildingObject` | Building data |
+
+---
+
+## π‘ EDITOR-ONLY β leave behind / delete in fork
+
+These exist for the editor experience and have no place in a game
+client. Delete in fork.
+
+- **`Modules/Landscape/Tools/*`** β `BrushTool`, `BucketFillTool`,
+ `RoadLineTool`, `RoadVertexTool`, `InspectorTool`,
+ `ObjectManipulationTool`, `Gizmo*` (DragHandler, HitTester, Renderer,
+ State), `TexturePainting*`, `SceneRaycaster`,
+ `LandscapeBrush`, `LandscapeToolBase`, `LandscapeToolContext`,
+ `IToolSettingsProvider`, `ILandscapeBrush`, `ILandscapeEditorService`,
+ `ILandscapeRaycastService`, `ILandscapeTool`, `ITexturePaintingTool`
+- **`Modules/Landscape/Commands/*`** β undo/redo command pattern for
+ editor (Add/Delete/Move/Rename/Reorder/etc.)
+- **`LandscapeDocument` + `LandscapeLayer` + `LandscapeLayerGroup` + `LandscapeChunk` + `LandscapeLayerChunk` + `LandscapeLayerBase`** β editor document model
+- **`Modules/Landscape/Models/TerrainPatch*` + `LandblockChangedEventArgs`** β editor mutation events
+- **`Modules/Landscape/Services/ILandscapeCacheService` + `ILandscapeDataProvider` + `ILandscapeObjectService` + impls** β editor data flow
+- **All `Migrations/*`** β SQLite schema migrations (project file format)
+- **`Repositories/*`** + **`Services/*`** β project storage, dat repository, AceDb, SignalR sync, document manager, undo stack, world coordinates, keyword DB, project migration, semantic kernel AI helpers
+- **`Hubs/*`** β collaborative editing via SignalR
+- **`StaticObject` (editor model)** β replace with our own scene-state data model fed from network
+- **`BackendGizmoDrawer` + `GizmoRenderer`** β editor gizmos
+- **`ProjectStructures, IProject, Project`** β editor project files
+- **`KeyBinding`** β editor input binding
+- **`ViewportInputEvent[Extensions]`** β editor viewport input
+- **`EditorState`** β editor state container
+
+---
+
+## π‘ AUDIO / FONT β we already have alternatives
+
+Keep ours; don't take theirs.
+
+- **`AudioPlaybackEngine`** β uses MP3Sharp. We have OpenAL.
+- **`FontRenderer`** β uses ImageSharp. We have BitmapFont/StbTrueTypeSharp + ImGui.
+
+---
+
+## π΄ NOT IN WORLDBUILDER β port from retail decomp ourselves
+
+WorldBuilder is a dat editor; it does not have:
+
+- **Network protocol** β UDP framing, ISAAC, packet codec, ACE message
+ layer (we have this; oracle is `references/holtburger`)
+- **Physics** β collision (CPhysicsObj transitions, BSP queries, sphere
+ sweeps), step-up, walkable validation (we have partial; oracle is the
+ retail decomp at `docs/research/named-retail/`)
+- **Animation** β motion sequencer, cycle/non-cycle parts, animation
+ frame interpolation (we have this; oracle is retail decomp)
+- **Movement** β local player WASD β MoveToState wire, remote-entity
+ motion via UpdateMotion + dead-reckoning (we have this; oracle is
+ `references/holtburger` + retail decomp)
+- **Game UI** β chat, vitals, inventory, spell book, allegiance, options
+ (we have this; ImGui-based today, custom-toolkit later)
+- **Plugin API** β `IGameState`, `IEvents`, `IActions`, `IPacketPipeline`,
+ `IOverlay` (we have this β acdream-unique)
+- **Game events** β combat, allegiance, spell casting, quest events
+ (we have this; oracle is ACE for opcodes + retail for client behavior)
+- **Audio** β OpenAL pipeline, sound triggers (we have this)
+- **TurbineChat** + **slash commands** (we have this)
+- **Login + character selection flow** (we have this)
+
+---
+
+## What this means for the workflow
+
+The CLAUDE.md "grep named β decompile β verify β port" workflow stays
+the rule for everything in the π΄ list (network, physics, animation,
+movement, UI, plugin, audio, chat). For anything in π’, the new rule is:
+**check this inventory FIRST**. If WB has it, port from WB. Re-porting
+from retail decomp when WB already has a tested port is no longer
+appropriate β that's how we got the scenery edge-vertex bug.
+
+When the inventory says "take wholesale or adapt" and we discover a
+behavior mismatch with retail (rare β WB is verified), the resolution
+is: reconcile WB β retail decomp β holtburger β ACE β ACViewer (the
+existing reference hierarchy in CLAUDE.md). WorldBuilder ranks at the
+top of that hierarchy for anything π’.
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 91f7100..0fc0d30 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1,6 +1,6 @@
# acdream β strategic roadmap
-**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
+**Status:** Living document. Updated 2026-05-19. **Between phases.** **Since the last header update:** Indoor walkable-plane BSP port FOUNDATION shipped (6 commits, `ff548b9` β `f845b22`) but visual verification failed β cellar descent, 2nd-floor walking, single-floor cottage regressions all confirm the shipped fix doesn't address the user-reported indoor bugs. Root cause now diagnosed as deeper than originally thought: `TryFindIndoorWalkablePlane` exists as a Phase 2 stop-gap that retail doesn't have an analog for. Retail retains ContactPlane state across frames; we re-synthesize per frame. Foundation work (BSP walker + probe + tests) remains useful; next phase needs to port retail's ContactPlane retention mechanism and likely eliminate `TryFindIndoorWalkablePlane` entirely. Handoff: [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](../research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md). ISSUES #83 remains OPEN with deeper diagnosis. **Earlier:** Indoor cell rendering Phase 1 (diagnostics) + Phase 2 (fix) shipped β root cause was a one-line WB bug at `ObjectMeshManager.cs:1223` (blind `TryGet` on GfxObj-prefixed stab ids threw `ArgumentOutOfRangeException` which WB's outer catch silently swallowed, causing 26/123 Holtburg cells to fail upload). Identified via diagnostic chain (5 `[indoor-*]` probes + a `ContinueWith` exception surfacer + a `ConsoleErrorLogger` injected into WB), fixed with a Setup-prefix guard. User visually confirmed floors render. Surfaced 9 pre-existing indoor bugs filed in `docs/ISSUES.md`. **Earlier:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs β portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30β50Γ); C.1.5a shipped (portal PES wiring; surfaced #56 β resolved in C.1.5b).
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@@ -31,6 +31,7 @@
| A.1 | Streaming landblock loader β runtime-configurable visible window (default 5Γ5, `ACDREAM_STREAM_RADIUS`), camera-centered offline / player-centered live, hysteresis-based unloads, pending-spawn list for late CreateObject events | Live β |
| A.2 | Frustum culling β per-landblock AABB test (Gribb-Hartmann), terrain + static-mesh renderers skip culled landblocks, perf overlay in window title | Visual β |
| A.3 | Background net receive thread β dedicated daemon thread buffers UDP into Channel, render thread drains | Visual β |
+| A.5 | Two-tier streaming + horizon LOD β Nβ=4 (full detail, 81 LBs) + Nβ=12 (terrain only, 544 LBs); fog blend at Nβ; per-LB entity dispatcher walk tightened (Change #1 animated-walk fix + Change #2 cached AABB); single-worker off-thread mesh build; mipmaps + 16x anisotropic on TerrainAtlas; A2C with MSAA 4x on foliage; depth-write audit + lock-in test; **NEW T22.5: QualityPreset system** (Low/Medium/High/Ultra) with per-preset radii + MSAA + anisotropic + A2C + completions; env-var overrides per field; F11 mid-session re-apply. **Bug fixes post-T26 ship-prep**: (Bug A) far-tier worker now strips entities from far-tier loads β without this fix, far-tier LBs were loading their full entity layer (~71K entities) defeating the two-tier optimization; (Bug B) WalkEntities switched from per-frame fresh-list allocation to caller-provided scratch list (eliminated ~480 KB/frame GC pressure). **Deferred to post-A.5**: Tier 1 entity-classification cache (first attempt broke animation; revert + redo with animation-mutation audit), lifestone visual (missing in render), JobKind plumbing through BuildLandblockForStreaming (proper Bug A fix), Tier 2/3 perf optimizations (roadmap at docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md). Plan archived at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md. | Live β |
| B.3 | Physics MVP resolver foundation β terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests β |
| B.2 | Player movement mode β Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live β |
| D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual β |
@@ -57,6 +58,21 @@
| K | Input architecture β `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live β |
| L.0 | Full retail-style Settings interface β F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live β |
| C.1 | PES particle system + sky-pass refinements β retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive β pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` β alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 β 1331 tests. | Live β |
+| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live β |
+| N.3 | WorldBuilder-backed texture decode β `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha β `FillA8Additive`, non-additive entity surfaces β `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live β |
+| N.4 | Rendering pipeline foundation β adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6 (retired early in N.5 ship amendment). | Live β |
+| N.5 | Modern rendering path β lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. **Ship amendment 2026-05-08:** legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag`) retired within N.5 β modern path is mandatory; missing bindless throws `NotSupportedException` at startup. N.6 scope narrowed accordingly. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live β |
+| N.5b | Terrain on the modern rendering path β `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 Β΅s / 9-14 Β΅s p95 vs legacy 1.5 Β΅s / 3.0 Β΅s β **modern is ~4Γ SLOWER on CPU at radius=5** because legacy's 16Γ16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("β₯10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead β N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live β |
+| N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 30β50Γ at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live β |
+| C.1.5a | Portal PES wiring β server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity` β `OnCreate`, `RemoveEntityByServerGuid` β `OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts β visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live β (with #56) |
+| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuidβentity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c β THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live β |
+| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer linkβcycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live β |
+| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled β surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted β OnLiveEntityDeleted β RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored β out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live β |
+| Indoor lighting + rendering β Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing β confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests β |
+| Indoor lighting + rendering β Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids β surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger` with a Console-backed `ConsoleErrorLogger` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 β `TryGet(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` β needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) β all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live β |
+| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** β new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` β `[Default]` β first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** β activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (FarβNear promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (NearβFar demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec Β§3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) β no synthetic-ID scheme or separate walker class needed (handoff Β§4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live β |
+| Indoor walking Phase 1 β BSP cluster (partial) | 2026-05-19. Probe + WorldPicker cell-BSP occlusion (#86 closed) + CellId promotion via AABB containment (partial #84 fix). Seven commits across 5 phases: `18a2e28` plan, `27d7de1` Phase A `[indoor-bsp]` probe + toggle, `3764867` Phase B CellBspRayOccluder in WorldPicker, `4e308d5` Phase B screen-rect tests, `c19d6fb` Phase D AABB containment + L.2e bare-low-byte fix, `fda6af7` Phase E `[cell-cache]` diagnostic, `1f11ba9` Phase E extended AABB/bsphere/poly-count fields. **#86 closed** (picker occlusion). **#84 partially closed** (spawn-in-building stuck-above-floor resolved; threshold/doorway walls remain open under #87). **#85 open** (wall pass-through root cause confirmed as same as #84 remaining symptom β CellId doesn't stay promoted during outdoorβindoor walking). **#87 filed** (portal-based indoor cell tracking β retail-faithful follow-up). `[indoor-bsp]` + `[cell-cache]` probes stay in place as scaffolding for the follow-up phase. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](../research/2026-05-19-cluster-a-shipped-handoff.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md). | Tests β |
+| Indoor walking Phase 2 β Portal-based cell tracking | 2026-05-19. Portal-graph traversal replaces Phase D's AABB containment. Six commits: `1969c55` CellBSP+Portals wired into CellPhysics; `aad6976` CellTransit.FindCellList + FindTransitCellsSphere + AddAllOutsideCells + ResolveCellId rename; `069534a` BuildingPhysics + CheckBuildingTransit for outdoorβindoor entry; `702b30a` code-review polish; `3ffe1e4` pass foot-sphere center to ResolveCellId (critical fix β was passing CheckPos instead of GlobalSphere[0].Origin, causing PointInsideCellBsp to return false at floor level); `eb0f772` TryFindIndoorWalkablePlane synthesizes walkable plane from cell floor poly so the resolver doesn't fall through to outdoor SampleTerrainWalkable. **Closes #87, #85, and the wall-pass-through portion of #84 (fully closes #84).** Files #88 (indoor static object vibration β pre-existing) and #89 (BSPQuery.SphereIntersectsCellBsp β approximation in CheckBuildingTransit). `[cell-transit]`, `[indoor-bsp]`, `[check-bldg]`, `[cell-cache]` probes stay in place. Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md). | Live β |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@@ -77,6 +93,7 @@ Plus polish that doesn't get its own phase number:
- **β SHIPPED β A.2 β Frustum culling.** Per-landblock AABB test (Gribb-Hartmann plane extraction + positive-vertex AABB test) in both `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw`. Per-entity culling deferred. LOD deferred to Phase C. Performance overlay in window title shows FPS, frame time, visible/total landblock ratio, entity count, animated count. ~160fps uncapped at 5Γ5 radius.
- **β SHIPPED β A.3 β Background net receive thread.** Dedicated daemon thread continuously pulls raw UDP datagrams from the kernel buffer into a `Channel`. Render thread's `Tick()` drains the channel. All decode, fragment assembly, ISAAC crypto, event dispatch, and ack-sending remain on the render thread β minimal change that prevents packet drops during frame stalls. Thread starts after `EnterWorld()` completes; `PumpOnce()` during handshake still reads the socket directly.
- **A.4 β Async dat decoding.** Folded into the streaming worker β it's the worker's read path, not a separate subsystem. Called out here because regressions in dat caching could land on this surface.
+- **β SHIPPED β A.5 β Two-tier streaming + horizon LOD.** Shipped 2026-05-10. See shipped table above for full description. Plan archived at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`.
**Acceptance:**
- Walk across 10+ landblocks in any direction, no crashes, no empty voids.
@@ -113,6 +130,10 @@ Plus polish that doesn't get its own phase number:
**Sub-pieces:**
- **β SHIPPED β C.1 β VFX / particle system + sky-pass refinements.** Retail-faithful `ParticleEmitterInfo` runtime + 13-type motion integrator port + `PhysicsScript` runner + instanced billboard renderer with material-derived blend + global back-to-front sort + AttachLocal live-parent follow. Sky-pass refinements: Translucent+ClipMap alpha-blend, raw-Additive fog-skip, per-keyframe SkyObjectReplace divide-by-100, sampler-object wrap selection (ported from WorldBuilder), gated post-scene Z-offset. Sky-PES disabled by default β named-retail decomp proves `GameSky` drops `pes_id`. **Portal swirls, chimney smoke, fireplace flames** still need a Phase C.1.5 follow-up to wire entity-attached emitters to retail effect IDs (the data layer is ready; only the wiring is deferred). Lands as merge `feat(vfx): Phase C.1 β PES particle renderer + post-review fixes` (`ec1bbb4`) + `refactor(sky): replace per-frame wrap-mode mutation with persistent samplers` (`3d21c13`).
+- **C.1.5 β entity-attached PES wiring (sliced).** Three sub-slices wiring `PhysicsScript` / `DefaultScript` dispatch to the entity lifecycle so portals, chimneys, fireplaces, and sky effects animate per retail:
+ - **β SHIPPED β C.1.5a (portals)** β 2026-05-11 (merge `88bda12`). `EntityScriptActivator` fires `Setup.DefaultScript` on every server-spawned `WorldEntity` via `PhysicsScriptRunner`. Visual-verified at Holtburg Town network portal. Surfaced known limitation as issue #56 (per-part transform handling) β addressed in C.1.5b. Plan archived at [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
+ - **β SHIPPED β C.1.5b (per-part transforms + EnvCell statics)** β 2026-05-12. Closes #56. `SetupPartTransforms.Compute` + `ParticleHookSink.SetEntityPartTransforms` + `SpawnFromHook` part-transform application β multi-emitter scripts now distribute across mesh parts. `EntityScriptActivator` `ServerGuid==0` guard relaxed (keys by `entity.Id` when ServerGuid is zero) + 4 new `GpuWorldState` fire-sites pick up dat-hydrated entities (EnvCell statics + exterior stabs) β fireplaces and chimneys now fire their `DefaultScript` automatically. Reality discovery during design: EnvCell statics are already hydrated as `WorldEntity` items by `BuildInteriorEntitiesForStreaming`, so no synthetic-ID scheme or separate walker was needed. Visual-verified at Holtburg portal + Inn fireplace + cottage chimney + spell cast. Plan archived at [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md).
+ - **PLANNED β C.1.5c (sky-PES dispatch chain).** Promoted from former issue #36 (2026-05-11 triage). Ports retail's persistent-emitter creation on celestial / sky objects + the PES timeline driver (`CallPESHook::Execute` β `CPhysicsObj::CallPES` β `create_particle_emitter`) that drives them ~150Γ/min. Decomp anchors + live-trace evidence + 6-step impl outline in closed issue [#36](../ISSUES.md#36). **Closes #2 (lightning), #28 (aurora), #29 (cloud thinness) when shipped.** Does NOT close #4 (sky horizon-glow fog) β that's shader work, not PES.
- **C.2 β Dynamic point lights.** Fireplaces and lamps need local lighting; small upgrade to the mesh shader to accumulate N (e.g., 4) nearest point lights per draw. Uniform-buffer or UBO-friendly layout.
- **C.3 β Palette range tuning.** Small per-range offset/length tweaks to match retail skin/hair/eye colors. Mostly diff and verify work, no architecture change.
- **C.4 β Double-sided translucent polys.** Edge case left by Phase 9.2: neg-side translucent polys are culled because cull is always BACK. Fix by tracking per-sub-mesh `CullMode` and flipping GL state per draw (or drawing twice with opposite cull). Minor.
@@ -195,6 +216,7 @@ Research: R1 + R2 + R6 + R8 + UI slices 04/05.
- **F.3 β Combat math + damage flow.** Damage formula, per-body-part AL, crit, hit-chance sigmoid. Server broadcasts damage events; client displays + HP bar. See `r02-combat-system.md` + `src/AcDream.Core/Combat/`.
- **F.4 β Spell cast state machine.** `SpellCastStateMachine` + active buff tracking. Buffs + recalls first, projectile spells later. Fizzle sigmoid + mana conversion. See `r01-spell-system.md` + `src/AcDream.Core/Spells/`.
- **F.5 β Core panels.** Attributes / Skills / Paperdoll / Inventory / Spellbook β using the retail-ui framework from Phase D.2. See `05-panels.md` under retail-ui. **(Targets `AcDream.UI.Abstractions`; unblocked by D.2a β ships with ImGui widgets β and reskinned when D.2b lands.)**
+ - **F.5a β Visible-at-login dev panels.** First deliverable on top of #13 (PD trailer parser shipped 2026-05-10): wire `PlayerDescriptionParser.Parsed.{Inventory, Equipped, Shortcuts, HotbarSpells, DesiredComps, Options1, Options2, SpellbookFilters}` and `ItemRepository.Items` into minimal ImGui dev panels under `ACDREAM_DEVTOOLS=1` so the parsed data is observable in-game without a real retail-skin panel. Establishes the binding pattern (`AcDream.UI.Abstractions` ViewModels β ImGui renderer) the eventual D.2b retail-skinned panels reuse. Acceptance: log in, open dev overlay, see your inventory list / hotbars / shortcuts / character-options bitfields populated from the live PD message. **Targets:** `src/AcDream.UI.Abstractions/` (ViewModels) + `src/AcDream.App/UI/ImGui/` (panels). Spec to brainstorm before code.
**Acceptance:** equip a weapon, swing at a monster, see damage numbers, buff yourself, recall to the lifestone.
@@ -204,7 +226,8 @@ Research: R9 + R12 + R13.
- **β SHIPPED β G.1 β Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSessionβWorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **β SHIPPED β G.2 β Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
-- **G.3 β Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on L.2e** for trustworthy `cell_bsp`, indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
+- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
+- **G.3 β Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on indoor portal-based cell tracking above** (and previously on L.2e) for trustworthy indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
@@ -424,19 +447,40 @@ EchoRequest/EchoResponse handling, runtime ping/timeout policy, and a typed
protocol/action layer. These gaps will become expensive as movement, dungeons,
inventory, combat, and plugins depend on stable packet semantics.
-**Plan of record:** create
-`docs/superpowers/specs/2026-05-02-network-stack-conformance.md` before
-implementation starts. Treat holtburger as the client-behavior oracle for this
-phase; cross-check wire details against named retail, ACE, Chorizite, and AC2D
-before porting.
+**Plan of record:** Detailed design spec at
+[`docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md`](../superpowers/specs/2026-05-10-phase-m-network-stack-design.md)
+(supersedes the planned-but-never-written `2026-05-02-network-stack-conformance.md`
+the original entry referenced). The spec defines: **Bar C** ("wireable on demand")
+as the completeness target; a **three-layer architecture** (`INetTransport` /
+`IReliableSession` / `IGameProtocol`) with `WorldSession` as a thin behavior
+consumer on top; a **worktree-branch big-bang** migration model on
+`claude/phase-m-network-stack` with weekly rebase cadence and single-merge ship;
+per-sub-phase entry/exit gates with hour estimates; conformance test plan
+(golden vectors + live capture replay + live ACE smoke); risk register; and a
+**256-hour / ~6.4-week single-developer cost estimate** (4β6 weeks calendar
+with subagent parallelization on M.1 and M.6). Treat holtburger as the
+client-behavior oracle, ACE as server-outbound authority, named retail decomp
+as wire-format ground truth.
-**Sub-lanes:**
-- **M.1 β Audit & parity map.** Produce a source-by-source comparison of
- acdream `AcDream.Core.Net` and holtburger `holtburger-session`,
- `holtburger-protocol`, and `holtburger-core` networking code. Inventory each
- packet flag, optional header, session transition, control packet, fragment
- path, game message, and game action. Mark each as `parity`, `partial`,
- `missing`, or `intentional divergence`.
+**2026-05-10 update:** holtburger pulled to `629695a` (+237 commits since
+last audit). First parity-pass at
+[`docs/research/2026-05-10-holtburger-network-stack-study.md`](../research/2026-05-10-holtburger-network-stack-study.md);
+formal opcode coverage matrix (M.1's main deliverable) under construction
+at `docs/research/2026-05-10-phase-m-opcode-matrix.md` via parallel
+class-by-class agent dispatch. Most relevant recent holtburger commits:
+`99974cc` (session crate split + retransmit core), `403bc98` (port-switch
+race), `336cbad` (turning + locomotion fix), `797aece` (disconnect
+carries client_id). Six "Tier 1" quick-wins identified by the study
+(originally tracked as M.0) are folded into M.3 / M.4 / M.6 per the
+spec β they no longer ship as a separate sub-phase.
+
+**Sub-lanes:** *(brief summary; the spec has full entry/exit criteria,
+conformance gates, and hour estimates for each.)*
+- **M.1 β Audit & opcode matrix.** Build the per-opcode coverage table
+ citing holtburger / ACE / named retail / acdream-today / Phase M target.
+ Status: parity-pass done; matrix construction in flight via per-class
+ agent dispatch (transport flags + optional headers, GameMessages,
+ GameEvents, GameActions). 16h.
- **M.2 β Layer extraction.** Split the low-level stack under `WorldSession`
into testable components: `INetTransport`, `PacketCodec`,
`ReliablePacketSession`, `FragmentSession`, `GameMessageSession`, and the
@@ -498,6 +542,227 @@ before porting.
---
+### Phase N β WorldBuilder Rendering Migration
+
+**Goal:** Stop re-porting AC-specific rendering / dat-handling
+algorithms. Depend on a fork of `Chorizite/WorldBuilder` (MIT) for
+terrain, scenery, static objects, EnvCells, portals, sky, particles,
+texture decoding, mesh extraction, and visibility. Acdream keeps its
+own network, physics, animation, motion, UI, plugin, audio, chat
+layers (those aren't in WB).
+
+**Why now (2026-05-08):** the scenery edge-vertex bug at landblock
+`0xA9B1` was the third subtle porting bug in a quarter (after the
+triangle-Z bug and the hover-over-terrain bug). Even when our code
+looked byte-identical to WB's, our output diverged. WB renders the
+world correctly; the cost of "we re-port retail algorithms" is now
+higher than "we depend on WB's tested port."
+
+**Design + inventory:**
+
+- `docs/architecture/worldbuilder-inventory.md` β full taxonomy of
+ what WB has and what we keep porting ourselves.
+- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` β
+ parent design doc.
+- `docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md` β
+ N.1 detailed design.
+
+**Integration model:** fork at
+`https://github.com/eriknihlen/WorldBuilder` (already created), git
+submodule replacing `references/WorldBuilder/` snapshot, project
+references in our solution. Long-lived `acdream` branch in the fork
+for our deletions/additions; merge upstream `master` periodically.
+
+**Lessons from N.1 (apply to N.2-N.10):**
+
+1. **Per-helper conformance tests work.** The N.1 conformance test caught a
+ ~180Β° rotation bug in our retail port that had been silently wrong
+ forever. Write the conformance test BEFORE the substitution in each
+ sub-phase.
+
+2. **ACME β Chorizite/WorldBuilder.** ACME is a downstream fork of WB with
+ additional retail-faithful filters that upstream WB (our submodule)
+ doesn't have. When a visual discrepancy appears, check ACME's source
+ (`references/WorldBuilder-ACME-Edition/`) for delta filters BEFORE
+ investigating retail decomp directly. ACME's deltas tend to come as
+ coherent units β porting one filter without its companions can
+ over-suppress.
+
+3. **"Whackamole" is the warning sign.** If a phase generates 3+ visual
+ regressions on default-on, stop, accept the cosmetic deltas as
+ ISSUES.md entries, ship the migration. Bugs we leave behind are
+ debuggable; bugs we never ship are forgotten.
+
+4. **Subagent-driven execution holds up at this scope.** Fresh subagent
+ per task with the full task text inline keeps quality high without
+ polluting the controller's context. Each task should be self-contained
+ enough that a subagent without session history can complete it.
+
+**Sub-phases (strangler-fig with feature flags):**
+
+- **β SHIPPED β N.0 β Setup.** Shipped 2026-05-08 (commit `c8782c9`).
+ WorldBuilder fork at `github.com/eriknihlen/WorldBuilder.git` registered
+ as git submodule at `references/WorldBuilder/` tracking the `acdream`
+ branch. `AcDream.Core.csproj` references `WorldBuilder.Shared` +
+ `Chorizite.OpenGLSDLBackend`. Build green, all 28 scenery/terrain tests
+ passing.
+- **β SHIPPED β N.1 β Scenery algorithm calls.** Shipped 2026-05-08.
+ Replaced `IsOnRoad` / `DisplaceObject` / slope-normal calc / rotation /
+ scale inside `SceneryGenerator.Generate()` with calls to WB's
+ `SceneryHelpers` + `TerrainUtils`. Adapter `WbSceneryAdapter` produces
+ `TerrainEntry[]`. Visual verification at Holtburg confirmed Issue #49's
+ previously missing edge-vertex trees still visible after the migration;
+ rotation bug fixed (our retail port's `yawDeg = -(450-degrees)%360`
+ formula was ~180Β° off from retail's actual `Frame::set_heading` atan2
+ round-trip). One known cosmetic difference filed in ISSUES.md
+ (road-edge tree at landblock 0xA9B1).
+- **N.2 β Terrain math helpers.** β οΈ **Blocked on N.5 β do not attempt
+ in isolation.** Originally scoped as a 1-2 day low-risk substitution
+ of `TerrainSurface.SampleZ` / `SampleSurface` / `SampleSurfacePolygon`
+ with WB's `TerrainUtils.GetHeight` / `GetNormal`. Audit during N.3
+ follow-up uncovered that **WB's `CalculateSplitDirection` uses a
+ different formula than retail's `FSplitNESW`** (the AC2D-cited
+ polynomial `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`
+ that our visual terrain mesh and physics already share). The
+ formulas pick different cell-diagonals on disputed cells, producing
+ up to ~2m Z divergence at the same world position. Substituting
+ physics-side alone would un-sync physics from the still-ours visual
+ mesh β exactly the triangle-Z hover bug class. N.1's conformance
+ test proved WB's `GetNormal` is good enough for slope-filtering
+ (boolean walkable check) but NOT that WB's height formula matches
+ retail. Resolution: fold this work into **N.5** when the visual
+ mesh switches to WB's renderer in lockstep with physics. Until
+ then, leave `TerrainSurface` alone. See ISSUE #51.
+- **β SHIPPED β N.3 β Texture decoding.** Shipped 2026-05-08. `SurfaceDecoder`
+ now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8 to WB's
+ `TextureHelpers.Fill*`. The A8 divergence (our old code did R=G=B=A=val
+ always; WB splits additive vs non-additive) was resolved by threading an
+ `isAdditive` parameter through `DecodeRenderSurface`: terrain alpha masks
+ pass `isAdditive: true` (matches our prior behavior, preserves the
+ shader's `.r` blend-weight read), entity surfaces pass
+ `surface.Type.HasFlag(SurfaceType.Additive)`. Bonus: R5G6B5 + A4R4G4B4
+ formats now decode (previously fell to magenta). X8R8G8B8, DXT1/3/5, and
+ SolidColor remain ours (no WB equivalent). **9 conformance tests prove
+ byte-identical equivalence per format** before substitution; updated
+ `SurfaceDecoderTests` to match the new A8 split semantics. Visual
+ verification at Holtburg passed 2026-05-08 β no texture regressions.
+- **β SHIPPED β N.4 β Rendering pipeline foundation.** Shipped 2026-05-08.
+ WB's `ObjectMeshManager` is integrated as the production mesh pipeline
+ behind `ACDREAM_USE_WB_FOUNDATION=1` (default-on). The integration is
+ three pieces: `WbMeshAdapter` (single seam owning the WB pipeline,
+ drains the staged-upload queue per frame, populates
+ `AcSurfaceMetadataTable` for translucency / luminosity / fog),
+ `WbDrawDispatcher` (production draw path β groups all visible
+ (entity, batch) pairs, uploads matrices in a single `glBufferData`,
+ fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group
+ with `BaseInstance` slicing the shared instance VBO), and the
+ `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge that wires our
+ streaming loader to WB's `IncrementRefCount` / `PrepareMeshDataAsync`
+ lifecycle (atlas tier vs per-instance customized).
+ Issue #47 (close-detail mesh) preserved; sky pass structurally
+ independent of the WB foundation. Perf wins shipped as part of N.4:
+ per-entity AABB frustum cull, opaque front-to-back sort, palette-hash
+ memoization. Legacy `InstancedMeshRenderer` retained as flag-off
+ fallback until N.6 fully retires it. Plan archived at
+ `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
+- **β SHIPPED β N.5 β Modern rendering path.** Shipped 2026-05-08.
+ **Rebranded from "Terrain rendering" 2026-05-08 after N.4 perf
+ review.** Lifted `WbDrawDispatcher` onto bindless textures
+ (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame
+ entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch
+ data @ binding=1, indirect commands) + 2 indirect calls (opaque +
+ transparent). ~12-15 GL calls per frame regardless of group count, down
+ from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median
+ at Holtburg (1662 groups, ~810 fps). All textures on the modern path use
+ 1-layer `Texture2DArray` + `sampler2DArray`; legacy callers retain
+ `Texture2D` via the parallel `TextureCache` path until N.6 retires them.
+ Three gotchas in memory (`project_phase_n5_state.md`): texture target
+ lock-in, bindless Dispose two-phase order, GL_TIME_ELAPSED double-
+ buffering. Plan archived at
+ `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`.
+- **β SHIPPED β N.5b β Terrain on the modern rendering path.** Shipped
+ 2026-05-09. **Path C** (mirror WB's `TerrainRenderManager` pattern but
+ consume `LandblockMesh.Build` for retail-formula compliance). Path A
+ (substitute WB's `CalculateSplitDirection`) killed during pre-implementation
+ divergence test: WB's formula disagrees with retail's `FSplitNESW`
+ (addr `00531d10`) on **49.98%** of cells across `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`'s
+ sweep β wholly incompatible with our shared physics + visual mesh.
+ Path B (fork-patch WB to use retail's formula) rejected for permanent
+ maintenance burden. Path C ships the architectural pattern (single
+ global VBO/EBO + slot allocator + bindless atlas + `glMultiDrawElementsIndirect`)
+ while keeping retail's formula via `LandblockMesh.Build` β
+ `TerrainBlending.CalculateSplitDirection`. `TerrainModernRenderer` +
+ `terrain_modern.vert/.frag` shipped, `TerrainChunkRenderer` +
+ `TerrainRenderer` + legacy `terrain.vert/.frag` deleted in T9.
+ Closes ISSUE #51. **Perf reality check:** at radius=5 in Holtburg,
+ modern is ~4Γ SLOWER on CPU than legacy was (6.4 Β΅s vs 1.5 Β΅s median;
+ legacy collapsed radius=5's visible LBs into one `glDrawElements`
+ via 16Γ16-LB chunking). Architectural wins (zero `glBindTexture`/frame,
+ constant-cost dispatch as A.5 raises radius, per-LB frustum cull)
+ manifest at higher radius. Spec acceptance criterion #5 was wrong;
+ amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Plan
+ archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`.
+- **β SHIPPED β N.6 slice 1 β GPU timing fix + radius=12 perf baseline.** Shipped 2026-05-11.
+ Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3
+ query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel
+ desktop GL). Added env-gated surface-format histogram dump in `TextureCache`
+ for atlas-opportunity audit. Captured authoritative baseline at Holtburg
+ radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us`
+ diagnostic. Plan + spec at `docs/superpowers/{specs,plans}/2026-05-11-phase-n6-slice1-*.md`.
+ Baseline numbers + next-phase recommendation at
+ [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md).
+- **N.6 slice 2 β Perf polish cleanup.** **Planned β deferred until after C.1.5
+ (PES emitter wiring) per the baseline doc's recommendation.** Builds on
+ slice 1's measurement. Scope: retire the legacy `Texture2D`/`sampler2D` path
+ in `TextureCache` (currently kept for Sky + Debug + particle paths now that
+ Terrain has migrated); delete orphan `mesh.frag` (verify zero callers post-N.5
+ amendment); decide bindless-everywhere vs legacy-island for the remaining
+ `sampler2D` consumers. **Dropped from slice 2 scope per baseline data**:
+ WB atlas adoption and persistent-mapped buffers β both target GPU/sampler
+ throughput but the baseline shows GPU is wildly under-utilized (max gpu_us
+ p95 ~600 Β΅s vs 16,600 Β΅s frame budget). Slice 2 reduces to a ~1-day cleanup.
+ Plan + spec written when work begins. **Estimate: ~1 day once C.1.5 lands.**
+- **N.7 β EnvCells / dungeons.** Replace EnvCell rendering with WB's
+ `EnvCellRenderManager` + `PortalRenderManager` on top of N.4's
+ foundation. **Estimate: 1-2 weeks** (was 2-3 β naturally smaller now
+ that infrastructure is shared).
+- **N.8 β Sky + particles.** Replace sky rendering + particle pipeline
+ (#36 / C.1 work) with WB's `SkyboxRenderManager` +
+ `ParticleEmitterRenderer`. **Estimate: ~1 week** (was 1.5-2 β C.1
+ already shipped most of this; N.8 is glue + sampler-object reuse).
+- **N.9 β Visibility / culling.** Replace `CellVisibility` +
+ `FrustumCuller` with WB's `VisibilityManager`. **Estimate: ~1 week**
+ (was 3-5 days, slight bump for streaming-loader interaction).
+- **N.10 β GL infrastructure consolidation (optional).** Replace our
+ `Shader` / `TextureCache` / `SamplerCache` plumbing with WB's
+ `ManagedGL*` wrappers + `OpenGLGraphicsDevice`. **Largely subsumed by
+ N.4** β `OpenGLGraphicsDevice` arrives as the host of `ObjectMeshManager`
+ and atlas. May not need a dedicated phase; revisit after N.6.
+
+**Estimated calendar:** **2.5-3 months / 9-13 engineering weeks for
+N.4-N.9 (N.10 likely subsumed; N.2 folded into N.5; N.3 shipped).**
+Revised 2026-05-08 after recognizing N.4-N.6 are one rendering rebuild
+on shared infrastructure rather than three independent substitutions.
+
+**Each sub-phase:**
+- Ships behind `ACDREAM_USE_WB_=1` flag.
+- Has its own conformance test (side-by-side against existing path).
+- Visual verification before flag becomes default-on.
+- Old code deleted after default-on lands cleanly.
+
+**N.2-N.10 detailed specs are NOT yet written** β each gets its own
+brainstorm + spec when we reach it.
+
+**Acceptance:**
+- All 10 sub-phases shipped, feature flags removed, old rendering code
+ paths deleted.
+- Visual verification at Holtburg + Foundry statue + a representative
+ dungeon shows no regression vs Phase C.1.
+- WB upstream merges into our `acdream` branch are clean (or have
+ documented conflict-resolution patterns).
+
+---
+
### Phase J β Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant.
diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md
index 8bfc2c1..05ab3b0 100644
--- a/docs/plans/2026-04-29-movement-collision-conformance.md
+++ b/docs/plans/2026-04-29-movement-collision-conformance.md
@@ -92,6 +92,35 @@ Goal: make every bad movement outcome explainable.
- Build real-DAT fixture capture for known walls, building ledges, rooftops,
slopes, landblock seams, and dungeon entrances.
+Current shipped slices:
+
+- 2026-04-30: cdb + TTD retail-observer toolchain (`tools/pdb-extract/`,
+ `tools/ttd-record.ps1`, `tools/ttd-query.ps1`) with PDB pairing checker
+ and ring-buffer trace replay. The "retail observer harness" line item.
+- 2026-04 (pre-L.2 rename): `ACDREAM_DUMP_MOVE_TRUTH` paired
+ outbound/server-echo dumper in `GameWindow` covers outbound packet
+ fields + server echo + correction delta with cell-id mismatch.
+- Pre-L.2: scenario-specific dumps `ACDREAM_DUMP_MOTION`,
+ `ACDREAM_DUMP_STEEP_ROOF`, `ACDREAM_DUMP_STEPUP`,
+ `ACDREAM_DUMP_EDGE_SLIDE` for the codepaths hit during prior bug chases.
+- 2026-05-12 (slice 1): general-purpose probes via new
+ `AcDream.Core.Physics.PhysicsDiagnostics` static class.
+ `ACDREAM_PROBE_RESOLVE` emits one `[resolve]` line per
+ `PhysicsEngine.ResolveWithTransition` call (input/output pos+cell,
+ ok-vs-partial, grounded-in, contact-plane status, wall normal if hit,
+ walkable polygon valid, moving entity id).
+ `ACDREAM_PROBE_CELL` emits one `[cell-transit]` line per
+ `PlayerMovementController.CellId` change with oldβnew + position +
+ reason tag (`resolver`/`teleport`). Both flippable live via the
+ DebugPanel "Diagnostics" section β checkbox toggles take effect on
+ the next resolve, no relaunch required.
+
+Remaining L.2a work: contact-plane probe (general, not just steep-roof),
+ShadowObjectRegistry hit log ("you collided with entity X"), water probe,
+real-DAT fixture-capture pipeline, and folding the older sticky-at-startup
+`ACDREAM_DUMP_*` flags into `PhysicsDiagnostics` for unified runtime
+toggling.
+
### L.2b - Movement Wire / Contact Authority
Goal: stop sending movement packets that claim more certainty than the local
@@ -140,6 +169,41 @@ fallback.
- Audit `Setup.Radius` and cylinder fallback behavior against retail before
relying on them for conformance.
+Current sub-direction (revised 2026-05-13 evening after slice 1 + 1.5
+shipped and Holtburg-doorway capture analyzed β third reframe):
+L.2d as scoped ("shape fidelity: Sphere / CylSphere / Building Objects")
+is **essentially closed at the Holtburg site that motivated this phase**.
+Building BSP collision works correctly β the slice-1.5 probe captured
+real triangles in plausible world positions for `gfxObj=0x01000A2B` with
+`bspR=13.99m`. The 121 wall hits the L.2a probe attributed to
+`obj=0xA9B47900` were **side effects of the player already being pushed
+back by a separate Door cylinder entity** at the same doorway threshold.
+
+The actual blocker is a server-spawned **Door** entity β Setup
+`0x020019FF` named `"Door"` β that ACE places at each Holtburg-town
+building threshold (five doors total observed across `0xA9B40029`,
+`0xA9B40154`, `0xA9B40155`). It registers as a Cylinder shadow entry
+via the server-spawn path; its Cylinder collision blocks the player
+walking into the doorway. That's **door-state handling**, a different
+class of problem from L.2d's shape-fidelity scope β it touches network
+(`CreateObject` PhysicsState bits), interaction (Use action on door
+entity), animation (door open/close), and collision-state-toggle.
+
+Recommend: **leave L.2d in "watch-and-wait" mode** with slice 1's probe
+infrastructure in place. No more L.2d slices until a NEW shape-fidelity
+bug is observed at a different site (dungeon walls, stairs, roofs) with
+the probe-armed client. The door-state work becomes its own sub-phase
+(probably nested under B.4 interaction or filed as a new L.2 sub-phase
+like L.2g) scoped separately.
+
+Full slice 1 + 1.5 handoff:
+[docs/research/2026-05-13-l2d-slice1-shipped-handoff.md](../research/2026-05-13-l2d-slice1-shipped-handoff.md).
+Design spec (now mostly historical, framing was wrong but probe
+infrastructure shipped from it):
+[docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md](../superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md).
+Predecessor L.2a handoff:
+[docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../research/2026-05-12-l2a-shipped-l2d-handoff.md).
+
### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp
Goal: the resolver knows which cell owns the movement and which adjacent cells
@@ -164,6 +228,62 @@ 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.
+
+Current shipped slice (2026-05-12):
+
+| Commit | Subject |
+|---|---|
+| `2459f28` | `feat(phys L.2g slice 1): inbound SetState (0xF74B) parser` |
+| `d538915` | `feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState` |
+| `536a608` | `feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe` |
+| `108e386` | `feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log` |
+
+Slice 1 is CODE-COMPLETE: parser + registry mutator + WorldSession
+dispatcher + GameWindow subscriber. 6 new tests pass (3 parser + 3
+registry). Build clean. Per-commit + final integration code reviews
+all approved. **Visual verification deferred to Phase B.4b** β the
+inbound SetState chain can't fire at runtime until B.4b finishes the
+outbound Use handler. See
+[docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](../research/2026-05-12-l2g-slice1-shipped-handoff.md)
+for full evidence + the 4 minor + 1 Important review notes.
+
## Named Retail Anchors
Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
diff --git a/docs/plans/2026-05-08-phase-n5-perf-baseline.md b/docs/plans/2026-05-08-phase-n5-perf-baseline.md
new file mode 100644
index 0000000..6d14bb8
--- /dev/null
+++ b/docs/plans/2026-05-08-phase-n5-perf-baseline.md
@@ -0,0 +1,72 @@
+# Phase N.5 perf baseline
+
+**Captured:** 2026-05-08, against N.5 head (post-Task 12) on local machine.
+**Method:** `ACDREAM_WB_DIAG=1` + character at Holtburg spawn position +
+roaming. Numbers below are 5-second window medians from `[WB-DIAG]`.
+
+## Holtburg courtyard (steady state)
+
+| Metric | N.5 measured | N.4 (estimated*) | Gate |
+|---|---|---|---|
+| CPU dispatcher (median) | **1227 Β΅s / frame** | β₯2500 Β΅s / frame | β€70% of N.4 β **PASS** |
+| CPU dispatcher (p95) | 1303 Β΅s / frame | β | β |
+| GPU rendering (median) | unmeasured (see below) | β | within Β±10% β **DEFERRED** |
+| `drawsIssued` per 5s | 4.85M (= 1662 groups Γ ~580 fps) | far higher per frame | β |
+| `drawsIssued` per pass (CPU GL calls) | **2** (1 opaque + 1 transparent indirect) | ~hundreds per pass | β€5 β **PASS** |
+| `groups` (working set) | 1662 | ~similar | sanity |
+| Frame rate (inferred) | ~810 fps | ~100-200 fps | substantial uplift |
+
+*N.4 baseline NOT measured directly in this run. The "β₯2500 Β΅s / frame"
+estimate assumes N.4's per-group glBindTexture + glBindBuffer +
+glDrawElementsInstancedBaseVertexBaseInstance hot path costs β₯1.5 Β΅s per
+group and N.4 has ~1700 groups in this scene, putting the GL portion alone
+at ~2.5 ms before adding the entity-walk overhead. N.5's measurement
+includes ALL dispatcher work (entity walk + group bucketing + 3 SSBO
+uploads + 2 indirect calls + state changes) at 1230 Β΅s total β comfortably
+half of the lower bound estimate.
+
+## Acceptance gates (spec Β§8.3)
+
+- [x] **Visual identity to N.4** β confirmed at Task 10 USER GATE: Holtburg
+ courtyard renders identical, no missing entities, no z-fighting, no
+ exploded parts.
+- [x] **CPU dispatcher time β€ 70% of N.4** β N.5 measures 1.23 ms/frame
+ median; estimated N.4 β₯2.5 ms/frame; **comfortably under 70%**.
+- [ ] **GPU rendering time within Β±10% of N.4** β DEFERRED. The
+ `GL_TIME_ELAPSED` query polling never reports `avail != 0` in our
+ single-frame poll loop; the driver hasn't finalized the result by the
+ time we check. The fix is double-buffering (issue queryA on frame N,
+ read result on frame N+2). N.6 perf polish item.
+- [x] **`drawsIssued` β€ 5 per pass (CPU GL calls)** β exactly 2 indirect
+ calls per frame regardless of scene size.
+- [x] **All tests green** β 70/70 in
+ `FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition`.
+ 8 pre-existing failures in `MotionInterpreter` / `BSPStepUp` /
+ `PositionManager` / `PlayerMovementController` / `Dispatcher` are
+ carry-forward from before N.5 and unrelated to rendering.
+- [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** β escape hatch
+ formally retired in N.5 ship amendment. `InstancedMeshRenderer`,
+ `StaticMeshRenderer`, and `WbFoundationFlag` deleted. Missing
+ bindless throws `NotSupportedException` at startup with a clear
+ error message. No fallback path.
+
+## Visual verification (Task 14)
+
+- [x] **Holtburg courtyard** β PASS at Task 10 USER GATE.
+- [ ] **Foundry interior / dense static-object scene** β TODO Task 14.
+- [ ] **Indoor β outdoor cell transition** β TODO Task 14.
+- [ ] **Drudge / character close-up (Issue #47 close-detail mesh)** β TODO Task 14.
+- [ ] **Magic content (Decision 2 additive fallback check)** β TODO Task 14.
+- [ ] **Long-session sanity** β DEFERRED (N.6 watchlist; not load-bearing for ship).
+
+## Open follow-ups for N.6
+
+1. **GPU timer query double-buffering** β the current single-frame poll
+ pattern never sees `QueryResultAvailable=true`. Issue queryA on frame N,
+ queryB on frame N+1, read queryA on frame N+2. ~30 lines of state.
+2. **Direct N.4 vs N.5 perf comparison** β re-run with `git checkout`ed N.4
+ SHIP (`c445364`) for a side-by-side measurement. Not load-bearing but
+ useful for N.6 ship message.
+3. **Persistent-mapped buffers** β Decision 7 deferral. If profiling shows
+ the per-frame `glBufferData` cost is the residual hot spot, layer it on
+ top of the modern path.
diff --git a/docs/plans/2026-05-09-phase-n5b-perf-baseline.md b/docs/plans/2026-05-09-phase-n5b-perf-baseline.md
new file mode 100644
index 0000000..c5f9136
--- /dev/null
+++ b/docs/plans/2026-05-09-phase-n5b-perf-baseline.md
@@ -0,0 +1,98 @@
+# Phase N.5b β terrain perf baseline
+
+**Captured:** 2026-05-09 at Holtburg town dueling field, radius=5, ~30s standstill.
+
+## Methodology
+
+Same build (commit at perf measurement: `da56063`), `ACDREAM_WB_DIAG=1`. The build
+included a TEMPORARY `ACDREAM_LEGACY_TERRAIN=1` env-var toggle (since retired in T9
+deletion of the legacy renderer) that routed Draw through the legacy renderer for
+direct comparison. Both renderers were constructed and fed AddLandblock / RemoveLandblock
+in parallel; only one drew per frame; the same Stopwatch wrapped whichever ran.
+
+## Numbers
+
+| Renderer | cpu_us median | cpu_us p95 | draws/frame | Visible LBs |
+|---|---|---|---|---|
+| **Legacy** (`TerrainChunkRenderer`) | 1.5 | 3.0 | 1 (1 chunk) | 132-143 (whole chunk) |
+| **Modern** (`TerrainModernRenderer`) | 6.4-7.0 | 9-14 | ~36-51 | 36-51 (per-LB cull) |
+
+(Legacy `draws=1` because its 16Γ16-LB chunking collapses radius=5's 121 visible
+landblocks into a single chunk, dispatched as one `glDrawElements`. Modern issues
+one `glMultiDrawElementsIndirect` with N=36-51 sub-commands.)
+
+## Acceptance criterion
+
+The N.5b spec acceptance criterion 5 read: "CPU dispatcher time at radius=5 β₯10%
+lower than today's per-LB-binds path." The captured numbers show modern is ~4Γ
+HIGHER on CPU at radius=5. **The criterion was wrong** β at radius=5 in Holtburg,
+legacy's chunked path was already collapsed to one draw call. The architectural
+wins of multi-draw indirect manifest at higher chunk counts (A.5 territory).
+
+The spec is amended via this doc: ship N.5b on visual identity + structural
+correctness rather than CPU savings at radius=5.
+
+## Architectural wins of the modern path (real, even when CPU is higher)
+
+1. **Zero `glBindTexture` per frame.** Bindless atlas handles are made resident
+ once at startup; the modern shader samples via `sampler2DArray(uvec2 handle)`.
+ Legacy issued 2 `glBindTexture(Texture2DArray)` calls per frame.
+
+2. **Constant-cost dispatch.** As A.5 raises the streaming radius (next phase),
+ the visible chunk count grows. Legacy scales linearly: at radius=10 (4Γ chunks)
+ it's 4 `glDrawElements` calls; at radius=15 (β₯9 chunks) it's 9+ calls. Modern
+ stays at exactly 1 `glMultiDrawElementsIndirect` regardless.
+
+3. **Per-LB frustum culling.** Legacy culled at chunk granularity (16Γ16 LBs);
+ modern culls per-LB. At a typical Holtburg view, ~36-51 of 132 loaded LBs are
+ actually visible; legacy drew the entire 132-LB chunk (3.5Γ the visible work
+ pushed to GPU vertex/fragment stages, even though CPU dispatch was cheap).
+
+## Why modern's CPU was higher at radius=5
+
+Per-frame work in modern (in microseconds-ish budget on this scene):
+- Walk all loaded slots checking visibility (~120 slots) β AABB test each
+- Build DEIC array (51 entries Γ 20 bytes = 1020 bytes)
+- `glBufferSubData(DRAW_INDIRECT_BUFFER, ...)` β driver memcpy
+- 2Γ `glProgramUniform2(..., handle.low, handle.high)` for atlas handles
+- `glBindVertexArray` + `glMemoryBarrier(GL_COMMAND_BARRIER_BIT)` + `glMultiDrawElementsIndirect`
+
+Legacy's per-frame work:
+- Bind 2 textures
+- Bind one VAO (the chunk)
+- One `glDrawElements`
+
+The DEIC array build + buffer upload alone is ~3-5Β΅s at radius=5 on this hardware,
+which is the bulk of the modern overhead. At higher radius, this overhead amortizes:
+the buffer is similar size, but the alternative (legacy's N draws) grows.
+
+## Follow-up work
+
+- **A.5 (next phase)** will exercise the higher-radius case where modern wins.
+ Capture a fresh baseline at radius=8 / 10 once A.5 lands.
+- **N.6 perf polish** can investigate persistent-mapped buffers for the indirect
+ buffer, which would eliminate the per-frame `glBufferSubData`. Likely small win
+ at radius=5 (single ~1KB upload), bigger at higher radii.
+- **GPU-side culling** (compute shader generating the DEIC array directly into
+ the indirect buffer) eliminates the CPU slot walk + DEIC build entirely. N.6 or
+ later territory; only worth it if profiling shows the CPU walk is hot.
+
+## Lessons captured to memory
+
+`memory/project_phase_n5b_state.md` records the high-value gotchas surfaced
+during N.5b implementation. Three particularly bitable ones:
+
+1. **`uniform sampler2DArray` + `glProgramUniformHandleARB` is unreliable.** Some
+ drivers (NVIDIA Windows in this case) reject the combination with
+ `GL_INVALID_OPERATION`. Use the `uniform uvec2` + `sampler2DArray(handle)`
+ constructor pattern instead β N.5's mesh_modern uses this, and N.5b's
+ terrain_modern adopted it after the black-terrain regression.
+
+2. **`MaybeFlushTerrainDiag` underflow.** A naive median calc (`copy[N - nz/2]`)
+ underflows to `copy[N]` when only one sample has been recorded. Use
+ `copy[N - 1 - (nz - 1) / 2]` instead.
+
+3. **Visual gate must actually be visually confirmed.** "Go" doesn't mean
+ "verified." During N.5b's gate the user said "go" without launching, which
+ masked the black-terrain regression for hours. The gate must include the
+ user reporting actual visual confirmation, not assent to proceed.
diff --git a/docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md b/docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md
new file mode 100644
index 0000000..c7d9883
--- /dev/null
+++ b/docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md
@@ -0,0 +1,195 @@
+# Performance Tiers 2 + 3 β Future Roadmap
+
+**Created:** 2026-05-10 during Phase A.5 polish.
+**Status:** Future planning β not for current execution.
+**Context:** A.5 shipped two-tier streaming with the entity dispatcher landing at ~3.5ms median (post-Bug-A and Bug-B fixes). Tier 1 (entity-classification cache) lands as A.5 polish and brings the dispatcher inside the 2.0ms spec budget. Tiers 2 + 3 are the "next big perf wins" beyond Tier 1.
+
+---
+
+## Background β why this exists
+
+Discussion captured 2026-05-10: user observed 200-240 FPS at radius=12 on a Radeon 9070 XT @ 1440p and asked why an "old game like AC" doesn't deliver Unreal-level (1000+ FPS) on this hardware.
+
+The honest answer: the bottleneck is *architectural*, not hardware. The CPU is single-threaded and rebuilds the entire draw plan from scratch every frame. Modern engines pre-bake static-world batches at content-cook time and rebuild only what changes.
+
+AC's design β server-spawned per-entity world streamed at runtime β doesn't naturally batch the way Unreal's pre-cooked content does. Closing the gap requires backporting modern techniques while preserving AC's data model. Tiers 2 and 3 are that backporting work.
+
+---
+
+## Tier 2 β Static/dynamic split with persistent groups
+
+**Estimated effort:** ~10-15 days (2-week phase).
+**Estimated win:** entity dispatcher ~3.5ms β **~0.5-1ms median** at radius=12.
+**Total frame time:** ~4-5ms β **~2-3ms = 400-600 FPS at standstill.**
+
+### The core idea
+
+Today, `WbDrawDispatcher._groups` (the dictionary of "(mesh + texture + blend) β list of instances to draw") is cleared and rebuilt from scratch every frame.
+
+For trees, rocks, buildings, and other static entities (~95% of the world), the answer is identical every frame forever. Tier 2 makes the static-group instance buffers **persistent GPU-resident data**, just like Unreal's pre-baked world. The CPU only orchestrates "which groups are visible" per frame.
+
+### Architectural shift
+
+```csharp
+class StaticInstancedGroup
+{
+ public GroupKey Key;
+ public Matrix4x4[] Matrices; // grown as entities spawn
+ public BitArray ActiveSlots; // for free-list reuse
+ public bool NeedsGpuUpload; // dirty flag for delta upload
+ public Dictionary EntityToSlot; // for despawn lookup
+ public uint InstanceBufferOffset; // start of group's slice in global SSBO
+}
+```
+
+**On entity spawn (atlas-tier static):** allocate a slot in each relevant group, write the matrix, mark dirty.
+
+**On entity despawn:** free the slot, mark dirty.
+
+**Per frame:**
+- Static groups: LB-cull each group (cheap). For visible groups, flag for draw. **No matrix copy. No list rebuild.**
+- Dynamic entities (~50 NPCs/players): today's per-frame walk-and-classify. Keeps the existing slow path for things that legitimately change every frame.
+- Upload only the dirty groups' matrix slices (delta upload, not full reupload).
+- Issue 2 multi-draw-indirect calls.
+
+### Sub-decisions
+
+**Frustum cull granularity at the group level:** at group level you can't reject individual instances; you draw the whole group or none of it. Two strategies:
+
+- **Per-LB subgroups:** split each group into per-landblock subgroups. LB-frustum-culls reject subgroups whose LB is invisible. ~2K groups Γ ~5 LBs per group on average = ~10K subgroups. Each subgroup AABB cull is ~0.3 Β΅s β ~3 ms per frame. Roughly a wash with today's per-entity cull.
+- **Per-instance GPU cull (Tier 3):** compute pre-pass on the GPU writes which instances are visible to a draw-indirect buffer. ~0.05ms CPU. The right long-term answer.
+
+For Tier 2 alone, per-LB subgroups are the recommended approach β keep CPU culling, just at coarser granularity than per-entity.
+
+**Dynamic entities crossing LB boundaries:** when an NPC walks across a landblock boundary, it stays in the same group key but its "spatial bucket" changes. Solution: dynamic entities are tracked in a single global "dynamic group" outside the per-LB structure; they don't need spatial bucketing because there are only ~50 of them.
+
+**Palette override invalidation:** server event swaps an NPC's clothing color β group key changes. Treat as despawn-from-old + spawn-into-new. NPCs are dynamic so this just rebuckets them.
+
+**Animation overrides on static entities:** static entities don't animate. Trees don't bend (foliage wave is a vertex shader effect, not a group-key change). Buildings don't move. So the static path never invalidates.
+
+**EnvCell visibility:** dungeon entities are gated by per-cell visibility state. Need to track which group instances are tied to which cell, and during visibility cull, gate per-cell. Keep using existing `ParentCellId` field on WorldEntity.
+
+**Streaming load/unload integration:** when an LB unloads, all its static entity matrices need to be removed from their groups. Free-list management. Matches existing `LandblockSpawnAdapter` lifecycle.
+
+### Effort breakdown
+
+| Task | Days |
+|---|---|
+| Design + invariants document | 2 |
+| Spawn-time slot allocator + free-list | 3 |
+| Per-frame visibility + dirty-flag delta upload | 2 |
+| Dynamic entity path (NPCs, projectiles) | 2 |
+| Invalidation (palette/ObjDesc events) | 2 |
+| EnvCell visibility integration | 1 |
+| Streaming load/unload integration | 1 |
+| Conformance testing | 2-3 |
+| **Total** | **~10-15 days** |
+
+### Risks
+
+- **Slot management bugs** = double-frees or leaks (entities draw at random positions β visible).
+- **Invalidation bugs** = stale matrices (entity teleports back to spawn point when palette changes).
+- **Dynamic entity tracking** adds complexity around the static/dynamic boundary.
+
+### Mitigations
+
+- **Conformance test:** render a fixed scene through both pipelines, compare draw output. Adds CI infrastructure.
+- **Per-frame validation in debug:** walk all groups, assert no orphan slots.
+- **Hash invariant test:** static entities should produce stable group keys frame-over-frame. Add a debug assertion that fires once per frame in Debug builds.
+
+---
+
+## Tier 3 β GPU-side culling (compute pre-pass)
+
+**Estimated effort:** ~1 month (longer phase).
+**Estimated win:** entity dispatcher ~0.5-1ms (post-Tier-2) β **~0.05ms median.**
+**Total frame time:** ~2-3ms β **~1.5-2ms = 600-1000+ FPS at standstill.**
+
+### The core idea
+
+Today (and after Tier 2), the CPU does per-LB or per-subgroup frustum culling and tells the GPU which groups to draw.
+
+Tier 3 moves per-instance frustum cull to the GPU via a compute shader pre-pass. The CPU just uploads "here are all 1M instance matrices" once; the GPU compute shader writes which ones are visible to a draw-indirect buffer; the rasterizer draws only those.
+
+This is the level Unreal is at. With this, per-frame CPU work for the entity dispatcher becomes essentially "tell the GPU what to do" + a tiny scratch upload.
+
+### Why Tier 3 needs Tier 2 first
+
+Without Tier 2's persistent group structure, GPU culling has nothing stable to operate on. The compute shader needs an addressable "here are the static instances" buffer to read from; that buffer only exists after Tier 2.
+
+### Sub-decisions to be made
+
+**Compute shader API:** OpenGL 4.3+ compute shaders are sufficient. We're already at GL 4.3+ for bindless. No additional capability requirement.
+
+**Indirect draw command generation:** the compute shader writes a `DrawElementsIndirectCommand[]` buffer per pass. Render thread issues `glMultiDrawElementsIndirect` reading from that buffer. No CPU readback.
+
+**LOD selection:** opportunity to add per-instance LOD selection in the compute shader (distance-based mesh detail). Not needed for A.5's scope; could be a Tier 4 follow-up.
+
+**Per-light shadow map culling:** if shadows ship, GPU culling extends naturally to per-light frustum cull. Significant win for shadow rendering.
+
+### Effort breakdown
+
+| Task | Days |
+|---|---|
+| Compute shader design + GLSL implementation | 4 |
+| Buffer layout coordination with Tier 2 | 2 |
+| Silk.NET compute dispatch integration | 3 |
+| Indirect command compaction logic | 4 |
+| LOD selection (optional, ~stretch) | 4 |
+| Validation: per-instance cull matches CPU cull within epsilon | 3 |
+| Conformance + regression testing | 5 |
+| **Total** | **~21-25 days, ~1 month** |
+
+### Risks
+
+- **GPU stalls** if the compute shader takes longer than expected (esp. on lower-end GPUs).
+- **Sync overhead** between compute pre-pass and rasterizer pass.
+- **Debugging difficulty** β GPU compute bugs are harder to diagnose than CPU bugs.
+
+### Mitigations
+
+- **Profile-driven design:** measure compute shader runtime on target hardware before committing.
+- **Fallback path:** keep CPU cull as a runtime-toggleable option (env var) so we can A/B compare.
+- **GPU debugging tools:** RenderDoc captures + frame-by-frame compute shader inspection.
+
+---
+
+## When to schedule these
+
+**Tier 2:**
+- Best fit: dedicated 2-week phase after a SHIP cycle. Treat it like a Phase B/C/N (i.e., name it Phase A.6 or N.7).
+- Trigger: user wants to push radius beyond 12 (e.g., to 15 or 20 for true continent-scale horizon).
+- Trigger: user wants to add 100+ active NPCs in a city without dropping below 240Hz.
+
+**Tier 3:**
+- Best fit: after Tier 2 has been live and stable for at least one cycle.
+- Trigger: shadow map work begins (GPU cull + shadow cull share the same compute pre-pass infrastructure).
+- Trigger: user wants 500+ FPS sustained for very-high-refresh scenarios (360Hz monitors, future hardware).
+
+**Both:**
+- Don't bundle with other phases. These are dedicated perf phases with their own brainstorm + spec + plan + SHIP cycles.
+
+---
+
+## What's "free" or smaller (out of Tier 1/2/3 scope but worth noting)
+
+- **Plumb `JobKind` properly through `BuildLandblockForStreaming`** (~30 min). Today's Bug A patch wastes worker-thread CPU on hydration that gets thrown away for far-tier. Cleaner code, slight CPU savings on worker.
+- **Eliminate `ToEntries` adapter allocation in `Draw`** (~15 min). Tiny win (~25 KB / frame). Could fold into Tier 1.
+- **Persistent-mapped indirect buffer** (~2 days). Today's `glBufferData` per frame becomes a pre-mapped persistent buffer. Marginal win on RDNA 4; meaningful on lower-end GPUs.
+- **Multi-thread mesh-build worker pool** (~1 day). 2.7s first-traversal horizon-fill drops to 0.7s with 4 workers. UX win on first walk-into-region.
+
+These are good candidates for a "perf polish" mini-phase or to backfill into Tier 2.
+
+---
+
+## The architectural ceiling
+
+Even with all three tiers, **a faithful AC client written in C# with bindless OpenGL tops out around 800-1500 FPS at radius=12 on RDNA 4 hardware**. Beyond that requires:
+
+- Native C++ rendering core (eliminate .NET GC + JIT overhead)
+- DX12/Vulkan API (eliminate driver state validation)
+- Offline content cooking (eliminate runtime mesh/texture decode)
+
+Each of those is a several-month undertaking and represents "becoming a different engine." The realistic target for acdream is 240-500 FPS at the user's monitor refresh, comfortably ahead of the visible-stutter threshold. Tier 1 + Tier 2 alone should deliver that for radius=12-15.
+
+For "Unreal-level FPS at full quality," that's a different project.
diff --git a/docs/plans/2026-05-11-phase-n6-perf-baseline.md b/docs/plans/2026-05-11-phase-n6-perf-baseline.md
new file mode 100644
index 0000000..93870e7
--- /dev/null
+++ b/docs/plans/2026-05-11-phase-n6-perf-baseline.md
@@ -0,0 +1,193 @@
+# Phase N.6 slice 1 β perf baseline at Holtburg
+
+**Created:** 2026-05-11.
+**Spec:** [docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md](../superpowers/specs/2026-05-11-phase-n6-slice1-design.md)
+**Measured against commit:** `25cb147` (Task 1 final β gpu_us fix + diag-gate symmetry follow-up)
+**Purpose:** Capture authoritative CPU+GPU dispatch numbers so the next-phase decision (slice 2 vs C.1.5 vs Tier 2) rests on real data.
+
+---
+
+## Β§1. Setup
+
+- **Hardware:** Radeon RX 9070 XT
+- **Resolution:** 1440p (2560Γ1440)
+- **Quality preset:** High (default)
+- **Connection:** live ACE at `127.0.0.1:9000`
+- **Character:** `+Acdream` at Holtburg
+- **Sky / time:** clear midday (F7 β Noon, F10 β Clear)
+- **Build:** Debug
+- **Date measured:** 2026-05-11
+- **Environment overrides:** `ACDREAM_WB_DIAG=1`, `ACDREAM_STREAM_RADIUS=`
+
+Note: `ACDREAM_STREAM_RADIUS=N` forces Nβ=N (all N near-tier landblocks at full detail).
+This is NOT the production A.5 default (Nβ=4 / Nβ=12), which was characterized in
+CLAUDE.md as comfortable 200β400 FPS at the default preset. These measurements
+characterize the scaling curve β what happens as near-tier radius grows β not current
+production behavior. FPS was not captured directly (no window-title screenshot per run);
+it can be derived from `(1e6 / total_frame_time_us)` but the dispatcher's `cpu_us` is
+only part of the frame (terrain, sky, particles, UI, GL submission overhead, and
+swap-buffer wait are not included).
+
+## Β§2. Dispatch CPU / GPU numbers
+
+Each cell records the median of the last 3 `[WB-DIAG]` lines from a ~30s stable window.
+`entSeen / entDrawn / groups / drawsIssued` are also from those lines (values per 5s bucket).
+FPS column omitted β not captured per the note above.
+
+| Radius | Motion | cpu_us median | cpu_us p95 | gpu_us median | gpu_us p95 | entSeen (per 5s) | entDrawn (per 5s) | groups | drawsIssued (per 5s) |
+|--------|------------|---------------|------------|---------------|------------|------------------|-------------------|--------|----------------------|
+| 4 | standstill | 3,208 | 3,313 | 93 | 95 | 16.9M | 15.5M | 1,216 | 1.65M |
+| 4 | walking | 2,967 | 3,112 | 95 | 120 | 13.9M | 13.9M | 1,850 | 1.45M |
+| 8 | standstill | 6,732 | 7,199 | 126 | 130 | 19.8M | 19.8M | 333 | 218K |
+| 8 | walking | 6,572 | 6,927 | 96 | 113 | 18.1M | 18.0M | 534 | 245K |
+| 12 | standstill | 12,853 | 13,525 | 344 | 507 | 19.6M | 19.6M | 541 | 184K |
+| 12 | walking | 16,320 | 17,241 | 553 | 603 | 17.8M | 17.8M | 898 | 200K |
+
+**Notable:** `meshMissing` counts at r4 standstill (~1.45M per 5s) drop to near-zero while
+walking. This suggests the static-entity slow path's mesh-load lifecycle has some delay
+before populating for newly-streamed content. Not fatal β doesn't affect rendered output β
+but worth a follow-up issue in `docs/ISSUES.md` if it persists in normal play.
+
+## Β§3. Surface-format histogram
+
+From `ACDREAM_DUMP_SURFACES=1` at radius=12, ~30s after enter-world.
+Output written to `%LOCALAPPDATA%\acdream\n6-surfaces.txt`.
+
+- **Total unique GL textures:** 760
+- **Total bytes (sum of WΓHΓ4):** 96,387,584 (~96.4 MB)
+
+**Top 10 (W, H) dimension buckets:**
+
+| Dimensions | Count | Share |
+|------------|-------|-------|
+| 128Γ128 | 236 | 31% |
+| 64Γ64 | 111 | 15% |
+| 256Γ256 | 102 | 13% |
+| 128Γ256 | 71 | 9% |
+| 64Γ128 | 69 | 9% |
+| 256Γ128 | 48 | 6% |
+| 128Γ64 | 39 | 5% |
+| 512Γ512 | 30 | 4% |
+| 8Γ8 | 18 | 2% |
+| 32Γ32 | 14 | 2% |
+
+**Format distribution:**
+
+| Format | Count | Share |
+|---------------|-------|-------|
+| RGBA8_DECODED | 760 | 100% |
+
+All uploads land as RGBA8 regardless of source format (INDEX16, P8, DXT, BGRA, etc.
+all decode through `TextureHelpers` before upload). The source-format diversity is real
+but invisible to GL after the decode step.
+
+**Top 10 (W, H, format) triples β atlas-opportunity input:**
+
+Same as the dimension buckets above since there is only one format. The top-3 triples
+(128Γ128, 64Γ64, 256Γ256) cover 449 of 760 surfaces = **59%**.
+
+**Atlas-opportunity score: 59%** of surfaces fall into the top-3 (W, H, format) triples.
+A conventional rule-of-thumb is that >30% concentration into the top buckets makes atlas
+packing worth the implementation cost for memory savings; this measurement is well above
+that. However, see Β§4 for why atlas is not the right next step despite the high score.
+
+## Β§4. Conclusion + next-phase recommendation
+
+### What the data shows
+
+**The entity dispatcher is strongly CPU-bound.** At every radius, CPU dominates GPU by
+30β50Γ. At radius=12 standstill: 12.9 ms CPU vs 0.34 ms GPU. At radius=12 walking the
+ratio is 16.3 ms CPU vs 0.55 ms GPU. There is no GPU bottleneck.
+
+**GPU is wildly under-utilized.** The highest gpu_us p95 observed is 603 Β΅s at radius=12
+walking β against a 16,600 Β΅s frame budget at 60 FPS. The GPU is working at roughly
+3.6% of its 60fps capacity for entity rendering alone. Even accounting for terrain, sky,
+particles, UI, and swap-buffer overhead, there is substantial headroom. The "GPU
+comfortable" threshold (gpu_us p95 < 8,000 Β΅s) is not even close to being challenged.
+
+**CPU grows more than linearly with Nβ (near-tier radius), but sublinearly with
+visible-LB count.** As Nβ grows from 4 β 8 β 12, median cpu_us grows from 3.2 ms β
+6.7 ms β 12.9 ms β roughly 1.0Γ β 2.1Γ β 4.0Γ the r4 baseline. The visible-LB count
+scales as `(2N+1)Β²`: 81 β 289 β 625, so CPU growth is sublinear in LB count (4.0Γ
+vs 7.7Γ expected if every LB cost the same). Frustum culling discards most far LBs
+early, but the outer per-LB walk still has to touch each one. The Tier 1 entity-
+classification cache (`EntityClassificationCache`, shipped as #53) wins on the inner
+loop (per-entity classification avoided on cache hits) but the outer walk dominates
+as Nβ grows. This is exactly what the Tier 2 plan (persistent groups) at
+`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` addresses by eliminating the
+per-frame LB scan entirely.
+
+**Radius=12 is not the production scenario.** `ACDREAM_STREAM_RADIUS=12` forces Nβ=12
+(625 near LBs at full detail). The production A.5 default preset is Nβ=4 / Nβ=12 (81
+full-detail near + 544 terrain-only far), which CLAUDE.md already characterizes as
+comfortable 200β400 FPS at the default preset. The numbers above characterize the scaling
+curve for headroom analysis, not the experience a typical player sees.
+
+**Atlas opportunity is high (59%) but the win is memory-only β and modest.** With 96 MB
+of textures and 59% in the top-3 dimension buckets, atlas consolidation would let the
+top buckets share single `Texture2DArray` objects rather than each surface owning its
+own 1-layer array. The primary wins of atlas β fewer sampler switches, fewer texture
+binds β are already near-zero because bindless textures are made resident once at upload
+and never bound per draw. The remaining win is the per-array metadata overhead Γ N
+surfaces, which is bounded but not dramatic given all surfaces are already power-of-two
+and same-format (RGBA8). Even on the optimistic side, the absolute memory saving is on
+the order of low-MB to ~10 MB, not a 40β50% halving. GPU is not bottlenecked on sampler
+switches or memory bandwidth (0.6 ms gpu_us p95 at radius=12 walking demonstrates this
+directly), so atlas adoption would cost 1β2 weeks of implementation risk for a memory
+saving the process doesn't currently need at 96 MB.
+
+### Recommendation
+
+**Primary: do C.1.5 next (PES emitter wiring β portals, chimneys, fireplaces).** Four
+reasons: (a) the production dispatcher is already comfortable at the default Nβ=4 preset
+per the CLAUDE.md notes; (b) the two slice-2 items that were "conditional on baseline"
+data (atlas adoption and persistent-mapped buffers) are not justified β GPU is not
+bottlenecked; (c) C.1.5 fills a visible content gap that has been open since C.1 shipped
+and is in the roadmap queue ahead of N.6 slice 2; (d) C.1.5 stabilizes the particle path
+before any future shader migration work in slice 2 touches `particle.frag`. Starting
+point for C.1.5 scoping: `docs/plans/2026-04-27-phase-c1-pes-particles.md` lines 285β295.
+
+**Secondary (after C.1.5 lands): N.6 slice 2 with reduced scope.** The baseline data
+justifies dropping atlas adoption and persistent-mapped buffers from slice 2 entirely.
+What remains is a ~1-day cleanup: retire orphan `mesh.frag` (verify zero callers post-N.5
+amendment), collapse dead `_handlesByOverridden` / `_handlesByPalette` legacy caches once
+their callers are confirmed gone, migrate `particle.frag` to bindless sampling after C.1.5
+stabilizes the path. Slice 2 is a cleanup sprint, not a performance phase.
+
+**Tertiary option (if perf escalation becomes pressing): Tier 2 first.** The scaling
+curve (3.2 β 6.7 β 12.9 ms as Nβ grows 4 β 8 β 12) confirms the per-LB walk is the
+bottleneck β exactly what Tier 2's persistent-group structure at
+`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` addresses. Not urgent at the current
+default Nβ=4; worth revisiting if a future quality preset wants Nβ=8 as default or if the
+200β400 FPS range at Nβ=4 shrinks after more content is streamed.
+
+**Decision rule for revisiting:** if future measurement at the default preset shows
+cpu_us median > 5,000 Β΅s or gpu_us p95 > 8,000 Β΅s, re-open the escalation question.
+Otherwise, hold the C.1.5 β reduced-slice-2 sequence.
+
+## Β§5. Reproducing the measurements
+
+Raw `[WB-DIAG]` output from each run was inspected live during measurement and the
+median of the last three steady-state lines from each scenario was transcribed into Β§2.
+The raw launch logs were not preserved β the captured medians in Β§2 are the canonical
+record. To reproduce on the same hardware:
+
+```powershell
+$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_WB_DIAG = "1"
+$env:ACDREAM_STREAM_RADIUS = "4" # or 8, 12
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline.log"
+```
+
+Stand still for ~30 s at the target radius (60 s at radius 12 to let streaming settle),
+or walk NβEβSβW across one landblock. Then `Select-String -Path baseline.log -Pattern
+"\[WB-DIAG\]" | Select-Object -Last 3` captures the steady-state numbers.
+
+For the surface histogram, also set `$env:ACDREAM_DUMP_SURFACES = "1"`, stay in-world
+~30 s after streaming has loaded β₯100 textures (the cache-size gate), then read
+`$env:LOCALAPPDATA\acdream\n6-surfaces.txt`.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
new file mode 100644
index 0000000..a6ffc96
--- /dev/null
+++ b/docs/plans/2026-05-12-milestones.md
@@ -0,0 +1,379 @@
+# acdream β milestones (morale + scope layer)
+
+**Status:** Living document. Created 2026-05-12.
+**Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index).
+**Currently working toward:** **M1 β Walkable + clickable world.**
+
+---
+
+## Why this document exists
+
+The roadmap is a phase index β week- to month-scale, ~50 phases by the time
+v1.0 lands. Phases ship in vertical slices (architecture-first, horizontal
+completion deferred), which is the right strategy for a solo open-source
+project at this scale β but it leaves a chronic "everything is half-built"
+feeling because no single phase ship feels like a real milestone.
+
+This document sits **one altitude above** the roadmap. Each milestone is:
+
+- **~6β10 weeks of focused work** (not a single phase, not a whole year).
+- Defined by a **concrete playable scenario** β when the scenario works
+ end-to-end, the milestone lands.
+- A **scope-freeze event**: when a milestone lands, the phases it covers go
+ off-limits until v1.0's final polish pass (M7).
+
+Crossing a milestone is a textual event β milestones doc gets the writeup,
+the freeze list flips, CLAUDE.md's "currently working toward" line advances.
+Phases ship; milestones **land**.
+
+---
+
+## Operating rules
+
+1. **One active milestone at a time.** Everything not on the critical path to
+ the current milestone gets filed in `docs/ISSUES.md` with a `post-MN` tag
+ and explicitly muted until the milestone hits. This is what kills the
+ jumping-between-things feeling.
+
+2. **Frozen phases are off-limits.** "Frozen" means no rework, no polish, no
+ follow-up commits unless something is actively broken (player crash,
+ regression). Visual nice-to-haves, "while I'm here" cleanups, and
+ architecture second-guesses are all post-M7. The freeze is the discipline
+ mechanism that makes the milestone meaningful β without it, M0's many
+ shipped phases keep silently consuming attention.
+
+3. **The milestone log is the morale instrument.** When a milestone hits:
+ - Pin a one-paragraph writeup at the top of this doc describing what
+ works end-to-end (any caveats or known regressions are explicit).
+ - Update the freeze list. Update CLAUDE.md's "currently working toward"
+ line to the next milestone.
+ - NO demo videos. User explicitly removed that requirement 2026-05-16
+ ("pointless of recording videos, for what purpose?").
+
+4. **State both altitudes at session start.** "Currently working toward M1.
+ Current phase: L.2 collision. Next concrete step: L.2d slice 1 spec." This
+ keeps the high-level orientation visible alongside the immediate task and
+ makes mid-session drift obvious.
+
+---
+
+## The milestones
+
+### M0 β "Connect & explore" β β
DONE (crossed months ago)
+
+**Demo scenario:** Log in, walk Holtburg in chase camera, see other characters
+animate, send a chat message and see the echo, watch day turn to night,
+listen to footsteps and ambient audio.
+
+**Phases included (frozen):**
+
+| Phase | What landed |
+|---|---|
+| 1β3 | Terrain + per-vertex normals + per-cell texture blending |
+| 4 | UDP codec + handshake + character login + WorldSession |
+| 5 | ObjDesc: AnimPartChange + TextureChanges + SubPalettes + ObjScale |
+| 6.1β6.7 | Motion + animation foundation (idle, frame playback, slerp, UpdateMotion, UpdatePosition) |
+| 7.1 | EnvCell room geometry β walls/floors/ceilings |
+| 9.1β9.2 | Translucent render pass + back-face culling |
+| A.1βA.5 | Streaming landblock loader β two-tier streaming |
+| B.1βB.3 | Outbound ack pump + player movement + physics MVP resolver |
+| D.1, D.2a | 2D overlay + ImGui scaffold + `AcDream.UI.Abstractions` layer |
+| E.1βE.5 | Motion hooks + audio + particles + combat wire + spell wire |
+| F.1, F.2 | GameEvent dispatcher + item model + Appraise |
+| G.1, G.2 | Sky + day/night + weather + dynamic lighting |
+| H.1, I.1βI.8 | Chat wire + UI consolidation + holtburger inbound parity + combat translator |
+| K, L.0 | Input architecture + retail bindings + Settings panel |
+| N.0βN.6.1 | WB rendering migration (modern path mandatory) |
+| C.1, C.1.5a/b | Particle system + portal/EnvCell static-script wiring |
+| R.1βR.3 | Retail research infrastructure (PDB extract + named decomp) |
+
+**Status:** This is everything shipped through 2026-05-12. ~25 phase ships.
+**Worth saying out loud: this is the hard half of the project.** The engine
+runs, the world renders correctly, the network connects, the input is wired,
+the data layers for combat/spells/items/audio/particles all exist. What's
+missing is the gameplay loop on top.
+
+---
+
+### M1 β "Walkable + clickable world" β β
LANDED 2026-05-16
+
+**Landing writeup (2026-05-16, after Phase B.6 ship `d640ed7`):**
+All four M1 demo targets work end-to-end. You can log into `+Acdream`
+at Holtburg, walk freely without getting stuck (L.2 collision is
+retail-faithful through the doorway). Click any inn door at any
+range, press R, the character runs (or walks if close) toward it
+with the correct animation cycle including the leg-shuffle turn-first
+phase, opens the door via ACE's server-side `MoveToChain` callback.
+Same for clicking an NPC β runs over, body rotates to face, dialogue
+fires from ACE without any client-side retry. Pickup items at any
+range with F or R; spell components, food, money, weapons all work;
+signs and other `BF_STUCK` scenery correctly block at the gate.
+AP cadence matches retail (0 Hz idle, ~1 Hz smooth motion, per-event
+on cell/plane changes). Run/walk threshold matches retail observation
+(1m of distance left to walk). The earlier ladder of workarounds β
+client-side retry, per-frame chatty AP, MoveToState suppression
+grace period β all deleted via the Phase B.6 architectural refactor
+(`d640ed7`). M1's demo path is now bit-for-bit retail-faithful end
+to end.
+
+
+
+**Demo scenario:** Walk through Holtburg without getting stuck on the inn
+doorway. Open the inn door. Click an NPC and see selection feedback. Pick
+up an item from the ground.
+
+**Demo-target status (as of 2026-05-14):**
+
+| # | Target | Status | Evidence |
+|---|---|---|---|
+| 1 | Walk through Holtburg without getting stuck | β
met | L.2a/d/g shipped 2026-05-12; Holtburg doorway verified |
+| 2 | Open the inn door | β
met | B.4b (interaction) + B.4c (swing animation) shipped 2026-05-13 |
+| 3 | Click an NPC and see selection feedback | β
met | B.4b chain + chat handlers; verified 2026-05-14 (Tirenia + Royal Guard double-click β NPC dialogue in chat panel) |
+| 4 | Pick up an item from the ground | β
met (close-range path) | B.5 + post-B.5 `PickupEvent (0xF74A)` fix shipped 2026-05-14; visual-verified at Holtburg; creature-pickup guard added in `a01ebd5` |
+
+**Landing artifacts done 2026-05-16:**
+- β
Landing writeup pinned at top of this milestone block (above the table).
+- β
Freeze list applied (see below).
+- β
`CLAUDE.md`'s "currently working toward" advanced to M2.
+
+**Known polish items deferred to post-M7 (do not gate M1 landing):**
+- **#61** β AnimationSequencer linkβcycle frame-0 flash on door swing. LOW.
+- **#62** β PARTSDIAG null-guard. Latent, not reachable today.
+- **#63** β β
CLOSED by Phase B.6 (`d640ed7`). Server-initiated
+ `MoveToObject` is now honored end-to-end; ACE's `MoveToChain`
+ callback fires server-side on arrival.
+- **#64** β Local-player pickup animation does not render (retail
+ observers see it correctly). LOW.
+- **#69, #74, #75** β all closed by Phase B.6 (`d640ed7`). Turn-first
+ animation, retail-narrow AP cadence, body-direct auto-walk
+ architecture.
+
+**Phases that shipped to clear M1:**
+- **L.2 (a + d + g sub-lanes)** β Movement & Collision Conformance.
+ L.2a slices 1+2+3 + L.2d slice 1+1.5 + L.2g slice 1+1b+1c shipped
+ 2026-05-12 / 2026-05-13. Visual-verified via the B.4b doorway test.
+- **B.4b** β outbound Use + `WorldPicker` + double-click detection +
+ `CollisionExemption` widening + `ServerGuidβentity.Id` translation
+ (the ID-mismatch trap surfaced during L.2g slice 1c). Shipped
+ 2026-05-13.
+- **B.4c** β door swing animation: spawn-time `AnimationSequencer`
+ registration + stance-value fix (`NonCombat = 0x3D` not `0x01`, which
+ had been causing doors to render halfway underground). Shipped
+ 2026-05-13.
+- **B.5** β `BuildPickUp` (PutItemInContainer 0x0019) + `SendPickUp`
+ helper + F-key wiring + new `PickupEvent (0xF74A)` despawn handler.
+ Shipped 2026-05-14.
+- **B.5 polish** (`a01ebd5`) β guard `SendPickUp` against creature
+ targets so F-on-NPC produces a "Can't pick that up" toast instead of
+ the malformed pickup that triggered ACE's `WeenieError 0x0029` + NPC
+ emote chain. (Briefly visited adding "You pick up the X." chat /
+ toast feedback for ground pickups in `87ba5c9`, then reverted in
+ `20ecb23` β retail doesn't show that line for ground pickups; only
+ for items received from NPCs / other characters, and that path is
+ separate.)
+
+**Freeze on landing:**
+- L.2 zone (collision, cell ownership, transition parity, wire authority)
+- B.4 zone (interaction outbound)
+- B.5 zone (pickup outbound + inbound despawn)
+
+**What "M1 lands" looks like:** the existing Holtburg traversal works as a
+retail player would expect. Doorways are walkable. Buildings have solid
+walls. Outdoor cell seams report the right cell. Clicking an NPC selects it
+and produces NPC chat. The Use action opens doors. F picks up items at
+close range and the player sees "You pick up the X." in chat.
+
+---
+
+### M2 β "Kill a drudge" β π΅ NEXT (~6β10 weeks after M1)
+
+**Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit
+Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation
+play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel
+and see it.
+
+**Phases to ship:**
+- **F.2 (panels)** β Inventory panel reading `ItemRepository` (data already
+ shipped in F.2 base; M2 ships the visual surface).
+- **F.3** β Combat math + damage flow.
+- **F.5a** β Visible-at-login dev panels (Attributes, Skills, Equipped,
+ Inventory list) β minimal ImGui surfaces, retail-skin deferred to M5.
+- **L.1c** β Combat animation wiring (draw/sheath, attack swings by
+ stance/power/height, hit reactions, evades).
+- **L.1b** β Command router + motion-state cleanup (prereq for L.1c).
+
+**Freeze on landing:**
+- Combat math zone
+- Inventory zone (data + dev panel; retail-skin reopens in M5)
+- L.1b/c combat-animation zone
+
+**What "M2 lands" looks like:** the gameplay loop is real. You can fight,
+take damage, kill things, see loot, manage your inventory. The game becomes
+a game.
+
+---
+
+### M3 β "Cast a spell" β π΅ (~3β4 weeks after M2)
+
+**Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation,
+the projectile, the impact. Self-cast a buff (Strength Self). See the
+enchantment in a buff list. Recall to lifestone β full recall animation,
+correct teleport, correct re-spawn.
+
+**Phases to ship:**
+- **F.4** β Spell cast state machine (buffs + recalls first, projectile
+ spells second).
+- **L.1d** β Spell-casting animation wiring (cast command classification,
+ windup, release, fizzle/interruption, recoil).
+- **F.5 (Spellbook panel)** β dev-skin surface for learned spells + active
+ enchantments.
+
+**Freeze on landing:**
+- F.4 spell zone
+- L.1d cast-animation zone
+- Spellbook dev-panel surface
+
+**What "M3 lands" looks like:** mages are real characters. Buffs work,
+recalls work, the first projectile spells work. Combat from M2 + casting
+from M3 = retail-equivalent gameplay loop for melee and casters.
+
+---
+
+### M4 β "Live in the world" β π΅ (~6β10 weeks)
+
+**Demo scenario:** Create a fresh character from scratch (no ACE admin).
+Spawn. Talk to an NPC. Accept a quest. Walk to a dungeon entrance. Portal
+in (pink-bubble loading). Walk through the dungeon. Complete the quest.
+Walk back out.
+
+**Phases to ship:**
+- **H.3** β Emote scripts + quests + dialogs (122 EmoteType Γ 39 Trigger
+ mini-VM).
+- **G.3** β Dungeon streaming + portal space + `PlayerTeleport` handling.
+ (Unblocked by L.2e from M1.)
+- **H.4** β Character creation (`0xE000002 CharGen` + heritages + appearance
+ picker + preview).
+- **L.1e** β Emote + posture animation wiring.
+
+**Freeze on landing:**
+- H.3 dialog/quest zone
+- G.3 dungeon zone
+- H.4 character-creation zone
+- L.1e emote-animation zone
+
+**What "M4 lands" looks like:** the world feels populated and interactive.
+You can do quests, enter dungeons, create characters. Combined with M2/M3,
+the client is **functionally playable** β minus visual polish.
+
+---
+
+### M5 β "Looks like retail" β π’ PARALLELIZABLE WITH M3/M4 (~4β8 weeks)
+
+**Demo scenario:** Side-by-side screenshot of acdream vs retail at the same
+location, same time of day, same character. Hard to tell apart at a glance.
+Open the inventory panel β retail-skinned with the right font, icons, and
+9-slice panel borders.
+
+**Phases to ship:**
+- **C.1.5c** β Sky-PES dispatch chain (closes #2 lightning, #28 aurora,
+ #29 cloud thinness).
+- **C.2** β Dynamic point lights (fireplaces, lamps, torches with proper
+ local lighting).
+- **C.3** β Palette range tuning (skin/hair/eye colors match retail).
+- **C.4** β Double-sided translucent polys.
+- **D.2b** β Custom retail-look UI backend.
+- **D.3βD.7** β AcFont + dat sprites + core panels reskinned + HUD orbs +
+ cursor manager.
+- **L.1f** β NPC/monster + item-use animation coverage.
+
+**Freeze on landing:**
+- Visual polish zone (C.1.5c, C.2, C.3, C.4)
+- D.2b β D.7 UI-skin zone
+- L.1f NPC-anim zone
+
+**What "M5 lands" looks like:** the client visually convinces. Screenshots
+become postable. The "old / broken vs retail" feeling that drives most of
+the chronic ISSUES.md entries is gone.
+
+---
+
+### M6 β "Plugins ship" β π’ (~4 weeks)
+
+**Demo scenario:** A third party (not you) writes a small plugin against
+the published API β XP tracker, loot logger, simple chat filter β and it
+loads cleanly. Sample plugin lives in the repo with documented build steps.
+
+**Phases to ship:**
+- Plugin API surface: stable contract over `AcDream.Core` + `AcDream.UI.Abstractions`,
+ versioned, with the world-state interfaces exposed.
+- Plugin host: load isolation, lifecycle, error containment.
+- Sample plugin (XP tracker or loot logger) β proves the API by using it.
+- Plugin docs page.
+
+**Freeze on landing:**
+- Plugin API v1 surface (additive changes only post-freeze).
+
+**What "M6 lands" looks like:** the differentiator vs the retail client is
+real. acdream offers something retail never did, and the API is documented
+well enough that other people can build on it.
+
+---
+
+### M7 β "v1.0" β π’ (open-ended polish)
+
+**Demo scenario:** Long-running stress test: log in, play for 4 hours
+across outdoor + dungeon + portal + combat + spell + chat scenarios,
+reconnect once mid-session, log out clean. No crashes, no protocol errors,
+no visual regressions, no audio dropouts.
+
+**Phases to ship:**
+- **Phase M** β Network stack conformance (retransmit, ACK piggybacking,
+ echo/keepalive, fragment splitting, typed actions). Deferred until now
+ because ACE handles loss gracefully β but v1.0 needs proper network
+ hardening.
+- **H.2** β Allegiance.
+- **N.6 slice 2 + N.7βN.10** β Finish WB rendering migration (EnvCells,
+ sky/particles via WB, visibility manager, GL infrastructure
+ consolidation).
+- **Phase J long-tail** β Player rig polish, group/fellowship UI, trade
+ window, salvage/tinker UI, house ownership, society UI, dev-mode tools.
+- **L.1g** β Animation polish + conformance.
+- Final visual + audio polish pass against ISSUES.md chronic backlog.
+
+**What "M7 lands" looks like:** v1.0. Ship.
+
+---
+
+## Estimated timeline
+
+| Milestone | Effort | Cumulative |
+|---|---|---|
+| M0 | DONE | DONE |
+| M1 | ~4β6 wk | ~5 wk |
+| M2 | ~6β10 wk | ~13 wk |
+| M3 | ~3β4 wk | ~17 wk |
+| M4 | ~6β10 wk | ~25 wk |
+| M5 | ~4β8 wk (parallel) | overlaps M3/M4 |
+| M6 | ~4 wk | ~29 wk |
+| M7 | open-ended | v1.0 |
+
+**Roughly 9β12 months of focused solo work from 2026-05-12 to v1.0.** That's
+honest for an open-source project of this scale. The biggest single rock is
+M2 (combat math + animations + inventory panels lining up); M5 can be
+chipped at in parallel by subagents while you drive M3/M4.
+
+---
+
+## What this document is **not**
+
+- **Not a release schedule.** Internal morale + scope layer only. If acdream
+ goes public-alpha at some point, that's a separate decision built on top
+ of one of these milestones.
+- **Not immutable.** When reality and the milestones diverge, update the
+ milestones in the same session you discover the divergence. Same rule as
+ the roadmap.
+- **Not a replacement for the roadmap.** Phases are still where the
+ implementation details live. This doc is the orientation layer above them.
+- **Not granular enough for daily work.** Daily work happens at the phase /
+ sub-phase / commit level. The milestone is the multi-week target you're
+ aiming at.
diff --git a/docs/plans/2026-05-12-phase-c1.5b-handoff.md b/docs/plans/2026-05-12-phase-c1.5b-handoff.md
new file mode 100644
index 0000000..b314516
--- /dev/null
+++ b/docs/plans/2026-05-12-phase-c1.5b-handoff.md
@@ -0,0 +1,320 @@
+# Phase C.1.5b handoff β issue #56 + EnvCell statics + animation-hook verification
+
+**Created:** 2026-05-12, immediately after Phase C.1.5a merged to `main` (commit `88bda12`).
+**Audience:** the fresh-session Claude (or human) picking up C.1.5b.
+**Predecessor:** [C.1.5a portal PES wiring](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md) β slice 1, shipped.
+
+---
+
+## Β§1 Startup prompt (copy this into a fresh session)
+
+Everything below this fence is the prompt to paste into a new Claude Code session. The detailed context the session needs lives in Β§2+ of this same file.
+
+```
+Pick up Phase C.1.5b β issue #56 (multi-emitter per-part collapse) first,
+then EnvCell static-object DefaultScript dispatch + animation-hook
+particle path verification.
+
+## Context
+
+Phase C.1.5a (portal PES wiring) merged to main 2026-05-12 (merge commit
+88bda12). The PhysicsScriptRunner now fires Setup.DefaultScript on every
+server-spawned WorldEntity via the new EntityScriptActivator. Visual
+verification at the Holtburg Town network portal confirmed the mechanism
+works end-to-end (10-hook portal script fires correctly, color +
+persistence + orientation match retail), but exposed a pre-existing C.1
+limitation now tracked as ISSUE #56: ParticleHookSink ignores
+CreateParticleHook.PartIndex, so all 10 of the portal's emitters
+collapse to one root position β compressed, partly-ground-buried swirl.
+
+The C.1.5a final cross-task reviewer recommended #56 be resolved FIRST
+in this slice, before the EnvCell static-object walker, because slice
+2's natural visual gate (Holtburg inn interior fireplace, cottage
+chimney) uses the same multi-emitter pattern β without #56 fixed,
+slice 2 ships with the same visual gap.
+
+## Read first (in order)
+
+1. docs/plans/2026-05-12-phase-c1.5b-handoff.md (this file's Β§2+)
+2. docs/ISSUES.md #56 (the per-part collapse problem with reproducible
+ identifiers from the C.1.5a verification session)
+3. docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md Β§10
+ (slice 2 preview written during C.1.5a brainstorming)
+4. docs/plans/2026-04-27-phase-c1-pes-particles.md lines 285β295 (the
+ original C.1.5 scope source)
+
+## Two slices in this session
+
+### Slice A β issue #56 fix (per-part transform handling for static entities)
+
+For static entities (portals, EnvCell statics, building decorations β
+no animation), precompute the per-part offset from
+Setup.PlacementFrames[Resting] at spawn time and surface those offsets
+to the ParticleHookSink so SpawnFromHook can apply them. The handoff
+doc Β§3 has the suggested architecture + decision space.
+
+Acceptance: relaunch + walk to the Holtburg Town network portal. The
+10 emitters should distribute across the portal Setup's parts instead
+of collapsing β swirl extends vertically through the arch with
+retail-like shape, not buried in the ground.
+
+### Slice B β EnvCell static-object DefaultScript dispatch + animation-hook verification
+
+Walk EnvCell.StaticObjects for newly-loaded landblocks; for each
+StaticObject whose Setup has a non-zero DefaultScript, fire the
+activator with a synthetic entity ID (suggested scheme: hash of
+(landblockId, cellIndex, staticIndex) with a high-bit marker so it
+doesn't collide with server guids β see handoff Β§4). Then verify the
+animation-hook particle path (already shipped in C.1; just needs
+visual confirmation): cast a spell on +Acdream and compare to retail.
+
+Acceptance: Holtburg inn fireplace flames, cottage chimney smoke, and
+a spell-cast particle effect on +Acdream all match retail.
+
+## What this is NOT
+
+- Not a renderer change. particle.frag stays as-is; bindless migration
+ waits for N.6 slice 2 after this slice lands.
+- Not a perf phase. The N.6 baseline at radius=4 still holds; the
+ per-part precompute cost is bounded by N parts Γ M emitters per
+ spawned entity (small).
+- Not adding new emitter types. Use the existing PES emitter data.
+- Not touching the animated-entity path. For animated entities (NPCs,
+ monsters), per-part transforms vary per frame and would need a
+ per-tick refresh similar to UpdateEntityAnchor. Defer to a future
+ phase; C.1.5b stays scoped to static entities only.
+
+## Suggested workflow
+
+1. Read the handoff doc + the four referenced docs above.
+2. Invoke superpowers:brainstorming to settle:
+ - For slice A: precompute-per-part-at-spawn vs render-thread-side-table
+ approach (handoff Β§3 has the tradeoff analysis).
+ - For slice B: the synthetic-entity-id scheme; whether the EnvCell
+ walker piggybacks LandblockSpawnAdapter or gets its own class.
+ - Visual verification locations.
+3. After brainstorm: spec at
+ docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md (one spec
+ for both slices since they share the activator and tests), then plan
+ at docs/superpowers/plans/2026-05-13-phase-c1.5b.md, then execute
+ via superpowers:subagent-driven-development.
+
+## Open issues from C.1.5a worth knowing
+
+- #56 β multi-emitter per-part collapse. This slice's headline.
+- #55 β meshMissing diagnostic spam at radius=4 standstill. LOW
+ severity, not blocking; only touch if you're already in the
+ dispatcher for unrelated reasons.
+- Cold-path timing observation (C.1.5a Task 2 review): the activator
+ fires DefaultScript before pending-bucket entities are merged into
+ a loaded landblock. Mirrors existing _wbEntitySpawnAdapter pattern;
+ not a regression; defer.
+
+## Three doc-drift items from C.1.5a (trivial β fold into the new spec)
+
+1. C.1.5a spec Β§4 says "fifth (optional) parameter" β actually fourth.
+2. C.1.5a spec Β§4 says "~50 lines" β file ships at 93 lines.
+3. GpuWorldState.AddEntitiesToExistingLandblock (A.5 FarβNear
+ promotion path) does not fire the activator. No-op today because
+ promotion-tier entities are atlas-tier and the activator's
+ ServerGuid==0 guard would skip them anyway, but worth a code
+ comment explaining why the call is intentionally omitted there
+ (parallel to existing comments at the RemoveEntitiesFromLandblock
+ block in the same file).
+
+Start by reading the handoff doc, then ask me what slice-A/slice-B
+boundary feels right and what visual verification locations I want
+to target.
+```
+
+---
+
+## Β§2 What shipped in C.1.5a (so you don't re-do it)
+
+### Commits on `main` (oldest to newest under merge `88bda12`)
+
+| SHA | Title |
+|---|---|
+| `06d7fbd` | docs(vfx): Phase C.1.5a β portal PES wiring design spec |
+| `ed5335b` | docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes |
+| `003c502` | feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet) |
+| `e0529b0` | test(vfx #C.1.5a): real-emitter verification in OnRemove test + unused using |
+| `44d8502` | feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle |
+| `65d833d` | feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow |
+| `849690c` | refactor(vfx #C.1.5a): reuse SequencerFactory's capturedDats in resolver |
+| `334f0c6` | fix(vfx #C.1.5a): seed entity rotation in activator so hook offset rotates |
+| `9009318` | docs(vfx #C.1.5a): ship Phase C.1.5a + file issue #56 for per-part collapse |
+| `88bda12` | Merge branch 'claude/lucid-burnell-aab524' β Phase C.1.5a |
+
+### New files
+
+- [`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) β 93 lines including doc comments. Constructor `(PhysicsScriptRunner, ParticleHookSink, Func)`; `OnCreate(WorldEntity)` resolves the entity's `Setup.DefaultScript.DataId`, seeds `_particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation)`, and calls `_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position)`; `OnRemove(uint serverGuid)` calls `_scriptRunner.StopAllForEntity(serverGuid)` + `_particleSink.StopAllForEntity(serverGuid, fadeOut: false)`.
+- [`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`](../../tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs) β 4 xUnit `[Fact]` tests with mutation-check teeth verified during the C.1.5a code-quality reviews.
+
+### Modified files
+
+- [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../src/AcDream.App/Streaming/GpuWorldState.cs) β fourth optional ctor parameter `EntityScriptActivator? entityScriptActivator = null`, field `_entityScriptActivator`, and two `?.OnCreate(entity)` / `?.OnRemove(serverGuid)` calls immediately after the matching `_wbEntitySpawnAdapter?.OnCreate` / `?.OnRemove` calls in `AppendLiveEntity` and `RemoveEntityByServerGuid`.
+- [`src/AcDream.App/Rendering/GameWindow.cs`](../../src/AcDream.App/Rendering/GameWindow.cs) β new field declaration alongside `_wbEntitySpawnAdapter` and inline construction of the activator + resolver lambda inside the existing `OnLoad` block (~line 1620), passed to `GpuWorldState` as a named argument.
+
+### What's working
+
+- Server-spawned entities (`ServerGuid != 0`) with `Setup.DefaultScript.DataId != 0` fire that script through `PhysicsScriptRunner.Play` on enter-world.
+- Multi-hook scripts dispatch all their hooks in order (timed by `StartTime` offsets β more retail-faithful than WB's "all at once" collection).
+- `CreateParticleHook.Offset.Origin` rotates correctly from entity-local to world frame via the activator's `SetEntityRotation` seed.
+- Despawn cleanly stops all scripts + emitters for the entity.
+- 4 unit tests cover all three branches plus the rotation-seed correctness.
+- Visual verification at the Holtburg Town network portal passed for the mechanism: 10-hook portal script fires correctly with matching color, persistence, orientation, multi-emitter dispatch.
+
+## Β§3 Issue #56 decision space (slice A)
+
+### The problem
+
+`ParticleHookSink.SpawnFromHook` computes:
+
+```csharp
+var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) ? rot : Quaternion.Identity;
+var anchor = worldPos + Vector3.Transform(offset, rotation);
+```
+
+β¦where `worldPos` is `entity.Position` and `offset` is `cph.Offset.Origin`. The `CreateParticleHook.PartIndex` field is recorded into the per-handle tracking dict but never applied to the anchor. Retail's intended geometry is:
+
+```
+anchor = entityWorldPose Γ partLocalTransform[partIndex] Γ hookOffsetInPartLocal
+```
+
+Without the part transform multiplication, every emitter in a multi-emitter script lands at the same root position. Visible symptom: the Holtburg portal's 10 emitters compress to one point and the swirl appears partially buried because the offset's local-up direction goes off in world axes instead of the part's local axes.
+
+### Where part transforms come from
+
+For STATIC entities (no animation), per-part transforms come from `Setup.PlacementFrames[Resting].Frames[partIndex]` β see how `ObjectMeshManager.CollectParts` walks them in `references/WorldBuilder` (worktree-relative path; submodule must be initialized to read):
+
+- For each `i` in `0..setup.Parts.Count`, the per-part transform is `Matrix4x4.CreateScale(setup.DefaultScale[i]) * Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation) * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)`.
+- `DefaultScale` only applies when `SetupFlags.HasDefaultScale` is set.
+- Fall back to `PlacementFrames[Default]` if `Resting` isn't present.
+
+For ANIMATED entities (NPCs, monsters, the player), per-part transforms vary per animation frame and live in `AnimatedEntityState` / the animation tick. **Out of scope for C.1.5b.**
+
+### Approach options
+
+**Option A β precompute per-spawn, pass at activator-call time.**
+
+`EntityScriptActivator` reads the Setup's `PlacementFrames[Resting]` once per spawn, builds a `Matrix4x4[] partTransforms` array, and passes it to a new sink method `_particleSink.SetEntityPartTransforms(entityId, partTransforms)` before calling `_scriptRunner.Play(...)`. `ParticleHookSink.SpawnFromHook` then reads `_partTransformsByEntity` to apply per-hook:
+
+```csharp
+var partXf = _partTransformsByEntity.TryGetValue(entityId, out var pts) && partIndex < pts.Length
+ ? pts[partIndex] : Matrix4x4.Identity;
+var anchor = worldPos + Vector3.Transform(Vector3.Transform(offset, partXf), rotation);
+```
+
+Pros: clean ownership (activator owns the lifecycle of part transforms keyed by entityId), matches existing sink-state patterns (`_rotationByEntity`, `_renderPassByEntity`), small code surface, fully testable.
+
+Cons: stores per-entity array (matrix per part) β bounded but allocates. Doesn't compose with the animated-entity case (which would need per-tick refresh).
+
+**Option B β render-thread side-table populated by the dispatcher.**
+
+The `WbDrawDispatcher` already computes per-part world transforms each frame. Surface them via a side-table the sink queries. Per-frame.
+
+Pros: free composition with animated entities (the dispatcher transforms whether the entity is animated or not).
+
+Cons: render-thread / sink-thread coordination concern, bigger architectural surface, the dispatcher would need a new responsibility (publish part transforms) outside its draw-loop hot path. Risk of touching the modern bindless dispatcher's perf budget that N.5/N.5b worked to lock in.
+
+**Option C β sink-side dat lookup on demand.**
+
+`ParticleHookSink` calls `_dats.Get(...)` on the hook fire to look up the part transform. Pros: zero state on activator. Cons: introduces dat coupling into the sink (currently dat-free), per-hook-fire dat lookup is a hidden allocation, doesn't compose with animated entities either, and we'd be reading the same Setup multiple times for the same entity.
+
+### Recommended approach
+
+**Option A.** It's the smallest surface, matches the existing sink-state pattern, doesn't expand any other layer's responsibilities, and the "doesn't compose with animated entities" downside is intentional β animated entities are explicitly out of scope and will get their own treatment later, possibly via Option B at that time.
+
+### Test approach
+
+Mirror the C.1.5a `OnCreate_SetsEntityRotationForHookOffsetTransform` test: construct an entity whose Setup has 2 parts (root at origin + part 1 lifted at (0, 0, 1)), fire a CreateParticleHook with `PartIndex=1` and `Offset.Origin=(0, 0, 0)`, assert the spawned particle's world position is `(0, 0, 1)` (the part's offset, not the root). Add a mutation check: delete the `SetEntityPartTransforms` line and confirm the test fails.
+
+## Β§4 EnvCell static-object dispatch decision space (slice B)
+
+### The problem
+
+`EnvCell.StaticObjects` are interior decoration objects inside dungeon / building cells. Each StaticObject has a Setup reference and a placement frame. They have NO `ServerGuid` β they're dat-hydrated, not server-spawned.
+
+Our `EntityScriptActivator.OnCreate` early-returns when `entity.ServerGuid == 0` (atlas-tier guard). So as-is, the activator won't fire DefaultScript for EnvCell statics.
+
+### Two architectural questions
+
+**Q1 β synthetic entity ID for tracking + cleanup.**
+
+`PhysicsScriptRunner` keys active scripts by `(scriptId, entityId)`. `ParticleHookSink` keys per-entity emitter handles by `entityId`. EnvCell statics need a stable, unique 32-bit ID for these tables that won't collide with server guids (and won't collide between two EnvCell statics in different cells).
+
+Suggested scheme:
+
+```
+uint syntheticId = 0xC0000000u
+ | ((landblockId & 0x0000FF00u) << 16) // landblock X byte β bits 24-31 minus high marker
+ | ((landblockId & 0xFF000000u) >> 8) // landblock Y byte β bits 16-23
+ | ((cellIndex & 0x0000FFFFu) << 0); // bits 0-15: cell index within landblock
+```
+
+β¦leaving 4 bits for the static-object index within the cell. Adjust bit layout for the actual `(LandblockId, CellIndex, StaticIndex)` distribution. The `0xC0_______u` marker is **above** server guid range and **above** the anonymous-emitter range (`0x80_______u`) used by `ParticleHookSink._anonymousEmitterSerial`, so no collision.
+
+Sanity check: `WorldEntity.ServerGuid` is `uint`; the `(scriptId, entityId)` dedupe key in the runner only needs uniqueness, not semantic meaning. Either scheme works as long as it's collision-free.
+
+**Q2 β which adapter walks EnvCell.StaticObjects?**
+
+Three options:
+
+- **Option Ξ± β piggyback `LandblockSpawnAdapter`.** That adapter already walks `landblock.Entities` for atlas-tier mesh-ref counting. Extending it to also walk `EnvCell.StaticObjects` and fire DefaultScript via the activator keeps the per-landblock-load flow in one place. Cons: blurs the adapter's single responsibility.
+
+- **Option Ξ² β new `EnvCellStaticActivator` class.** Mirror `EntityScriptActivator`'s shape but key by synthetic-id, walking each loaded landblock's EnvCells on load and firing per-static-object. Cons: more code; slight duplication of the activator pattern.
+
+- **Option Ξ³ β `EntityScriptActivator` learns a "static-object" entry point.** Add `OnEnvCellStaticCreate(LoadedLandblock landblock, int cellIndex, int staticIndex, Setup setup, Vector3 worldPos, Quaternion worldRot)` to the existing activator. Compute the synthetic ID inside. Cons: signature creep on the activator.
+
+Recommended: **Option Ξ².** Keeps the existing activator's `WorldEntity`-shaped contract pure; the new class has a clean per-static-object contract; both share `_scriptRunner` and `_particleSink` instances so no architectural duplication, just two thin orchestrators.
+
+### Lifecycle
+
+EnvCell statics live as long as their parent landblock is loaded. On landblock unload, the new activator should stop all scripts for all its synthetic IDs from that landblock. Mirror `LandblockSpawnAdapter`'s `OnLandblockLoaded` / `OnLandblockUnloaded` lifecycle.
+
+## Β§5 Animation-hook verification (slice B's quick half)
+
+Already shipped in C.1: `MotionInterpreter` fires per-keyframe hooks through `IAnimationHookSink` β `ParticleHookSink`. We just haven't verified visually in the current codebase state.
+
+Procedure:
+1. Cast a spell on `+Acdream` (the test character likely has at least one spell + components configured β check or grant if needed).
+2. Watch the cast-anim particle effect (sparkles, glyphs, etc.) β does it match retail's casting animation?
+3. Optional: trigger an emote with a particle hook (the `\dance` / `\drink` emotes are good candidates if they have particle data).
+
+If broken, file an issue with the symptom. If working, mark slice B complete on verification.
+
+## Β§6 Verification locations
+
+All in or near Holtburg, within ~30s of `+Acdream`'s spawn:
+
+- **#56 fix re-verify** β the Town network portal used in C.1.5a. Same procedure as C.1.5a's Task 4 (see [the C.1.5a spec Β§8](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md)).
+- **EnvCell chimney** β any cottage / inn within the Holtburg outer perimeter with a smoking chimney in retail. Confirm via dual-client.
+- **EnvCell fireplace** β Holtburg Inn interior. Walk inside and stand near the fireplace. Confirm flame particles match retail.
+- **Animation-hook verify** β cast a spell standing somewhere safe (outside any aggro range). Compare to retail.
+
+## Β§7 File pointers for slice 2
+
+- Particle pipeline (Core): [`src/AcDream.Core/Vfx/ParticleSystem.cs`](../../src/AcDream.Core/Vfx/ParticleSystem.cs), [`ParticleHookSink.cs`](../../src/AcDream.Core/Vfx/ParticleHookSink.cs), [`PhysicsScriptRunner.cs`](../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs).
+- Activator (App): [`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs).
+- Streaming bridge (App): [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../src/AcDream.App/Streaming/GpuWorldState.cs), [`LandblockSpawnAdapter.cs`](../../src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs).
+- Renderer: [`src/AcDream.App/Rendering/ParticleRenderer.cs`](../../src/AcDream.App/Rendering/ParticleRenderer.cs) β **don't touch** in C.1.5b; bindless migration is N.6 slice 2.
+- EnvCell loader: search for `LoadedCell` / `EnvCell.StaticObjects` in `src/AcDream.App/Streaming/` and `src/AcDream.Core/World/`.
+- C.1.5a tests as a reference: [`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`](../../tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs).
+
+## Β§8 Open questions to surface during brainstorming
+
+- Slice A: does the C.1.5a final reviewer's "static-only fix is self-contained" claim hold up? (Section Β§3 Option A says yes; brainstorming should verify by checking `EntityScriptActivator`'s spawn path doesn't depend on animation state.)
+- Slice B: which Setup field actually lives on `EnvCell.StaticObjects` β is it a `SetupId` reference or an inline Setup? Different shape changes the synthetic-ID hash input.
+- Slice B: are EnvCell statics ALSO subject to the cold-path timing observation from C.1.5a Task 2 review (firing before the cell is rendered)?
+
+## Β§9 Worktree cleanup reminder (one-time, from outside the worktree)
+
+The C.1.5a worktree directory at `C:/Users/erikn/source/repos/acdream/.claude/worktrees/lucid-burnell-aab524` was not auto-removed because the controller session held a file lock. After this session ends, from any other directory:
+
+```powershell
+git -C "C:/Users/erikn/source/repos/acdream" worktree remove --force `
+ "C:/Users/erikn/source/repos/acdream/.claude/worktrees/lucid-burnell-aab524"
+```
+
+The branch `claude/lucid-burnell-aab524` was successfully deleted; only the worktree directory needs manual cleanup.
diff --git a/docs/research/2026-05-08-phase-n3-handoff.md b/docs/research/2026-05-08-phase-n3-handoff.md
new file mode 100644
index 0000000..7b7e7fa
--- /dev/null
+++ b/docs/research/2026-05-08-phase-n3-handoff.md
@@ -0,0 +1,132 @@
+# Phase N.3 handoff β texture decoding via WorldBuilder
+
+**Use this whole document as the prompt** when handing off to a fresh
+agent. Everything they need to pick up cold is below.
+
+---
+
+## Background you'll need
+
+You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
+of Asheron's Call's retail client. The project's house rule (in
+`CLAUDE.md`) is **the code is modern, the behavior is retail**.
+
+acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`),
+the first sub-phase of a strategic migration to fork WorldBuilder
+(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested
+rendering + dat-handling code instead of porting algorithms from retail
+decomp ourselves.
+
+**Read first:**
+- `docs/architecture/worldbuilder-inventory.md` β the full taxonomy of
+ what WB has and what we keep porting ourselves
+- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
+ β the parent design doc for Phase N
+- `CLAUDE.md` β especially the "Reference repos" section (now points at
+ WB as the rendering BASE) and the workflow rules
+
+**Phase N.1 commit history (just shipped):** read
+`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were
+structured. The pattern repeats for N.3.
+
+## What N.3 is
+
+Replace acdream's texture decoding pipeline with WorldBuilder's
+`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16,
+P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations
+of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs`
+and possibly `src/AcDream.Core/Meshing/` β find them with
+`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`.
+
+## Acceptance criteria
+
+- Build green (`dotnet build`)
+- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests`
+ failures don't count β they exist on main)
+- New conformance tests added per format that's substituted (one xUnit
+ Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte
+ array decoded by our path vs WB's path; assertions on output pixel array.
+- Visual verification at Holtburg (or wherever) shows no texture
+ regressions: terrain texturing, mesh texturing, particle textures all
+ look the same.
+- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern β
+ if WB and retail disagree on something subtle, file it, don't try
+ to fix it inline).
+
+## Tasks (suggested decomposition)
+
+Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`)
+as the template. Concretely:
+
+1. **Audit our texture decode paths.** Grep, list every file/method that
+ decodes a texture. Map each to the WB equivalent in
+ `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
+ (read it end to end first).
+2. **Per-format conformance test.** TDD style: write the test, run it
+ to fail, then plumb the substitution. Conformance test fixture inputs
+ should include real-dat byte sequences (read a known-good texture from
+ a dat, encode the bytes as a hex blob in the test).
+3. **Substitution.** Replace each decode site with the WB call. Keep our
+ GL upload pathways β those are NOT WB's responsibility.
+4. **Visual verification.** Launch the client at Holtburg, walk around,
+ look at a tree (mesh texture), the ground (atlas texture), particles
+ (the recent C.1 rain/clouds/aurora work), and a building (composite
+ texture). Compare against retail or against a screenshot before the
+ change.
+5. **Delete legacy decoders** once visual verification passes.
+6. **Update roadmap + ISSUES** as the final commit.
+
+## Watchouts (lessons from N.1)
+
+- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`).
+ WB's `TextureHelpers` may have ACME-specific patches not yet in upstream.
+ Compare both before assuming WB's version is canonical. We forked
+ upstream WB; ACME is reference-only.
+- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was
+ caught by the conformance test. Don't skip them. If a test fails, it's
+ a real divergence β investigate before "fixing" the test.
+- **Whackamole stops the migration.** If 3+ visual regressions appear on
+ default-on, stop, file as ISSUES, ship. The migration goal is "use WB's
+ tested code"; pixel-perfect equivalence with our broken hand-ports is
+ not the goal.
+- **`Setup.SortingSphere` β `Setup.CylSphere`.** The N.1 attempt at
+ `obj_within_block` over-suppressed because we used the wrong radius
+ source (sorting sphere too large). For texture decoding this likely
+ doesn't matter, but the general lesson is: read WB's full source
+ carefully before adapting; don't assume parallel methods do parallel
+ things.
+- **Per-vertex road check β STOP signal.** If you find yourself reading
+ ACME for "what's missing" and considering a per-vertex filter, STOP.
+ N.1 tried this (commit `e279c46`), regressed visually, reverted in
+ `677a726`. ACME's filter set works as a coherent unit; pick-and-choose
+ fails. If the N.3 work uncovers a similar ACME-only filter, file it
+ in ISSUES and move on, don't port it inline.
+
+## Where to start
+
+1. `git pull` on main to get the latest (Phase N.1 just merged).
+2. Create a new worktree for the work:
+ `git worktree add .claude/worktrees/ -b claude/`.
+3. Read the three "read first" docs above.
+4. Run `dotnet build && dotnet test` to confirm clean baseline.
+5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
+ end to end. Take notes on the public API surface.
+6. Run the audit task (#1 in Tasks above). Output should be a markdown
+ table of "our function / file:line / WB equivalent / format covered."
+7. Use `superpowers:writing-plans` to convert the audit into a concrete
+ per-format plan. Then use `superpowers:subagent-driven-development`
+ to execute it with fresh subagents per format.
+
+## Useful greps
+
+- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` β find decode paths
+- `grep -rln "TextureCache" src/` β find our cache layer
+- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` β WB's public API
+
+## Open question to resolve early
+
+Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the
+formats we use, or does it have gaps? Audit our texture types against
+WB's API in step 1. If WB is missing a format we need, the migration for
+that format gets deferred (file in ISSUES; keep our decoder for it; note
+in the roadmap).
diff --git a/docs/research/2026-05-08-phase-n4-week4-handoff.md b/docs/research/2026-05-08-phase-n4-week4-handoff.md
new file mode 100644
index 0000000..6ab30fe
--- /dev/null
+++ b/docs/research/2026-05-08-phase-n4-week4-handoff.md
@@ -0,0 +1,318 @@
+# Phase N.4 Week 4 handoff β full draw dispatcher + visual verification + ship
+
+**Use this whole document as the prompt** when handing off to a fresh
+agent. Everything they need to pick up cold is below.
+
+---
+
+## Background you'll need
+
+You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
+of Asheron's Call's retail client. The project's house rule (in
+`CLAUDE.md`) is **the code is modern, the behavior is retail**.
+
+acdream is in the middle of Phase N.4 β the rendering pipeline
+foundation migration to WorldBuilder's `ObjectMeshManager` +
+`TextureAtlasManager`. **Three of the four planned weeks have shipped
+this session (2026-05-08)**:
+
+- Week 1 (commits up through `c49c6ed`): foundation types β feature
+ flag, surface metadata side-table, mesh-extraction + setup-flatten
+ conformance tests, `WbMeshAdapter` constructed against the real WB
+ pipeline.
+- Week 2 (commits up through `36f7a60`): streaming integration β
+ `LandblockSpawnAdapter` routes atlas-tier (procedural / `ServerGuid==0`)
+ GfxObjs to WB's ref-count lifecycle. `WbMeshAdapter.Tick()` drains
+ the WB pipeline's main-thread queues per frame (fixes a real memory
+ leak).
+- Week 3 (commits up through `d30fcb2`): per-instance tier hookup β
+ `AnimatedEntityState` holds per-server-spawned-entity overrides;
+ `EntitySpawnAdapter` routes server-spawned entities through the
+ existing `TextureCache.GetOrUploadWithPaletteOverride` decode path.
+
+**Current state at `main`:** build green, **947 tests pass**, 8
+pre-existing failures only (unchanged from pre-N.4 main). Default-off
+behavior is byte-identical to pre-N.4 main; flag-on (`ACDREAM_USE_WB_FOUNDATION=1`)
+runs both rendering pipelines in parallel β WB silently prepares
+content, but nothing is yet drawn through it.
+
+**Read first:**
+
+- [docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md](../superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md) β
+ the **living-document** plan. Top of file has a Progress table
+ showing Tasks 1-21 β
shipped with commit SHAs. Adjustments 1-5
+ document architectural surprises caught during execution. **Read the
+ Adjustments before writing any Task 22 code** β they explain why the
+ current architecture is what it is.
+- [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md) β
+ the design spec. Architecture / two-tier split / animation handling /
+ data-flow diagrams. Strategic source of truth for "how the pieces
+ fit together."
+- [CLAUDE.md](../../CLAUDE.md) β project-wide rules. The "Currently in
+ flight" section near the top points at the plan.
+
+## What Week 4 is
+
+Seven tasks (22-28). **Task 22 alone is the biggest single task in the
+entire 28-task plan** β it's the moment we flip from "WB is silently
+preparing content" to "WB is drawing content to your screen."
+
+The remaining six tasks are smaller: surface-metadata side-table
+population, sky-pass preservation check, micro-tests round-out, visual
+verification at 5 named locations, flag default-on, delete legacy
+code, finalize plan + memory + ISSUES.
+
+**Task 22 also unlocks the Adjustment 3 mitigation.** Right now
+flag-on has a real FPS regression because both rendering pipelines run
+in parallel (legacy renderer still does atlas-tier upload + draw,
+even though WB is also building atlas state). When Task 22 lands the
+dispatcher AND wires the legacy-renderer short-circuit for atlas-tier
+content, that double-work disappears.
+
+## Two unresolved decisions before Task 22 starts
+
+These need a brainstorm checkpoint at the start of Week 4, NOT a
+"just dispatch":
+
+1. **Adjustment 4 plumbing.** `WorldEntity` doesn't carry
+ `HiddenPartsMask` or `AnimPartChanges` β those live on the
+ network-layer spawn record and don't make it to the render-side
+ entity. Two options:
+ - **A**: add `HiddenPartsMask` + `AnimPartChanges` fields to
+ `WorldEntity`, populate at spawn time. Cleaner long-term; small
+ networkβrender plumbing change.
+ - **B**: thread them as separate parameters into
+ `EntitySpawnAdapter.OnCreate(entity, hiddenMask, animPartChanges)`.
+ Sidesteps the `WorldEntity` change but couples the spawn-handler
+ to the adapter API.
+
+ Decide before writing Task 22 because the dispatcher reads from
+ `AnimatedEntityState` which currently holds defaults (empty mask +
+ empty override map). Without this resolved, hidden parts won't
+ actually be hidden flag-on.
+
+2. **Surface-metadata side-table population strategy** (Task 23). The
+ spec proposes: when `WbMeshAdapter.IncrementRefCount(id)` is first
+ called for a GfxObj, walk its sub-meshes via `GfxObjMesh.Build`,
+ write each `(gfxObjId, surfaceIdx) β AcSurfaceMetadata` entry into
+ the side-table. The `_metadataPopulated: HashSet` field
+ tracks which ids have been processed.
+
+ **But:** if the same GfxObj gets its ref count drop to zero and
+ then re-incremented (LRU eviction + reload), do we re-populate?
+ The metadata is invariant per-GfxObj (surface flags don't change
+ with eviction), so probably no β the `HashSet` is fine. But
+ verify before implementing.
+
+## Watchouts (lessons from Weeks 1-3)
+
+These are real, observed gotchas. Read each before going deeper.
+
+- **The renderer is tier-blind by design (Adjustment 2).** Don't try
+ to put routing decisions in `InstancedMeshRenderer` or any mesh
+ uploader. Routing belongs at the **spawn-callback layer**:
+ `LandblockSpawnAdapter` for atlas-tier, `EntitySpawnAdapter` for
+ per-instance. Task 22's dispatcher reads from those adapters'
+ per-entity state at draw time; it doesn't make tier decisions.
+
+- **Flag-off must stay byte-identical to pre-N.4.** Every Task-22 code
+ path must have a `WbFoundationFlag.IsEnabled` gate. Default-off path
+ is what users see; we can't regress it.
+
+- **WB's pipeline does work even when you're not draining its results.**
+ Adjustment 3: `IncrementRefCount` triggers background mesh prep,
+ texture decode, atlas allocation. `WbMeshAdapter.Tick()` already
+ drains the upload queue per frame. The remaining FPS cost is
+ pure dual-pipeline cost (legacy + WB doing the same upload work).
+ Task 22's short-circuit fixes this.
+
+- **`MeshRef.SurfaceOverrides`** is the per-surface texture-swap data
+ carried by spawned entities. `GfxObjSubMesh.SurfaceId` is what gets
+ swapped. Task 22's draw loop must consult both: the entity's
+ `MeshRef.SurfaceOverrides` for explicit swaps, and otherwise the
+ mesh's built-in `SurfaceId`.
+
+- **Conformance tests catch divergences early.** Per N.1's rotation
+ bug: write the conformance test BEFORE the substitution. The
+ matrix-composition test (`(entityWorld) Γ (animation) Γ (restPose)`)
+ is the load-bearing one for Task 22 β pin it before integrating.
+
+- **`WbMeshAdapter.Tick()` is required.** It's already wired into
+ `GameWindow.OnRender`. Task 22's dispatcher needs the upload queue
+ drained BEFORE it tries to draw, so order in OnRender is:
+ `_wbMeshAdapter?.Tick()` β `_wbDrawDispatcher?.Draw(...)` β other
+ draw work.
+
+- **Name retail decomp first; Phase N.4 doesn't change that rule.**
+ Task 22's matrix composition uses standard graphics math β no AC-
+ specific algorithms β so the "grep `named-retail/` first" workflow
+ doesn't apply to the matrix code itself. But for any AC-specific
+ question that surfaces during integration (e.g., "does retail
+ render hidden parts as zero-alpha or skip them entirely?"), grep
+ `docs/research/named-retail/acclient_2013_pseudo_c.txt` first.
+
+## Acceptance criteria for Week 4
+
+From the plan:
+
+- [ ] All conformance tests pass (Tasks 3, 4, 20 β already shipped;
+ verify still green after Task 22 lands).
+- [ ] All component micro-tests pass (Tasks 11, 17, 18, 19, 22 β
+ Task 22 adds matrix-composition tests).
+- [ ] All existing tests still pass. 8 pre-existing failures don't
+ count.
+- [ ] Build green throughout.
+- [ ] Visual verification at 5 named locations passes:
+ 1. Holtburg outdoor β terrain props, scenery, buildings, NPCs,
+ characters all render correctly.
+ 2. Drudge Hideout (or comparable) β EnvCell + interior lighting +
+ animated creatures.
+ 3. Foundry β heavy NPC traffic + customized appearances.
+ 4. A character with extreme palette overrides.
+ 5. Long roam (5+ minutes) β GPU memory stabilizes (LRU eviction
+ fires).
+- [ ] Memory budget enforcement actually verified (Task 13 was
+ deferred to here; Task 22 makes it testable because GL resources
+ finally get allocated for LRU to evict).
+- [ ] Sky pass renders identically (load-bearing β sky's
+ `Translucent+ClipMap` cloud sheet, raw-`Additive` fog skip,
+ `Luminosity` keyframe handling all flow through the side-table
+ via `AcSurfaceMetadata`).
+- [ ] Flag flipped to default-on at the end (Task 26).
+- [ ] Legacy code paths deleted (Task 27).
+- [ ] Roadmap + memory + ISSUES updated (Task 28).
+
+## Tasks 22-28 β quick map
+
+Full detail is in the plan. Brief here:
+
+- **22 β `WbDrawDispatcher` full draw loop.** ~1-2 days. Atlas-tier
+ + per-instance-tier draw with matrix composition. Reads from
+ `WbMeshAdapter.GetRenderData(id)` for atlas content; reads from
+ `EntitySpawnAdapter.GetState(serverGuid)` for per-instance state;
+ composes per-part `(entity Γ animation Γ rest-pose)` matrices;
+ pushes uniforms; issues GL draws. **Also wires the legacy-
+ renderer short-circuit** for atlas-tier content (the Adjustment 3
+ fix).
+- **23 β Surface-metadata side-table population.** ~half day. Hook
+ into `WbMeshAdapter.IncrementRefCount` so that on first registration
+ of a GfxObj, the side-table gets populated with one
+ `AcSurfaceMetadata` per surfaceIdx (using `GfxObjMesh.Build`'s
+ metadata as the source of truth).
+- **24 β Sky-pass preservation check.** ~half day. Verify the sky
+ pass's `NeedsUvRepeat` / `DisableFog` / `Luminosity` flow through
+ the side-table to `SkyRenderer` correctly. Likely no code change;
+ smoke-test sky rendering with flag on, weather/day-night cycle.
+- **25 β Component micro-tests round-out.** Audit existing tests
+ against the spec's Testing section. Probably nothing to add since
+ Tasks 11/17/18/19/22 already cover the listed micro-tests.
+- **26 β Visual verification + flag default-on.** Human-in-the-loop
+ walk through the 5 named locations. If clean, flip
+ `WbFoundationFlag.IsEnabled` from `== "1"` to `!= "0"` so flag-on
+ becomes the default.
+- **27 β Delete legacy code paths.** Remove the now-unused legacy
+ upload code in `StaticMeshRenderer` + `InstancedMeshRenderer`.
+ N.6 fully replaces these files anyway.
+- **28 β Update roadmap + memory + ISSUES + finalize plan.** Mark
+ N.4 shipped in the roadmap's Live β table. File any cosmetic
+ deltas as ISSUES. Add a memory note if a durable lesson emerged.
+ Flip the plan's status header from "Living document β work in
+ progress" to "Final state β phase shipped (merge ``)".
+
+## Where to start
+
+1. **Read the three "Read first" docs above end-to-end.** Especially
+ the Adjustments section in the plan β those are the architectural
+ constraints Task 22 must respect.
+2. **Decide Adjustment 4 plumbing** (option A vs B from above). This
+ is a small brainstorm checkpoint, not a multi-question
+ `superpowers:brainstorming` skill invocation. Document the choice
+ inline in the plan as Adjustment 6.
+3. **Don't create a new worktree.** The existing branch
+ `claude/quirky-jepsen-fd60f1` and worktree
+ `.claude/worktrees/quirky-jepsen-fd60f1` are clean and ready.
+ Submodule already initialized. Build green.
+4. **Use `superpowers:subagent-driven-development`** to execute Week 4
+ task-by-task. Pattern from Weeks 1-3: dispatch one subagent per
+ task (or batch of related tasks), use Sonnet for implementation,
+ merge to main per logical chunk, update the plan's Progress table
+ as commits land.
+5. **Pause for visual verification at Task 26.** This is a human-in-
+ the-loop step β needs you to walk the 5 named locations.
+
+## Open questions a fresh agent might hit
+
+- **Q: Why did Adjustment 5 mark Task 20 (per-instance decode
+ conformance) as "structural"?** Because both old and new paths call
+ the same `TextureCache.GetOrUploadWithPaletteOverride` function. We
+ preserved the decode logic exactly; the seam is at the call site,
+ not at the algorithm. Byte-equality is automatic.
+
+- **Q: Can I delete `InstancedMeshRenderer`?** Not in N.4. The plan
+ marks it as "becomes a thin adapter in N.4, fully replaced in N.6."
+ Task 27 deletes the legacy upload paths inside it but keeps the
+ file as a draw-orchestration adapter until N.6.
+
+- **Q: What's the memory budget check actually checking?** GPU memory
+ stabilizes during long roam. WB's `_maxGpuMemory = 1 GB` triggers
+ LRU eviction once the cache exceeds that. We verify by walking
+ for 5+ minutes at radius 7 (49 landblocks visible at any time) and
+ confirming GPU memory in the title bar plateaus rather than
+ growing unboundedly.
+
+- **Q: What happens if Task 22 takes longer than expected?** The
+ living-document convention says document Adjustments inline. If
+ Task 22 needs to split (e.g., atlas-tier draw lands first, per-
+ instance tier in a follow-on commit), that's fine β just update
+ the Progress table and add an Adjustment explaining the split.
+
+## Useful greps and commands
+
+- `dotnet build --verbosity quiet 2>&1 | tail -3` β quick build check.
+- `dotnet test --verbosity quiet 2>&1 | tail -3` β full test suite.
+- `git -C C:\Users\erikn\source\repos\acdream log --oneline -10` β
+ recent main commits.
+- `grep -rn "WbFoundationFlag.IsEnabled" src/` β every place we gate
+ on the flag (audit before flipping default-on in Task 26).
+- `grep -rn "_wbMeshAdapter\|_wbSpawnAdapter\|_wbEntitySpawnAdapter" src/` β
+ every WB adapter wiring point.
+
+## Smoke-test launch (PowerShell)
+
+```powershell
+# Kill any stale processes first
+Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
+Start-Sleep -Seconds 4
+
+# Flag-on at radius 7 β Week 4 dev environment
+$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_USE_WB_FOUNDATION = "1"
+$env:ACDREAM_STREAM_RADIUS = "7"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "n4-week4-smoke.log"
+```
+
+(Drop the `ACDREAM_USE_WB_FOUNDATION` line for flag-off comparison.)
+
+## Adjustments index β quick reference
+
+For full text, see the plan document (each is a `### Adjustment N`
+subsection under Task 6's old position, in chronological order):
+
+1. **`DefaultDatReaderWriter` discovery** (2026-05-08) β no
+ dat-reader bridge needed; WB ships a usable concrete
+ implementation.
+2. **Renderer is tier-blind** (2026-05-08) β routing belongs at
+ spawn callbacks, not in the renderer.
+3. **FPS regression = dual-pipeline cost** (2026-05-08) β both
+ pipelines run in parallel until Task 22's short-circuit lands.
+4. **`WorldEntity` lacks HiddenParts/AnimPartChange fields**
+ (2026-05-08) β plumbing deferred; Task 22 needs to resolve
+ (option A: add fields; option B: thread as separate args).
+5. **Task 20 is structural** (2026-05-08) β same function called
+ both paths, byte-equality automatic, no test file needed.
diff --git a/docs/research/2026-05-08-phase-n5-handoff.md b/docs/research/2026-05-08-phase-n5-handoff.md
new file mode 100644
index 0000000..1c4d7be
--- /dev/null
+++ b/docs/research/2026-05-08-phase-n5-handoff.md
@@ -0,0 +1,495 @@
+# Phase N.5 β Modern Rendering Path β Cold-Start Handoff
+
+**Created:** 2026-05-08, immediately after N.4 ship.
+**Audience:** the next agent picking up rendering perf work.
+**Purpose:** give you everything you need to start N.5 cold, without
+spelunking through five months of session history.
+
+---
+
+## TL;DR
+
+N.4 just shipped: WB's `ObjectMeshManager` is now acdream's production
+mesh pipeline, and `WbDrawDispatcher` is the production draw path. It
+works (Holtburg renders correctly, FPS substantially improved over the
+naΓ―ve dual-pipeline state we hit during week 4 verification) but it's
+still doing per-group state changes (`glBindTexture`, `glBindBuffer`
+for the IBO, `glDrawElementsInstancedBaseVertexBaseInstance` per group)
+and a fresh `glBufferData` upload per frame.
+
+**N.5's job: lift the dispatcher onto WB's modern rendering primitives
+that we're already paying GPU-feature-detection cost for.** Two big
+wins, paired:
+
+1. **Bindless textures** (`GL_ARB_bindless_texture`) β WB already
+ populates `ObjectRenderBatch.BindlessTextureHandle`. Switch our
+ shader to read texture handles from a per-instance attribute
+ (`uvec2` β `sampler2D` via the bindless extension). Eliminates
+ 100% of `glBindTexture` calls.
+2. **Multi-draw indirect** (`glMultiDrawElementsIndirect`) β build a
+ buffer of `DrawElementsIndirectCommand` structs (one per group),
+ upload once, fire ONE `glMultiDrawElementsIndirect` call per pass.
+ The driver pulls everything from the indirect buffer.
+
+Together they target a 2-5Γ CPU win on draw-heavy scenes (Holtburg
+courtyard, Foundry, dense dungeons). They're packaged together because
+both are "modern path" extensions we already gate on, both require
+the same shader rewrite, and they pair naturally β multi-draw indirect
+is a no-op CPU-win without bindless because per-group `glBindTexture`
+calls would still serialize.
+
+**Estimated scope: 2-3 weeks.** Plan + spec to be written by the
+brainstorm + spec steps below.
+
+---
+
+## Where N.4 left things
+
+### Branch state
+
+If this handoff is being read on `main` after merging the N.4 worktree:
+N.4 commits land at the head of main. The relevant final commits:
+
+- `c445364` β N.4 SHIP (flag default-on, plan final, roadmap, memory)
+- `573526d` β perf pass 1-4 (drop dead lookup, sort, cull, hash memo)
+- `7b41efc` β FirstIndex/BaseVertex + Issue #47 + grouped instanced
+- `943652d` β load triggers + `batch.Key.SurfaceId` source
+- `01cff41` β Tasks 22+23 (`WbDrawDispatcher` + side-table)
+
+If the worktree branch (`claude/tender-mcclintock-a16839`) hasn't been
+merged yet, that's where the work is. Verify with `git log --oneline`.
+
+### What works in N.4
+
+- `ACDREAM_USE_WB_FOUNDATION=1` is default-on. WB's `ObjectMeshManager`
+ loads, decodes, and uploads every entity mesh. Our existing
+ `TextureCache` decodes textures (palette-aware, per-instance overrides
+ via `GetOrUploadWithPaletteOverride`).
+- `WbDrawDispatcher.Draw`:
+ - Walks visible entities (per-landblock AABB cull + per-entity AABB
+ cull + portal visibility)
+ - Buckets every (entity Γ meshRef Γ batch) tuple by
+ `GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency)`
+ - Single `glBufferData` upload of all matrices for the frame
+ - Per group: `glActiveTexture(0) + glBindTexture(2D, handle) + glBindBuffer(EBO, ibo) + glDrawElementsInstancedBaseVertexBaseInstance(..., FirstInstance)`
+ - Two passes: opaque (front-to-back sorted) + translucent
+- 940/948 tests pass (8 pre-existing failures unrelated to rendering).
+- Visual verification at Holtburg passed: scenery + characters render
+ correctly with full close-detail geometry (Issue #47 preserved).
+
+### What N.5 inherits
+
+These are levers N.5 will pull on:
+
+- **WB's modern rendering is already active.** `OpenGLGraphicsDevice`
+ detected GL 4.3 + bindless on first run; WB's `_useModernRendering`
+ is true; every mesh lives in WB's single `GlobalMeshBuffer` (one VAO,
+ one VBO, one IBO).
+- **Bindless handles are already populated.** `ObjectRenderBatch.BindlessTextureHandle`
+ is non-zero for batches WB owns the texture for. (See gotcha #2
+ below for entities with palette overrides β those use acdream's
+ `TextureCache` which doesn't expose bindless handles yet.)
+- **The instance VBO is acdream-owned** (`WbDrawDispatcher._instanceVbo`)
+ with locations 3-6 patched onto WB's global VAO. Stride 64 bytes
+ (one mat4). N.5 expands this to (mat4 + uvec2 handle) = 80 bytes.
+
+### Three load-bearing WB API gotchas N.4 surfaced
+
+These bit us hard during Task 26 visual verification. Documented in
+CLAUDE.md "WB integration cribs" + plan adjustments 7-9 +
+`memory/project_phase_n4_state.md`. Re-stating here because they
+reshape the design space:
+
+1. **`ObjectMeshManager.IncrementRefCount(id)` is NOT lifecycle-aware.**
+ It only bumps a usage counter. Mesh loading is fired separately
+ via `PrepareMeshDataAsync(id, isSetup)`. The result auto-enqueues
+ to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`); our
+ existing `WbMeshAdapter.Tick()` drains it. `WbMeshAdapter.IncrementRefCount`
+ already calls `PrepareMeshDataAsync`. **N.5 doesn't need to change
+ this β just don't break it.**
+
+2. **`ObjectRenderBatch.SurfaceId` is unset.** WB constructs batches
+ with `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct
+ that has a `SurfaceId` field) but never populates the top-level
+ `SurfaceId` property. Read `batch.Key.SurfaceId`. **N.5 keeps this
+ pattern.**
+
+3. **WB's modern rendering packs every mesh into ONE global
+ VAO/VBO/IBO.** Each batch's `IBO` field points to the global IBO;
+ the batch's actual slice is identified by `FirstIndex` (offset into
+ IBO, in *indices*) and `BaseVertex` (offset into VBO, in *vertices*).
+ N.4's draw uses `glDrawElementsInstancedBaseVertexBaseInstance`
+ with those offsets. **N.5's `DrawElementsIndirectCommand` per-group
+ record will carry `firstIndex` + `baseVertex` for the same reason.**
+
+---
+
+## What N.5 is β technical detail
+
+### The two-feature pairing
+
+**Bindless textures** (`GL_ARB_bindless_texture`):
+- Each texture handle is a 64-bit integer (`uvec2` in GLSL).
+- Shader declares `layout(bindless_sampler) uniform sampler2D ...` or
+ receives the handle as a per-vertex-attribute `uvec2`.
+- No `glBindTexture` needed at draw time β the handle IS the binding.
+- Handle generation: `glGetTextureHandleARB(textureId)` followed by
+ `glMakeTextureHandleResidentARB(handle)` (the texture must be
+ resident on the GPU; non-resident handles produce GPU faults).
+
+**Multi-draw indirect** (`glMultiDrawElementsIndirect`):
+- Indirect command struct layout (must match `DrawElementsIndirectCommand`):
+ ```c
+ struct {
+ uint count; // index count for this draw
+ uint instanceCount; // number of instances
+ uint firstIndex; // offset into IBO, in indices
+ int baseVertex; // vertex offset into VBO
+ uint baseInstance; // first instance ID (offsets per-instance attribs)
+ };
+ ```
+- Build a buffer of N of these structs (one per group), upload once,
+ fire one GL call: `glMultiDrawElementsIndirect(mode, indexType, ptr, drawcount, stride)`.
+- The driver issues all N draws in one shot. Effectively zero CPU
+ overhead per draw beyond uploading the indirect buffer.
+
+**Why pair them.** Multi-draw indirect doesn't let you change uniform
+state between draws. So if textures are bound via `glBindTexture` per
+group, you'd still need N CPU-side setup steps before each indirect
+call β defeating the purpose. Bindless removes that constraint by
+encoding the texture handle as per-instance data the shader reads
+directly. With both, the modern render loop becomes:
+
+```
+1. Upload instance buffer (mat4 + uvec2 handle, per-instance) β once per frame
+2. Upload indirect command buffer (one DEIC per group) β once per frame
+3. glBindVertexArray(globalVAO) β once
+4. glMultiDrawElementsIndirect(...) β ONCE per pass
+```
+
+That's it. No per-group state changes.
+
+### Instance attribute layout
+
+Currently (N.4): location 3-6 = mat4 model matrix (16 floats = 64 bytes).
+
+N.5 (proposed): location 3-6 = mat4 + location 7 = uvec2 bindless
+handle = 16 floats + 2 uints = 72 bytes (16-aligned to 80 bytes per
+WB's `InstanceData` precedent).
+
+Or use std140-aligned struct:
+```c
+struct InstanceData {
+ mat4 transform; // locations 3-6
+ uvec2 textureHandle; // location 7
+ uvec2 _pad; // padding to 80
+};
+```
+
+Brainstorm should decide if we copy WB's `InstanceData` struct (Pack=16,
+80 bytes including CellId/Flags fields we don't use) or define our own
+minimal version. The 80-byte stride matches WB's so global VAO state
+configured by WB stays compatible if the legacy WB draw path ever runs.
+
+### Per-instance entity texture handles
+
+Here's the wrinkle. N.4 uses `WbDrawDispatcher.ResolveTexture` to map
+each (entity, batch) to a GL texture handle:
+
+- Tree (no overrides): `_textures.GetOrUpload(surfaceId)` β 2D texture handle
+- NPC with palette override: `_textures.GetOrUploadWithPaletteOverride(...)` β composite-cached 2D texture handle
+- Anything with surface override: `_textures.GetOrUploadWithOrigTextureOverride(...)` β composite-cached 2D texture handle
+
+Those are all `GLuint` 32-bit GL texture *names*, not bindless handles.
+**N.5 needs `TextureCache` to publish bindless handles for everything
+it owns, not just WB-owned textures.**
+
+Implementation sketch:
+- `TextureCache` adds a parallel cache keyed identically but storing
+ 64-bit bindless handles. On first request, generate via
+ `glGetTextureHandleARB(textureId)` + make resident.
+- New API: `GetBindlessHandle(uint surfaceId, ...)` returns the handle.
+- Or: change every `GetOrUpload*` method to return both the GL name
+ and the bindless handle (or just the handle; let GL name fall out
+ if anyone needs it later).
+
+WB's `ObjectRenderBatch.BindlessTextureHandle` covers the atlas-tier
+case. For per-instance entities, we use `TextureCache`'s handle.
+
+### The new shader
+
+Reuse WB's `StaticObjectModern.vert` / `StaticObjectModern.frag` as a
+template. Read those files cold. They already do bindless + the
+instance-data layout. Adapt to acdream's `mesh_instanced.vert/frag`
+conventions:
+
+- Keep the `uViewProjection` uniform, lighting UBO at binding=1, fog
+ uniforms.
+- Add `#version 430 core` + `#extension GL_ARB_bindless_texture : require`.
+- Replace `uniform sampler2D uDiffuse` with a `uvec2` per-vertex
+ attribute (location 7) β reconstruct sampler in vertex shader OR
+ pass through to fragment via flat varying.
+- Drop `uTranslucencyKind` uniform, OR keep it (still set per-pass β
+ multi-draw indirect doesn't break uniforms; only state that varies
+ per-draw is the constraint).
+
+### Translucency
+
+Multi-draw indirect can't change blend state mid-draw. Solution:
+**still use two passes** (opaque + translucent), but within translucent
+keep the per-blendfunc sub-passes (additive, alpha-blend, inv-alpha).
+Three sub-passes within translucent. Each sub-pass = one
+`glMultiDrawElementsIndirect` over its filtered groups.
+
+Or: if perf allows, fold all four blend modes into the shader via
+per-instance blendmode int, sort all translucent groups by blendmode
+in the indirect buffer, switch blend state at sub-pass boundaries.
+Brainstorm decides the cleanest pattern.
+
+---
+
+## Files to read before brainstorming
+
+In rough order:
+
+1. **N.4 plan + spec** β `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`
+ (status: Final). Adjustments 7-10 capture the gotchas. Spec at
+ `docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`.
+
+2. **N.4 dispatcher source** β `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`.
+ This is what you're modifying. Read end-to-end.
+
+3. **WB's modern rendering shaders** β `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObjectModern.vert`
+ + `StaticObjectModern.frag`. The template you're adapting from.
+
+4. **WB's `ObjectMeshManager.UploadGfxObjMeshData`** β lines ~1654-1780
+ of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`.
+ Shows how WB sets up the modern path's VBO/IBO/VAO. Especially note
+ how it patches in instance attribute slots (locations 3-6) on the
+ global VAO and configures location 7+ for bindless handles.
+
+5. **WB's `ObjectRenderBatch`** β same file, lines ~166-184. Note the
+ `BindlessTextureHandle` field β already populated when `_useModernRendering`
+ is on.
+
+6. **Our `TextureCache`** β `src/AcDream.App/Rendering/TextureCache.cs`.
+ Three composite caches: by surface id, by surface+origTex, by
+ surface+origTex+palette. N.5 adds parallel bindless-handle caches.
+
+7. **CLAUDE.md "WB integration cribs"** section. Lines ~28-80. The
+ three gotchas + the integration architecture in plain language.
+
+8. **Memory: `project_phase_n4_state.md`** β same content from a
+ different angle. Reading both helps lock in the gotchas.
+
+---
+
+## Brainstorm questions
+
+These are the questions to resolve in the brainstorm step. Don't
+prejudge them β bring them to the user with options + recommendation:
+
+1. **Instance attribute layout.** Match WB's `InstanceData` struct
+ (80 bytes including CellId/Flags fields we don't use) for global
+ VAO compatibility, or define a minimal acdream-specific version
+ (mat4 + handle = ~72 bytes padded to 80)?
+
+2. **Bindless handle generation strategy.**
+ - At texture upload time? (Eager β every texture that lands in
+ `TextureCache` gets a handle. Memory cost ~per-texture state.)
+ - On first draw lookup? (Lazy β cache fills as scene exercises
+ content. Possible first-use stall.)
+ - At spawn time via the spawn adapter? (Tied to lifecycle. Cleanest
+ but requires touching the spawn path.)
+
+3. **Translucent pass structure.** Three sub-indirect-draws (one per
+ blend mode) or a single sorted indirect buffer with per-instance
+ blend mode + state-flip at sub-pass boundaries? Or: just iterate
+ per-group like N.4 for translucent only (translucent groups are a
+ small fraction of total)?
+
+4. **Persistent-mapped indirect + instance buffers.** Use
+ `GL_ARB_buffer_storage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT`?
+ Triple-buffered ring + sync object? Or stick with `glBufferData`
+ (still one upload per frame, just larger)? Persistent mapping is
+ ~2-5% per-frame win in our context but adds buffer-management
+ complexity.
+
+5. **Shader unification.** Keep `mesh_instanced` for legacy + add
+ `mesh_indirect` for modern, or replace `mesh_instanced` entirely?
+ Replacement requires the legacy `InstancedMeshRenderer` (escape
+ hatch under `ACDREAM_USE_WB_FOUNDATION=0`) to also use the new
+ shader, which... probably doesn't matter if we delete legacy in
+ N.6 anyway. Brainstorm.
+
+6. **Conformance test strategy.** N.4 used visual verification at
+ Holtburg as the gate. N.5's gate is "no visual regression vs N.4
+ AND measurable CPU win." How do we measure CPU? `[WB-DIAG]`
+ counters give draw count + group count; we need frame-time
+ counters too. Add to the dispatcher? Use a profiler?
+
+7. **Per-instance entity bindless.** `TextureCache.GetOrUpload*`
+ returns a GL name. The dispatcher (or `TextureCache` itself) needs
+ to convert that to a bindless handle. Design questions:
+ - Where does the conversion happen?
+ - When is the texture made resident? (Residency is global state;
+ too many resident textures hits driver limits.)
+ - What about palette/surface overrides β same caching key as the
+ name, just a parallel handle dictionary?
+
+8. **Escape hatch.** N.4 keeps `ACDREAM_USE_WB_FOUNDATION=0` as a
+ fallback. N.5 needs to decide: does the new shader REPLACE the
+ N.4 dispatcher's draw path (so flag-on means N.5 modern path,
+ flag-off means legacy `InstancedMeshRenderer`)? Or do we add a
+ separate flag (`ACDREAM_USE_MODERN_DRAW`) so users can toggle
+ N.4 vs N.5 vs legacy independently? Three-way flag is more
+ complex but useful for A/B during rollout.
+
+---
+
+## Spec structure
+
+After the brainstorm, the spec doc covers:
+
+1. **Architecture diagram** β how `WbDrawDispatcher` changes shape.
+ Where the indirect buffer lives. Where bindless handles flow from.
+2. **Instance data layout** β exact struct, byte offsets, GL attribute
+ pointer setup.
+3. **TextureCache changes** β new methods, new cache, residency
+ policy.
+4. **Shader files** β name(s), version, extensions, in/out variables.
+5. **Conformance tests** β what to write, what coverage to claim.
+6. **Acceptance criteria** β visual identity to N.4 + measured CPU
+ delta.
+7. **Risks** β driver bugs in bindless / indirect, residency limits,
+ shader compile issues on weird GPUs, the legacy escape hatch
+ breaking.
+
+Spec lives at: `docs/superpowers/specs/2026-05-XX-phase-n5-modern-rendering-design.md`.
+
+## Plan structure
+
+After the spec, the plan doc lays out the week-by-week task list.
+Match N.4's plan structure (living document, task checkboxes, commit
+SHAs appended, adjustments documented inline). Plan lives at:
+`docs/superpowers/plans/2026-05-XX-phase-n5-modern-rendering.md`.
+
+Suggested initial breakdown (brainstorm + spec will refine):
+
+- **Week 1** β Plumbing: bindless handle generation in `TextureCache`,
+ shader rewrite (compile + bind), instance-attrib layout updated to
+ mat4+handle. Dispatcher still uses per-group draws but reads
+ textures bindless. Validate: visual identical to N.4.
+- **Week 2** β Indirect: build `DrawElementsIndirectCommand` buffer
+ per frame, switch to `glMultiDrawElementsIndirect`. Three-pass
+ translucent (or whatever brainstorm decides). Validate: visual
+ identical, draw-call count drops to 2-4 per frame.
+- **Week 3** β Polish + ship: persistent-mapped buffers if brainstorm
+ voted yes, profiler/counters, visual verification, flag flip, plan
+ finalization.
+
+---
+
+## Acceptance criteria for the whole phase
+
+- Visual output identical to N.4 (no character regressions, no
+ scenery missing, no z-fighting introduced)
+- `[WB-DIAG]` shows `drawsIssued` β€ ~5 per frame (down from N.4's
+ few hundred)
+- Frame time measurably lower in dense scenes (specify what scenes
+ to test in the spec β probably Holtburg courtyard + Foundry
+ interior)
+- All tests still green (940/948 + any new conformance tests)
+- `ACDREAM_USE_WB_FOUNDATION=0` escape hatch still works
+- Plan doc finalized, roadmap updated, memory captured if N.5
+ surfaces durable lessons (it almost certainly will β bindless
+ + indirect both have well-known driver gotchas)
+
+---
+
+## What you'll be doing in the first 30 minutes
+
+1. Read this handoff in full.
+2. Read CLAUDE.md "WB integration cribs" section.
+3. Read `WbDrawDispatcher.cs` end-to-end.
+4. Skim WB's `StaticObjectModern.vert/frag` + `ObjectMeshManager.UploadGfxObjMeshData`
+ to ground the reference.
+5. Verify build is green: `dotnet build`.
+6. Verify N.4 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|MatrixComposition"`
+ should produce 60 passing tests, 0 failures.
+7. Invoke the `superpowers:brainstorming` skill with the user. Walk
+ through the 8 brainstorm questions above. Capture decisions in a
+ spec.
+8. Write the spec at the path above.
+9. Write the plan at the path above.
+10. Begin Week 1 implementation per the plan.
+
+Don't skip the brainstorm. Multi-draw indirect + bindless have several
+real driver-compatibility / API-shape decisions that need user input,
+not "the agent makes a call and goes." This phase is structurally the
+same shape as N.4 β brainstorm β spec β plan β tasks-with-checkboxes β
+commits-update-checkboxes β final SHIP commit.
+
+---
+
+## Things to NOT do
+
+- **Don't delete the legacy `InstancedMeshRenderer`.** It's the N.4
+ escape hatch. N.6 retires it after N.5 is proven default-on.
+- **Don't fork WB.** N.4 deliberately avoided fork patches by using
+ the side-table pattern (`AcSurfaceMetadataTable`). Stay on that
+ path. If you need data WB doesn't expose, add a side-table or
+ decode it yourself from dats.
+- **Don't try to make per-instance entities use WB's `TextureAtlasManager`.**
+ That's N.6+ territory. acdream's `TextureCache` owns palette/surface
+ overrides because WB's atlas is keyed by `(surfaceId, paletteId,
+ stippling, isSolid)` and our overrides don't fit cleanly. Bindless
+ handles let us escape that mismatch β handles for both atlas-tier
+ AND per-instance-tier textures, no atlas adoption needed.
+- **Don't skip visual verification.** N.4 surfaced three bugs at
+ visual verification that no test caught. Don't trust "build green +
+ tests pass" β exercise the rendering path with the local ACE server.
+- **Don't extend the phase scope.** N.5 is bindless + indirect on
+ the existing rendering pipeline. Texture array atlas, GPU-side
+ culling, terrain wiring β all of those are subsequent phases. If
+ the brainstorm tries to expand, push back.
+
+---
+
+## Reference: the N.4 dispatcher flow you're modifying
+
+```
+Draw(camera, landblockEntries, frustum, ...) {
+ // Phase 1: walk entities, build groups
+ foreach (entity, meshRef, batch) {
+ cull, classify into _groups[GroupKey]
+ }
+
+ // Phase 2: lay matrices contiguously
+ // Phase 3: glBufferData(_instanceVbo, allMatrices)
+ // Phase 4: bind global VAO once
+ // Phase 5: opaque pass (sorted)
+ foreach (group in _opaqueDraws) {
+ glBindTexture(group.handle)
+ glBindBuffer(EBO, group.ibo)
+ glDrawElementsInstancedBaseVertexBaseInstance(...)
+ }
+ // Phase 6: translucent pass
+}
+```
+
+After N.5, Phases 5 and 6 collapse to:
+
+```
+glBindBuffer(DRAW_INDIRECT_BUFFER, _opaqueIndirect)
+glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, 0, opaqueGroups.Count, sizeof(DEIC))
+glBindBuffer(DRAW_INDIRECT_BUFFER, _translucentIndirect)
+// 3 sub-calls for translucent or 1 if shader-folded
+glMultiDrawElementsIndirect(...)
+```
+
+That's the destination. Get there cleanly.
+
+Good luck. Holler at the user if any of the brainstorm questions feel
+genuinely ambiguous after reading the references β they care about
+this phase landing right and will engage on design questions.
diff --git a/docs/research/2026-05-09-phase-n5b-handoff.md b/docs/research/2026-05-09-phase-n5b-handoff.md
new file mode 100644
index 0000000..05d7558
--- /dev/null
+++ b/docs/research/2026-05-09-phase-n5b-handoff.md
@@ -0,0 +1,445 @@
+# Phase N.5b β Terrain on the Modern Rendering Path β Cold-Start Handoff
+
+**Created:** 2026-05-09, immediately after N.5 ship + roadmap A.5 addition.
+**Audience:** the next agent picking up terrain rendering work.
+**Purpose:** give you everything you need to start N.5b cold, without
+spelunking through the N.5 session's history.
+
+---
+
+## TL;DR
+
+N.5 just shipped: `WbDrawDispatcher` lifts entity rendering onto bindless
+textures + `glMultiDrawElementsIndirect`. CPU dispatcher 1.23 ms / frame
+median at Holtburg courtyard, ~810 fps sustained. **Entities only β
+terrain is still on a separate legacy renderer.**
+
+**N.5b's job: port terrain rendering onto the same modern primitives that
+N.5 just delivered.** Concretely:
+
+1. Replace `TerrainRenderer` + `TerrainChunkRenderer` (per-landblock VAO,
+ `glDrawElements`, `sampler2D` atlases) with a multi-draw-indirect
+ dispatcher analogous to `WbDrawDispatcher`, sharing the modern path's
+ bindless texture infrastructure where it makes sense.
+2. Keep terrain visually identical to today. The legacy `TerrainAtlas` +
+ `terrain.vert/.frag` already render correctly; don't introduce visual
+ regressions.
+3. Resolve issue #51 (WB's terrain split formula diverges from retail's
+ `FSplitNESW`) β see "Load-bearing constraint" below.
+
+The roadmap estimate is **~1 week** because the modern-path primitives
+are already built. The actual work is porting + bridging + a real
+correctness decision on the split formula.
+
+---
+
+## Load-bearing constraint: Issue #51 (terrain split formula)
+
+This is the design decision that will dominate the brainstorm. **Read
+`docs/ISSUES.md` issue #51 in full before brainstorming.**
+
+The short version:
+
+- **acdream's terrain split formula** is the retail-decomp `FSplitNESW`
+ (constants `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`).
+ Documented in `CLAUDE.md` as **the** real AC formula. Ours is degree-2
+ polynomial in (x,y). Used by:
+ - `src/AcDream.Core/Physics/TerrainSurface.cs:113-120` (physics β
+ `IsSplitSWtoNE`)
+ - `src/AcDream.Core/World/TerrainBlending.cs` (visual mesh)
+- **WB's terrain split formula** in `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44`
+ is LINEAR in (x,y). Different math; they cannot be algebraically
+ equivalent. They disagree on a meaningful fraction of cells β up to
+ ~2m height delta on sloped cells.
+- **WB's `TerrainGeometryGenerator`** (the obvious adoption target for
+ N.5b's mesh path) uses WB's formula. If we adopt it wholesale, our
+ visual terrain disagrees with our physics (which uses retail's
+ formula). Player floats / sinks. Already-fixed bug class returns.
+
+**Three viable design paths** (the brainstorm has to pick one):
+
+- **Path A β Adopt WB's formula everywhere.** Switch both physics AND
+ visual mesh to WB's `CalculateSplitDirection`. Use WB's
+ `TerrainGeometryGenerator` directly. Visual + physics stay synced.
+ Risk: physics now disagrees with retail server-authoritative Z by up
+ to ~2m on sloped cells. Server-side validation (if any) might reject
+ movements; the player might "snap" to server's Z when packets land.
+ Need to confirm whether ACE actually validates Z or trusts the
+ client. Lowest implementation effort.
+
+- **Path B β Keep retail's formula; fork-patch WB.** Patch
+ `references/WorldBuilder/.../TerrainUtils.cs` to use retail's formula
+ in our fork. Push the patch to the `acdream` branch of the fork (per
+ the WB submodule plumbing fixed in the previous session). Submit
+ upstream PR if Chorizite wants it. Most retail-faithful. Implementation
+ effort: medium. Coordination overhead with upstream.
+
+- **Path C β Use WB's mesh layout but our formula.** Don't use WB's
+ `TerrainGeometryGenerator` directly. Instead port WB's *mesh layout*
+ (vertex buffer shape, index buffer per landblock, atlas integration)
+ into a new acdream-side `TerrainGeometryGenerator` that uses retail's
+ formula. Highest effort but cleanest separation β no fork patches.
+
+Recommendation in the brainstorm: probably **Path A** if quantification
+shows ACE doesn't validate Z aggressively (retail's network protocol
+is "client tells server position; server trusts within sanity bounds"),
+otherwise **Path B**. Path C is overengineered for the level of
+divergence.
+
+**Step 1 of the brainstorm:** quantify the divergence. Run WB's formula
++ retail's formula across all (lbX, lbY, cellX, cellY) tuples for
+several representative landblocks (Holtburg, Foundry, open landscape,
+some sloped terrain like Direlands). Record disagreement rate. If <5%
+of cells disagree, Path A's risk is bounded; if >20%, Path B becomes
+more attractive.
+
+---
+
+## Where N.5 left things
+
+### Branch state
+
+After last session:
+- `main` is at `a64cd11` ("docs(roadmap): add A.5 β two-tier streaming")
+- N.5 SHIP at `27eaf4e` (merge commit)
+- N.5 ship-amendment at `e0dbc9c` (legacy renderers retired)
+- Legacy `InstancedMeshRenderer` + `StaticMeshRenderer` + `WbFoundationFlag`
+ ARE GONE. Bindless is mandatory; missing extensions throws
+ `NotSupportedException` at startup.
+
+### What works in N.5
+
+- **Entity rendering:** `WbDrawDispatcher` does ~12-15 GL calls per frame
+ for all visible entities regardless of scene complexity. Three SSBO
+ uploads (instance matrices @ binding=0, batch data @ binding=1,
+ indirect commands) + 2 `glMultiDrawElementsIndirect` calls (opaque +
+ transparent passes).
+- **Bindless texture infrastructure:** `BindlessSupport` wrapper +
+ `TextureCache` parallel `UploadRgba8AsLayer1Array` path + three
+ `Bindless*` `GetOrUpload` methods + two-phase `Dispose`. All textures
+ on the WB modern path are 1-layer `Texture2DArray` + `sampler2DArray`.
+- **mesh_modern.vert/.frag** preserves the full `SceneLighting` UBO
+ (8 lights + fog + lightning flash + per-channel clamp) β visual
+ identity to N.4 confirmed at user gates.
+- **Diagnostic:** CPU stopwatch + GL_TIME_ELAPSED queries logged via
+ `[WB-DIAG]` (GPU timing currently shows 0/0 β query polling needs
+ double-buffering, deferred to N.6).
+
+### What N.5b inherits
+
+These are levers N.5b will pull on:
+
+- **`BindlessSupport`** at `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`
+ β already wraps `ArbBindlessTexture`. Reusable for terrain textures.
+- **`DrawElementsIndirectCommand` struct** at `src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs`
+ β 20-byte layout, ready to populate per-landblock terrain commands.
+- **`BuildIndirectArrays` helper** at `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+ β pure CPU layout helper, currently scoped to entities; could
+ generalize for terrain.
+- **`TextureCache`** with parallel Texture2DArray bindless cache β
+ but terrain has its own `TerrainAtlas` (multi-layer texture array
+ for splat blending). N.5b decides whether to integrate or keep
+ separate.
+- **`SceneLightingUbo`** at binding=1 β terrain.frag already consumes
+ it; the new modern terrain shader continues that.
+- **Retail's `FSplitNESW`** in `src/AcDream.Core/World/TerrainBlending.cs`
+ β the formula to preserve (or replace, per Path A/B/C decision).
+
+### What still uses the legacy path (NOT N.5b's job)
+
+- **Sky rendering** (`SkyRenderer.cs`) β N.8 territory.
+- **Particles** (`ParticleRenderer.cs`) β N.8 territory.
+- **Debug lines** (`DebugLineRenderer.cs`) β fine as-is.
+- **UI / text** (`TextRenderer.cs` + ImGui) β fine as-is; ImGui has its
+ own backend.
+
+---
+
+## What N.5b is β technical detail
+
+### Today's terrain stack (1383 lines acdream + ~140 lines shaders)
+
+| File | Lines | Role |
+|---|---|---|
+| `src/AcDream.App/Rendering/TerrainRenderer.cs` | 247 | Top-level orchestration; per-landblock cull + draw |
+| `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` | 454 | Per-landblock VAO + IBO management; `glDrawElements` per visible chunk |
+| `src/AcDream.App/Rendering/TerrainAtlas.cs` | 386 | Multi-layer `Texture2DArray` atlas for terrain splat textures |
+| `src/AcDream.App/Rendering/Shaders/terrain.vert` | 147 | Per-vertex world position, normal, UV, palCode |
+| `src/AcDream.App/Rendering/Shaders/terrain.frag` | 149 | Splat blending across 4 corner textures |
+
+**Per-frame today:** for each visible landblock, bind its VAO + IBO,
+bind the terrain texture atlas, set per-landblock uniforms, issue
+`glDrawElements`. With 25 landblocks at default radius=2, that's ~25
+draw calls per frame for terrain (cheap, but doesn't scale).
+
+### WB's terrain stack (1937 lines + ~200 lines shaders)
+
+| File | Lines | Role |
+|---|---|---|
+| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs` | 1023 | Top-level coordinator; uses multi-draw-indirect already |
+| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs` | 326 | Mesh generation per landblock (uses WB's split formula β see #51) |
+| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs` | 588 | Texture atlas management + alpha mask generation for splat blending |
+| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.frag` | ~200 | Modern shader; consumes SSBO instance data + bindless atlas handle |
+
+WB's renderer is structurally close to what N.5b targets. Key differences
+from acdream:
+
+- WB uses **uint32 indices** (`DrawElementsType.UnsignedInt`) for
+ terrain β landblocks have more vertices than fit in ushort range.
+ N.5's `WbDrawDispatcher` uses `UnsignedShort` for entities.
+- WB packs all visible terrain into shared mesh buffers + dispatches
+ via `glMultiDrawElementsIndirect`. We can mirror that pattern.
+- WB's `LandSurfaceManager` builds per-landblock alpha masks for splat
+ blending; this is the bulk of its 588 lines. Different model from
+ our `TerrainAtlas` which uses palCode-based blending in the fragment
+ shader.
+
+### What N.5b actually does
+
+Roughly four sub-pieces:
+
+1. **Terrain mesh on global VBO/IBO.** Following N.5's pattern, all
+ visible terrain landblocks pack into a single global vertex buffer
+ + index buffer. Per-landblock entries become `DrawElementsIndirectCommand`
+ records with `firstIndex` + `baseVertex` offsets. One
+ `glMultiDrawElementsIndirect` call per pass.
+2. **Bindless terrain atlas.** Either (a) port `TerrainAtlas` to use
+ bindless handles + sampler2DArray (small change, keeps current
+ blending math), or (b) adopt WB's `LandSurfaceManager` (bigger
+ change, switches to alpha-mask blending). Brainstorm decides.
+3. **New shader `terrain_modern.vert/.frag`** that:
+ - Reads per-landblock data from an SSBO (analogous to
+ mesh_modern's `Batches[]`)
+ - Samples the terrain atlas via bindless `sampler2DArray` handle
+ - Continues to consume `SceneLighting` UBO @ binding=1 (no
+ visual identity regression vs N.4 β same lighting math)
+4. **Resolve issue #51** per Path A/B/C decision in the brainstorm.
+
+### Per-frame target shape
+
+```
+// Once at init:
+Build global terrain VAO + VBO + IBO (resizable; grows as landblocks stream in)
+Generate bindless handles for terrain atlas
+
+// Per frame:
+1. Frustum cull landblocks (existing per-landblock AABB test)
+2. Build per-visible-landblock IndirectGroupInput list
+3. Upload _terrainBatchSsbo + _terrainIndirectBuffer
+4. glBindVertexArray(globalTerrainVao)
+5. glBindBufferBase(SHADER_STORAGE_BUFFER, 1, _terrainBatchSsbo)
+6. glBindBuffer(DRAW_INDIRECT_BUFFER, _terrainIndirectBuffer)
+7. glMultiDrawElementsIndirect(...) // ONCE per pass β opaque pass
+ (terrain has no transparent; one indirect call total)
+```
+
+Total ~6-8 GL calls per frame for terrain regardless of scene size.
+At radius=5 (121 landblocks) this is the same number of GL calls as
+at radius=2 (25 landblocks).
+
+---
+
+## Files to read before brainstorming
+
+In rough order:
+
+1. **`docs/ISSUES.md` issue #51** (49-103). Load-bearing constraint.
+2. **`CLAUDE.md`** the "Reference hierarchy by domain" terrain row +
+ "Reference repos: check ALL FOUR" β terrain math is one of the
+ places where checking multiple references matters most.
+3. **acdream terrain stack:**
+ - `src/AcDream.App/Rendering/TerrainRenderer.cs` (247 lines, easy
+ read)
+ - `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` (454 lines β
+ this is the per-landblock GL plumbing that goes away in N.5b)
+ - `src/AcDream.App/Rendering/TerrainAtlas.cs` (386 lines β
+ multi-layer atlas)
+ - `src/AcDream.App/Rendering/Shaders/terrain.vert/.frag` (~300
+ lines combined)
+ - `src/AcDream.Core/World/TerrainBlending.cs` (the FSplitNESW
+ side; preserve or replace)
+4. **WB terrain stack:**
+ - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs`
+ (1023 lines β the model to mirror; multi-draw indirect already
+ in place)
+ - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs`
+ (326 lines β uses WB's split formula; per #51)
+ - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs`
+ (588 lines β alpha-mask atlas; alternative to our `TerrainAtlas`)
+ - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.frag`
+ - `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44`
+ (CalculateSplitDirection β WB's formula)
+5. **N.5 plan + spec** (cribs for the modern-path pattern):
+ - `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`
+ (what we did, including amendments)
+ - `docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`
+ (decisions log)
+6. **Memory: `project_phase_n5_state.md`** β three high-value gotchas
+ from N.5 (texture target lock-in, bindless Dispose order,
+ GL_TIME_ELAPSED double-buffering). All apply to N.5b.
+
+---
+
+## Brainstorm questions
+
+These are the questions to resolve in the brainstorm step. Don't
+prejudge β bring them to the user with options + recommendation:
+
+1. **Path A vs B vs C** for issue #51 (the terrain split formula). The
+ biggest decision; everything else flows from it. Should be
+ informed by quantifying the divergence rate first (run both
+ formulas across representative landblocks).
+
+2. **Atlas model.** Keep `TerrainAtlas` (palCode-based fragment shader
+ blending) and just bindless-ify it, or adopt WB's `LandSurfaceManager`
+ (alpha-mask blending)? Tradeoff: minimal change vs alignment with
+ WB. Visual outcome should be identical either way.
+
+3. **Mesh ownership.** Use a single global VBO/IBO for all terrain
+ (mirror N.5's pattern), or per-landblock VBO/IBO with multi-draw
+ indirect over them? Single global is more cache-friendly + more
+ like N.5, but requires resizable buffer management. Per-landblock
+ is simpler but doesn't share the IBO across draws.
+
+4. **Index format.** N.5 uses `UnsignedShort` (max 64K verts per
+ draw). Terrain landblocks have many more verts than that. WB uses
+ `UnsignedInt`. Just commit to `UnsignedInt` for terrain?
+
+5. **Shader unification.** Separate `terrain_modern.vert/.frag` or
+ merge with `mesh_modern.vert/.frag` via uniforms? Probably separate
+ since the vertex layouts differ (terrain has palCode; entities
+ have UV).
+
+6. **Streaming integration.** Today's `TerrainChunkRenderer` integrates
+ with the streaming loader (landblocks come and go). N.5b's global
+ buffer model needs a strategy for adding/removing landblocks from
+ the global VBO/IBO without per-add reallocation. Free-list /
+ compaction / fixed-slot allocator?
+
+7. **Conformance test.** Per the lessons from N.2, "WB's terrain
+ formula differs from retail" β we need a test that proves our
+ visual terrain matches our physics terrain (i.e., visual mesh Z
+ at any (X,Y) equals `TerrainSurface.GetHeight(X,Y)`). Run a sweep
+ across ~1M (X,Y) points; assert |delta| < epsilon.
+
+8. **Visual verification gate.** Holtburg + Foundry + sloped terrain
+ (Direlands?) + cell transitions. The split-formula-disagreement
+ bug class shows up as terrain "wobble" at cell boundaries β that's
+ the specific thing to look for.
+
+---
+
+## Acceptance criteria for the whole phase
+
+- Visual terrain identical to current legacy path (no missing chunks,
+ no z-fighting at cell boundaries, no texture seams)
+- `[WB-DIAG]` shows terrain accounting for ~6-8 GL calls per frame
+ regardless of scene size (currently scales with visible landblock
+ count, ~25-121 calls)
+- Frame time measurably lower in dense-terrain scenes (specify scenes
+ in the spec β probably radius=5 outdoor roaming)
+- Conformance test: visual mesh Z agrees with `TerrainSurface.GetHeight`
+ within epsilon across a 1M-point sweep
+- All existing tests still green
+- The split-formula decision (#51) is resolved with a clear writeup
+ in the spec
+
+---
+
+## What you'll be doing in the first 30 minutes
+
+1. Read this handoff in full.
+2. Read `docs/ISSUES.md` issue #51 in full.
+3. Read CLAUDE.md "Reference hierarchy by domain" terrain row.
+4. Read `TerrainRenderer.cs` + `TerrainChunkRenderer.cs` end-to-end.
+5. Skim `TerrainRenderManager.cs` (WB's) β at least the multi-draw
+ indirect dispatch section.
+6. Verify build is green: `dotnet build`.
+7. Verify N.5 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"` should produce 71 passing tests, 0 failures.
+8. Quantify the formula divergence (Path A/B/C decision input):
+ write a one-shot test that runs both formulas across all
+ (lbX, lbY, cellX, cellY) tuples for ~10 representative landblocks
+ and reports disagreement rate.
+9. Invoke the `superpowers:brainstorming` skill with the user. Walk
+ through the 8 brainstorm questions above. Bring the formula
+ divergence number to inform the Path A/B/C decision.
+10. Write the spec.
+11. Write the plan.
+12. Begin Week 1 implementation per the plan.
+
+Don't skip the brainstorm. The terrain split formula decision (Path
+A/B/C) has real downstream consequences β physics, server-Z agreement,
+fork-patching of WB. Needs explicit user input, not "the agent makes
+a call and goes." This phase is structurally the same shape as N.5 β
+brainstorm β spec β plan β tasks-with-checkboxes β commits-update-checkboxes
+β final SHIP commit.
+
+---
+
+## Things to NOT do
+
+- **Don't adopt WB's terrain code wholesale without resolving #51
+ first.** The split formula decision affects the entire pipeline;
+ patching it after-the-fact requires re-doing visual + physics + the
+ TerrainGeometryGenerator port.
+- **Don't introduce a per-cell wobble at landblock boundaries.** That's
+ the visible signature of the formula disagreement. If you see it
+ during visual verification, the formula isn't aligned between your
+ physics and visual paths.
+- **Don't break the existing `[WB-DIAG]` instrumentation.** Add a
+ separate counter for terrain (`terrainDrawsIssued`) so the entity
+ + terrain perf can be observed independently.
+- **Don't bundle A.5 (two-tier streaming + horizon LOD) into this
+ phase.** N.5b is "terrain on modern path"; A.5 is "split the radius
+ + LOD." Different scopes, different brainstorms. A.5 might become
+ natural to pick up next once N.5b lands.
+- **Don't try to re-port `FSplitNESW` if you're going Path A.** The
+ whole point of Path A is to commit to WB's formula. If you keep
+ retail's formula via Path B/C, do it once, definitively.
+- **Don't skip the formula-divergence quantification.** Step 8 of
+ the first 30 minutes. The Path decision should be data-informed,
+ not gut-feel. <5% divergence makes Path A bounded-risk; >20% makes
+ Path B/C more attractive.
+- **Don't skip visual verification.** The split-formula bug class
+ shows up as cell-boundary wobble that's hard to spot in screenshots
+ but obvious in motion. Walk a sloped landblock during verification.
+- **Don't extend the phase scope.** N.5b is "terrain on modern path."
+ Sky, particles, EnvCells β all subsequent phases. If the brainstorm
+ tries to expand, push back.
+
+---
+
+## Reference: the N.5 dispatcher flow you're mirroring
+
+```
+WbDrawDispatcher.Draw(...) {
+ // Phase 1: walk entities, build groups
+ // Phase 2: lay matrices contiguously
+ // Phase 3: build BatchData + DEIC arrays via BuildIndirectArrays
+ // Phase 4: upload 3 SSBOs (instances, batches, indirect)
+ // Phase 5: bind global VAO + SSBOs
+ // Phase 6: opaque pass β glMultiDrawElementsIndirect
+ // Phase 7: transparent pass β glMultiDrawElementsIndirect
+}
+```
+
+For terrain the shape is similar but simpler:
+
+```
+TerrainModernDispatcher.Draw(...) {
+ // Phase 1: walk visible landblocks, frustum cull
+ // Phase 2: build per-landblock IndirectGroupInput list
+ // (one entry per visible landblock β typically 25-121)
+ // Phase 3: upload 2 SSBOs (terrain batch data, indirect commands)
+ // (no per-instance buffer needed β terrain isn't instanced)
+ // Phase 4: bind global terrain VAO + SSBOs
+ // Phase 5: opaque pass ONLY β glMultiDrawElementsIndirect
+}
+```
+
+Total ~6-8 GL calls per frame for terrain. That's the destination.
+
+Good luck. The split-formula decision is the only really hard call;
+everything else is mechanical port work on top of N.5's substrate.
+Holler at the user if anything in #51's three paths feels genuinely
+ambiguous after reading the references.
diff --git a/docs/research/2026-05-10-holtburger-network-stack-study.md b/docs/research/2026-05-10-holtburger-network-stack-study.md
new file mode 100644
index 0000000..ee05549
--- /dev/null
+++ b/docs/research/2026-05-10-holtburger-network-stack-study.md
@@ -0,0 +1,177 @@
+# Holtburger network stack β study & port candidates for acdream
+
+**Date:** 2026-05-10
+**Holtburger reference:** github.com/merklejerk/holtburger, vendored at `references/holtburger/`, fast-forwarded from `88b19bd` β `629695a` (237 commits, ~3 months of work).
+**Method:** Four parallel research agents β three over holtburger's transport, handshake, and movement; one inventorying acdream's current `src/AcDream.Core.Net/`. Findings cross-referenced and ranked by ROI.
+
+## TL;DR
+
+Holtburger has shipped real, citeable fixes since our last pin that we should adopt. The biggest tactical wins are:
+
+1. **A handful of one-line MoveToState fixes** that are likely candidates for the "remote retail observer sees acdream's player not perfect" issue (#L.X).
+2. **Three small handshake/transport corrections** β LoginComplete-on-teleport, EchoResponse reply, port-switch race β each <1 hour and each measurable.
+3. **A real retransmit subsystem we're missing entirely.** Our `WorldSession` parses retransmit requests, doesn't honor them, has no resend buffer, and never asks for a resend. Lost packets just vanish. Holtburger's `session/reliability.rs` is the reference-quality pattern.
+
+Separately, the audit surfaced one painful finding about acdream itself: **roughly half of our outbound `Messages/` library is dead code** β InteractRequests, InventoryActions, SocialActions, AllegianceRequests, CastSpellRequest, AppraiseRequest, and most of CharacterActions are built and unit-tested but have no `WorldSession.Send*` wrapper and no live caller. Phase B.4 (Use/UseWithTarget) per memory shipped, but the audit found no in-app caller. Either we left wiring on the table or there's an integration drift to investigate.
+
+The remainder of this doc is organized as: ranked port candidates β confirmations of what we got right β traps (where holtburger is wrong or stubbed) β recent commits worth knowing β recommended sequencing β cross-reference file map.
+
+---
+
+## 1. Ranked port candidates (highest ROI first)
+
+### 1.1 Outbound MoveToState audit β concrete suspects for the "observer not perfect" bug
+
+Five specific items where holtburger's wire format is likely tighter than ours. Each is a small change in our `Messages/MoveToState.cs` builder; together they're the most likely cause of remote retail observers reporting our player "lagging forward" or "walking when running."
+
+| # | Suspect | Holtburger reference |
+|---|---------|----------------------|
+| a | **`current_hold_key` always set on non-stop MoveToState.** Holtburger's drive emit seeds `flags = CURRENT_HOLD_KEY` and writes `current_hold_key = HoldKey::Run`(2) for run, `HoldKey::None`(1) for walk. ACE's relay code may treat its absence as "unknown" and broadcast Walk to observers. | `crates/holtburger-core/src/client/movement/common.rs:151-153` |
+| b | **`commands[]` array MUST be empty on held WASD.** Holtburger never puts a `MotionItem` in `commands[]` for held movement β only for transient slash commands like `/dance`. If acdream is putting one in for held W (or letting movement_sequence bump per-frame), every observer's `apply_self_update_motion` re-applies the same sequence as a fresh interpolation start β exactly the symptom. | `system.rs:743-766` (`execute_transient_motion_at`) |
+| c | **`turn_speed` always emitted alongside `TURN_COMMAND`.** Holtburger writes 1.5 rad/s for Run, 1.0 rad/s for Walk; the `TURN_SPEED` flag is *always* set whenever `TURN_COMMAND` is. Omitting it lets ACE default to 0 β "smoothly but slowly" turn observed. | `common.rs:184-186, 226-231` |
+| d | **Dedup gate must include gait.** Holtburger's `should_send_motion_state_pulse` compares the full `(MotionState, MotionStyle)`. If acdream's dedup is keyed on only `(forward_command, hold_key)` it would suppress the RunβWalk transition (since `forward_command = WalkForward = 0x45000005` for both), explaining the RunβWalk observer bug specifically. | `system.rs:916-926` |
+| e | **Don't emit `turning` field when locomotion is non-zero.** Recent fix in commit `336cbad`: `autonomous_wire_motion_state` no longer emits `turning` when locomotion β 0 (avoids server-side double-correction where it interpolates turn AND locomotes). | `crates/holtburger-core/src/client/movement/common.rs` |
+
+**Recommended action:** a side-by-side audit of [WorldSession.cs:6067-6089](src/AcDream.Core.Net/WorldSession.cs:6067) (MoveToState builder) and [Messages/MoveToState.cs](src/AcDream.Core.Net/Messages/MoveToState.cs) against holtburger `common.rs:122-186` and `system.rs:710-1000`. File whichever items don't already match as `#L.X.a-e` issues.
+
+### 1.2 LoginComplete on every PlayerTeleport, not just first PlayerCreate
+
+Holtburger sends `GameAction::LoginComplete` (0x00A1) **both** on first `PlayerCreate` (0xF746) AND on every `PlayerTeleport` (0xF74A) β no de-dup, server tolerates multiples. acdream sends it only on first PlayerCreate. Likely explains some portal-transition glitches.
+
+References: holtburger `messages.rs:433-467` (PlayerCreate), `messages.rs:480-487` (PlayerTeleport). acdream sends only at [WorldSession.cs:648](src/AcDream.Core.Net/WorldSession.cs:648).
+
+**Cost:** ~5 lines.
+
+### 1.3 EchoRequest β EchoResponse reply
+
+We parse `EchoRequest` from the optional header but never reply. ACE pings periodically; the missing response is a likely contributor to Network Timeout drops in long sessions. Holtburger handles it inline in the recv-message dispatcher.
+
+Reference: holtburger `crates/holtburger-session/src/session/receive.rs::finalize_ordered_server_packet` and the optional-header iterator at `crates/holtburger-session/src/optional_header.rs:59-141`.
+
+**Cost:** ~30 lines (parse the EchoRequest payload, build EchoResponse with mirrored time, send as control packet).
+
+### 1.4 Port-switch race fix (commit `403bc98`)
+
+On `ConnectRequest`, our `WorldSession` eagerly sets `_connectEndpoint = port+1`. Holtburger's recent fix introduces `pending_server_source_addr`: the new port is staged but `server_source_addr` is only updated when an actual packet arrives from the new port. ACE deployments occasionally send one more packet from `port` after the activation, and our code drops them.
+
+References: holtburger `session/auth.rs:42-47` (stage), `session/receive.rs:17-51` (confirm on first packet from new port).
+
+**Cost:** ~20 lines, one new field on `WorldSession`.
+
+### 1.5 Non-blocking 200 ms handshake delay
+
+We use `Thread.Sleep(200)` between receiving ConnectRequest and sending ConnectResponse on `port+1`. Holtburger queues ConnectResponse with `ready_at = Instant::now() + 200ms` and lets the recv loop keep draining during the gap (handles any inbound TimeSync that arrives in the window).
+
+Reference: holtburger `session/auth.rs:42-66`, queued via `pending_control_packets` flushed by the recv loop. (Their old form, deleted in `99974cc`, used `tokio::time::sleep` and matched our blocking pattern.)
+
+**Cost:** ~40 lines (small "deferred control packet" queue + flush check).
+
+### 1.6 AutonomousPosition cadence audit
+
+We have **three policies** in play, and at least two are wrong:
+
+- **acdream:** fixed 200 ms heartbeat (per `memory/project_retail_motion_outbound`)
+- **holtburger:** fixed 1 s heartbeat, unconditional regardless of motion (`common.rs:22`, `system.rs:858-893`)
+- **cdb retail trace (memory):** AutoPos appears gated on actual motion
+
+Most likely retail wins (cdb is observing real client behavior). If retail truly suppresses AutoPos when stationary, our 5Γ over-emission triggers ACE-side over-validation and may contribute to the observer-side jitter. **Recommended:** another cdb idle trace to confirm retail's exact behavior, then converge to it.
+
+### 1.7 Retransmit machinery (entire subsystem)
+
+Largest delta from holtburger. We are missing:
+
+- **A retransmit cache.** Holtburger's `MAX_CACHED_PACKETS=512`, LRU-style, drops oldest when full (`reliability.rs:32-37`).
+- **Server-requested retransmits.** When the server asks for resends, holtburger re-encrypts with current ISAAC + RETRANSMISSION flag and replays from cache (`reliability.rs:135-186`).
+- **Client-issued retransmit requests.** When inbound seq has gaps, holtburger sends `RequestRetransmit` for up to 115 seqs in a 256-seq window, rate-limited to once per second (`MAX_RETRANSMIT_SEQUENCE_IDS=115`, `MAX_RETRANSMIT_SEQUENCE_WINDOW=256`, `REQUEST_RETRANSMIT_INTERVAL=1s`).
+- **`Iteration` field handling.** Our `PacketHeader.Iteration` is always 0; holtburger increments on retransmit.
+- **`ISAAC::search` for out-of-order ENCRYPTED_CHECKSUM packets.** Out-of-order packets have ISAAC keys that have already advanced. Holtburger scans forward up to 256 keys, stashing each skipped key in `xors: HashSet` for later out-of-order packets to consume via `consume_key_value` (`crypto.rs:73-93`). **A naive port either drops the out-of-order packet or corrupts the ISAAC stream.** If our IsaacRandom doesn't have a search-and-stash mode, this is a latent bug waiting for any UDP loss event.
+
+Our `WorldSession` class doc explicitly defers this work (`WorldSession.cs:29` "ACK pump, retransmit handling β¦ deferred"). Symptoms when it's missing: any packet loss β silent state divergence, eventual desync, "purple haze" / Network Timeout drops.
+
+**Cost:** 1-2 days. The whole pattern is in holtburger's `reliability.rs` (196 lines) plus the ISAAC search-mode in `crypto.rs:73-93`.
+
+### 1.8 Fragment assembler TTL + outbound multi-fragment split
+
+Two smaller correctness gaps:
+
+- **Inbound:** Our `FragmentAssembler` has no TTL. If a multi-fragment server message loses its middle fragment, the partials sit forever. Memory leak in any long session that sees UDP loss. Holtburger's reassembler tracks completion per `(sequence, id)` and lives inside `process_fragment` in `send.rs`.
+- **Outbound:** Our `GameMessageFragment.BuildSingleFragment` throws on body > 448 bytes. Anything that needs splitting (long /tells, big inventory queries, large appraisals) silently can't be sent. Note: **holtburger doesn't do outbound fragmentation either** (`send_message` always emits `count: 1`, `send.rs:298`) β they're betting on UDP-level fragmentation. So this isn't a holtburger crib; it's a hole in both. AC2D + Chorizite are the better references when we get there.
+
+---
+
+## 2. Confirmations β we're doing it right
+
+Three places where the audit confirmed our existing approach matches the reference:
+
+- **Run/walk encoding via WalkForward + HoldKey.Run/None.** Holtburger sends `forward_command = 0x45000005 (WalkForward)` for **both** walk and run; the distinction is in `forward_hold_key` (Run=2 vs None=1) and `forward_speed`. ACE upgrades server-side. Test pinning this contract: `holtburger system/tests.rs:404-428`.
+- **Two-step EnterWorld** (`0xF7C8 CharacterEnterWorldRequest` β wait for `0xF7DF ServerReady` β `0xF657 CharacterEnterWorld`).
+- **ACK on every received packet with seq > 0.** Holtburger's `recv_packet_with_addr` queues an ack for every received packet with `sequence > 0 && flags != ACK_SEQUENCE`. Outbound `send_message` auto-piggybacks the latest server seq onto the next data packet; standalone ACKs flush only when nothing naturally goes out. (Worth double-checking that our `SendAck` is called automatically on `ProcessDatagram`, not as a separate periodic pump.)
+
+One thing **worth re-verifying** because it's easy to invert: ISAAC seeding direction. Holtburger uses `isaac_c2s = Isaac::new(crd.client_seed)` and `isaac_s2c = Isaac::new(crd.server_seed)` β i.e. the wire field labelled `client_seed` seeds the C2S keystream, and vice versa. Worth a 30-second check that our `WorldSession` does the same.
+
+---
+
+## 3. Don't crib these (holtburger gaps / wrong)
+
+- **Outbound fragmentation:** holtburger doesn't do it. Hole in both projects. Use AC2D + Chorizite when needed.
+- **Jump (0xF61B):** holtburger never sends Jump. The TUI client can't jump. `JumpActionData` is decoder-only. Use cdb retail trace + Chorizite.ACProtocol for jump format reference.
+- **Initial run_rate_scalar fallback:** holtburger uses 4.5 (max-cap formula, run_skill β₯ 800); acdream uses 2.4-2.94 default. Retail formula: `(load_mod * (run_skill / (run_skill + 200) * 11) + 4) / 4`. The right pre-PlayerDescription default depends on what retail does β cdb trace will settle it.
+- **AutoPos cadence:** holtburger's 1-second unconditional heartbeat is probably wrong (cdb retail trace says gated on motion). Don't copy this verbatim; investigate first.
+
+---
+
+## 4. Recent commits worth knowing (last 237)
+
+| Commit | Date | Intent | Relevance |
+|--------|------|--------|-----------|
+| `99974cc` | 2026-04-06 | "Fix/session issues" β splits 673-line `lib.rs` into `session/{api,auth,receive,send,reliability,types}`. **Adds the missing CβS retransmit logic.** Replaces `tokio::sleep(200ms)` with deferred control-packet queue. | Read this diff if you read only one. |
+| `403bc98` | 2026-04-21 | "do not switch ports prematurely" (#158). Pending vs confirmed source-port. | Apply same pattern to `WorldSession`. |
+| `336cbad` | 2026-04-?? | "fix: more movement fixes". `autonomous_wire_motion_state` no longer emits `turning` when locomotion β 0. | Likely also a bug class in our outbound MoveToState. |
+| `797aece` | 2026-04-06 | DISCONNECT now carries `id = client_id` instead of 0. | One-line fix on our `Dispose` path. |
+| `854c1bb` | (older) | "Feat/simulation system" (#105) β added the entire 2222-LOC `client/movement/{common,system}.rs`. | Foundation everything else builds on. |
+
+Nothing in 237 commits changes LoginRequest payload, ConnectRequest parse, ISAAC seeding, or EnterWorld message ordering. The wire format is unchanged from what acdream targets β the deltas are internal architecture and bug fixes.
+
+---
+
+## 5. Recommended sequencing
+
+**Tier 1 β Quick wins (under an hour each, high signal-to-noise):**
+1. MoveToState audit fixes (1.1.a-e) β file as `#L.X.a-e`, batch into one PR
+2. LoginComplete on PlayerTeleport (1.2)
+3. EchoRequest β EchoResponse reply (1.3)
+4. Port-switch race fix (1.4)
+5. Non-blocking handshake delay (1.5)
+6. Disconnect carries client_id (`797aece` finding)
+
+**Tier 2 β Investigation, then fix:**
+7. AutoPos cadence β cdb idle trace, then converge (1.6)
+8. Audit "dead outbound builders" (Phase B.4 wiring drift) β separate from holtburger but surfaced by this study
+
+**Tier 3 β Bigger investment:**
+9. Retransmit subsystem (1.7) β port `reliability.rs` wholesale, including ISAAC search-mode (1-2 days)
+10. Fragment assembler TTL (1.8 inbound)
+
+The Tier 1 group is a cohesive "post-A.5 network polish" pass β cheap, high-confidence, and several of them are likely candidates for the longstanding observer-not-perfect issue.
+
+---
+
+## 6. File map for cross-reference
+
+| acdream | holtburger | Role |
+|---------|-----------|------|
+| `src/AcDream.Core.Net/WorldSession.cs:411-521` | `crates/holtburger-session/src/session/{api,auth}.rs` | Handshake driver |
+| `src/AcDream.Core.Net/WorldSession.cs:556-924` | `crates/holtburger-core/src/client/runtime.rs:91-200` + `messages.rs` | Recv loop + dispatch |
+| `src/AcDream.Core.Net/WorldSession.cs:1096-1156` | `crates/holtburger-session/src/session/send.rs` | Outbound transport (encode + ack piggyback) |
+| `src/AcDream.Core.Net/Cryptography/IsaacRandom.cs` | `crates/holtburger-protocol/src/crypto.rs` | ISAAC (we likely lack `search`-mode) |
+| `src/AcDream.Core.Net/Packets/PacketCodec.cs` | `session/{send,receive}.rs` + `optional_header.rs` | Encode/decode + optional header iteration |
+| `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` | `session/send.rs::process_fragment` | Inbound reassembly |
+| `src/AcDream.Core.Net/Messages/MoveToState.cs` | `crates/holtburger-protocol/src/messages/movement/actions.rs:53-69` + `client/movement/common.rs:122-186` | MoveToState builder |
+| `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` | `messages/movement/actions.rs:175-189` + `system.rs:858-893` | AutoPos builder + cadence |
+| **(missing)** | `crates/holtburger-session/src/session/reliability.rs` | **Retransmit machinery β entirely absent in acdream** |
+
+---
+
+## Method note
+
+This study used four parallel general-purpose agents on the day-of pull (2026-05-10, holtburger HEAD `629695a`). All citations are file paths + line numbers in that exact tree. If holtburger moves forward, line numbers will drift; commit hashes (especially `99974cc`, `403bc98`, `336cbad`, `797aece`) are stable anchors.
diff --git a/docs/research/2026-05-10-phase-a5-handoff.md b/docs/research/2026-05-10-phase-a5-handoff.md
new file mode 100644
index 0000000..ae70602
--- /dev/null
+++ b/docs/research/2026-05-10-phase-a5-handoff.md
@@ -0,0 +1,376 @@
+# Phase A.5 β Two-tier Streaming + Horizon LOD β Cold-Start Handoff
+
+**Created:** 2026-05-10, immediately after N.5b ship.
+**Audience:** the next agent picking up streaming + horizon-LOD work.
+**Purpose:** brief you on where N.5b left things, what A.5 actually has to do
+to make the world look and feel great, and the load-bearing facts the
+brainstorm should be informed by.
+
+---
+
+## TL;DR
+
+N.5b just shipped: outdoor terrain rendering is on bindless + multi-draw
+indirect via `TerrainModernRenderer`. Constant-cost dispatch as the
+visible landblock count grows β radius=5 vs radius=15 are the same number
+of GL calls for terrain.
+
+**A.5's actual goal β verbatim from the user, 2026-05-09:**
+
+> "I just want great smooth HIGH fps visuals. Should look great. As long
+> as it scales and we get very high FPS"
+
+That reframes priorities. We are NOT optimizing the inner loop at radius=5
+(it's solved). We're scaling visual reach + scene density without the
+client falling off a perf cliff.
+
+**Concretely, A.5 ships three things:**
+
+1. **Two-tier streaming.** Near tier (β€ Nβ landblocks) loads everything as
+ today (terrain + scenery + EnvCells + collision). Far tier (Nβ < r β€ Nβ)
+ loads terrain mesh ONLY. No scenery generation, no collision, no
+ entity registration for the far tier.
+2. **Per-LB entity bucketing for the WB dispatcher.** Today the entity
+ dispatcher walks every loaded entity each frame for AABB cull β
+ ~16K entities @ ~1Β΅s/test = 4.3ms/frame, dominating the frame budget.
+ Bucket entities by landblock so the cull is hierarchical: cull the LB
+ first, then only walk entities inside surviving LBs.
+3. **Off-thread mesh build.** `LandblockMesh.Build` currently runs on the
+ render thread when a new LB streams in. At today's radius=5 this is
+ invisible; at A.5's higher Nβ it becomes a visible frame-time spike
+ when 4-5 LBs stream simultaneously. Move the build to a worker pool;
+ hand finished `LandblockMeshData` back via a queue.
+
+The headline win you're shooting for: **radius=15 sustains the user's
+target FPS in Holtburg with no streaming hitches.**
+
+---
+
+## Where N.5b left things
+
+### Branch state (relative to main)
+
+After N.5b ships:
+- N.5b SHIP at `08b7362` (final commit; appended SHIP record to plan)
+- Roadmap entry, issue #51 closure, perf baseline doc all in place at `083c10c`
+- Legacy `TerrainChunkRenderer` + `TerrainRenderer` + `terrain.vert/.frag`
+ deleted at `7dfa2af`. **The modern path is the only path.**
+
+### Captured perf baseline (load-bearing for A.5's "what's actually hot")
+
+From `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`, measured
+2026-05-09 at Holtburg town dueling field, radius=5, ~30s standstill:
+
+| Subsystem | cpu_us median per frame | Notes |
+|---|---|---|
+| **Entity dispatcher** (`WbDrawDispatcher`) | **~4,300** | 86% of frame budget. ~16K entities walked for AABB cull. THIS is the bottleneck. |
+| Terrain dispatcher (`TerrainModernRenderer`) | ~6.4 | <1% of frame. Constant-cost regardless of radius (proved in N.5b). |
+| Everything else (sky, particles, ImGui, swap, audio) | ~700 | Small. |
+
+**Actual FPS at radius=5 in Holtburg: ~200 fps** (frame time β 5ms).
+NOT the "810 fps" inferred from the N.5 ship doc (that was 1/dispatcher_ms,
+which is only the WB dispatcher CPU cost in isolation, not real frame time).
+
+### What naive radius increase does
+
+If you simply raised `ACDREAM_STREAM_RADIUS` to 15 today without A.5:
+
+- Loaded landblocks: 121 β ~961 (8Γ more). Acceptable.
+- Loaded entities: ~16K β ~125K (linear scaling with LB count). **NOT
+ acceptable.** At ~1Β΅s per AABB cull, the entity dispatcher would take
+ ~125ms/frame = 8 FPS. Slideshow.
+- Memory footprint: similar 8Γ explosion in scenery instance buffers.
+
+So the perf cliff is real and immediate. A.5 has to address it BEFORE
+the radius can be safely raised.
+
+### What N.5b set up that A.5 inherits
+
+- **Modern terrain dispatcher.** `TerrainModernRenderer` is O(1) GL calls
+ in radius. As you add far-tier LBs (terrain only), the terrain
+ dispatcher cost stays flat (~6Β΅s/frame). This is the one subsystem
+ that doesn't need any A.5 work β it just scales.
+- **Slot allocator for terrain GPU buffers.** Already grows by power-of-two
+ doubling. Will absorb radius=15 (~961 slots Γ ~15 KB each = ~14 MB)
+ without manual tuning.
+- **`[TERRAIN-DIAG]` instrumentation.** Reports per-frame median + p95 in
+ microseconds. Use this to confirm A.5 doesn't regress terrain perf.
+- **Conformance sentinel.** `TerrainModernConformanceTests` proves visual
+ mesh Z agrees with `TerrainSurface.SampleZFromHeightmap` to 0.015 mm.
+ Don't break this β physics β visual agreement must hold across both
+ tiers.
+- **Bindless atlas.** `TerrainAtlas.GetBindlessHandles()`. The far tier
+ shares the atlas (it's region-wide). Zero atlas-related per-LB cost.
+
+---
+
+## The brainstorm questions (the hard calls A.5 has to make)
+
+These are the questions to resolve in the brainstorm step. Bring them to
+the user with options + recommendation; don't prejudge.
+
+### 1. Tier radii: what are Nβ and Nβ?
+
+- **Nβ** = near-tier radius (everything loads). Today's default `STREAM_RADIUS`.
+ Probably stays at 5 (or maybe 4; maybe 3).
+- **Nβ** = far-tier radius (terrain mesh only). Could be 8, 12, 15, 20.
+
+Tradeoffs: bigger Nβ = more world visible = looks better. But each far-tier
+LB still costs ~16 KB GPU memory + a frustum cull AABB + a slot allocation.
+At Nβ=15, that's ~961 LBs Γ 16 KB = ~15 MB GPU mem (cheap) + ~961 cull
+tests (cheap, ~1ms total at 1Β΅s each β and we'll do this per-LB cull
+anyway as part of #2 below).
+
+Verify against retail: cdb attach + check how many landblocks retail keeps
+loaded at a given vantage point. Probably around 10-12 per the AC2D
+references and the holtburger client's behavior.
+
+### 2. Far tier: terrain only? Or also impostor scenery?
+
+Two options:
+- **Terrain only** (cleanest). Beyond Nβ, no trees, no rocks. Skyline is the
+ terrain mesh against the sky.
+- **Impostor scenery** (more retail-like). Beyond Nβ, generate flat
+ billboards or low-poly trees instead of full meshes. Adds substantial
+ complexity (billboard pipeline, mesh-LOD generation, per-camera-angle
+ rotation).
+
+Recommendation: start with terrain-only. Add impostors only if the
+horizon looks wrong (too bare). Retail definitely has SOME distant
+scenery but the cutoff is gradual; we can match it later if needed.
+
+### 3. Entity bucketing structure
+
+Today: `WbDrawDispatcher` keeps a flat dictionary of all entities and
+walks all of them per frame. To bucket by LB, we need:
+
+- A `Dictionary>` keyed by landblock ID
+- On `AddEntity(...)`, also stash it in the LB bucket (the spawn flow
+ already knows the LB context)
+- On `RemoveEntity(...)`, remove from the LB bucket too
+- Per frame: cull at LB granularity first; then cull entities only inside
+ surviving LBs
+
+LB-level AABBs are already computed (per the existing `_visibleSlots`
+logic in `TerrainModernRenderer` β the same AABB applies to entities,
+modulo a Z-range bump for trees/buildings).
+
+Open question: do entities outside a known LB exist? (Items dropped on the
+ground? Ephemeral effects? Player projectiles?) If yes, they need a
+fallback "unknown LB" bucket that's still walked every frame. Probably
+small.
+
+### 4. Where does the off-thread mesh build land?
+
+Today `LandblockMesh.Build` runs synchronously inside `OnLandblockLoaded`
+on the render thread. To move it off:
+
+- `StreamingLoader` worker thread (already async for dat reads) signals
+ "LB X is ready"
+- A new worker pool consumes that signal, builds the mesh on a worker
+ thread, posts the finished `LandblockMeshData` to a `ConcurrentQueue`
+- Render thread drains the queue at the start of each frame, calling
+ `_terrain.AddLandblock(...)` for each ready mesh
+
+Gotcha: the `TerrainBlendingContext` is shared. Need to confirm it's
+read-only (it is β built once at startup). Also `_surfaceCache` β
+currently a plain `Dictionary` populated lazily by `TerrainBlending.BuildSurface`.
+Either lock it, replace with `ConcurrentDictionary`, or pre-populate with
+all known palCodes at startup.
+
+### 5. Streaming hysteresis at the tier boundary
+
+When the player crosses Nβ β near-tier shrinks, far-tier grows.
+LBs that were near-tier need to:
+- Drop their scenery (unregister entities)
+- Drop their EnvCells
+- Keep the terrain mesh (still in far tier)
+
+When the player crosses back: the LB needs scenery + EnvCells re-loaded.
+Hysteresis (don't churn at the exact boundary) is needed.
+
+The streaming loader already has hysteresis for full LB load/unload. A.5
+extends that: a separate hysteresis radius for the scenery/entity layer.
+
+### 6. Visual quality wins to ride along
+
+A.5 is the natural place to land 2-3 nearly-free quality wins:
+
+- **Mipmapped terrain atlas + anisotropic 16x.** Today the atlas is
+ `GL_LINEAR` no mipmaps; distant terrain shimmers. ~half-day fix.
+ Big visible improvement at far tier.
+- **Tree alpha-test β alpha-to-coverage with MSAA.** Today tree edges are
+ binary cutoff and pixel-edged. A2C with MSAA fixes them. ~one day.
+- **Correct depth-write for transparent foliage.** Some scenery passes
+ may be writing depth incorrectly; confirm + fix.
+
+These are not strictly required for A.5 to ship, but they amplify the
+"looks great" payoff.
+
+### 7. Acceptance metrics
+
+The user's goal is "smooth + high FPS + great-looking + scales." Pin
+this concretely:
+
+- Target FPS at radius (whatever final Nβ + Nβ): β₯ user's monitor refresh
+ (probably 144 or 240 Hz). Capture before/after numbers in a perf
+ baseline doc parallel to N.5b's.
+- No frame-time spikes > 5ms during streaming (record a 60-second
+ trace running through Holtburg β North Yanshi).
+- Visual horizon visible at the new Nβ. Capture screenshots from the
+ same vantage point at the start of A.5 (before) and at ship (after)
+ for the SHIP record.
+
+### 8. What's NOT in A.5
+
+A.5 does not need to ship:
+- GPU-side culling (compute-shader cull). Bigger lift; N.6 territory.
+- Persistent-mapped indirect buffer. N.6 territory.
+- Sky / particles / EnvCells migration. Separate N.7+ phases.
+- Shadow mapping. Separate visual phase.
+
+Don't let scope creep pull these in.
+
+---
+
+## Files to read before brainstorming
+
+In rough order of relevance:
+
+1. **`docs/research/2026-05-09-phase-n5b-handoff.md`** β N.5b's handoff
+ (read for context on what was just shipped + the structure of these
+ handoff docs).
+2. **`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`** β captured
+ perf numbers + the architectural reasoning for what A.5 inherits.
+3. **`memory/project_phase_n5b_state.md`** β three high-value gotchas
+ captured during N.5b (especially #1: bindless uniform-sampler driver
+ quirk; A.5 won't directly need this, but it's the prior art for any
+ new shader code in the phase).
+4. **`docs/plans/2026-04-11-roadmap.md`** A.5 entry β the original A.5
+ description.
+5. **The streaming loader** β `src/AcDream.Core/World/StreamingLoader.cs`
+ (or wherever it lives; grep for `OnLandblockLoaded`). Understand the
+ existing ring + hysteresis logic before extending it.
+6. **WB dispatcher entity flow** β
+ `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` lines covering
+ `Draw` (the per-entity walk) and `EntitySpawnAdapter` (where entities
+ get registered). The bucketing change lands here.
+7. **`LandblockMesh.Build`** β `src/AcDream.Core/Terrain/LandblockMesh.cs`.
+ Its inputs (heightmap, ctx, surfaceCache) determine what the worker
+ thread needs. ~150 lines.
+8. **WB's `SceneryRenderManager`** β
+ `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryRenderManager.cs`.
+ Has a render-distance cap; informs Nβ vs Nβ defaults.
+9. **`TerrainModernRenderer`** β
+ `src/AcDream.App/Rendering/TerrainModernRenderer.cs`. Don't modify;
+ confirm the slot allocator handles radius=15 cleanly.
+
+---
+
+## Acceptance criteria for the whole phase
+
+1. Build green; existing tests stay green; N.5b's conformance sentinel
+ still passes (visual mesh Z = TerrainSurface Z within 1mm).
+2. **Far-tier LBs render terrain visibly past Nβ** in user-driven visual
+ verification.
+3. **Per-frame entity-dispatcher cpu_us at radius=Nβ drops** vs today
+ (the bucketing should help even at the current radius).
+4. **Per-frame entity-dispatcher cpu_us at radius (Nβ+Nβ) is bounded**
+ β does NOT scale linearly with total loaded LBs. Specifically:
+ bucketed cull should be < 1.5Γ today's cost despite far-tier LBs
+ loading.
+5. **No streaming hitch > 5ms** when running at run-speed across Nβ/Nβ
+ tier boundaries simultaneously (capture a 60s trace).
+6. **`[TERRAIN-DIAG]` cpu_us stays flat** as Nβ grows β the terrain
+ dispatcher proven O(1) (regression check).
+7. Visual identity at near-tier (no scenery missing inside Nβ; no
+ z-fighting; no cell-boundary wobble β N.5b sentinel still applies).
+8. SHIP record + perf baseline + memory entry written, mirroring N.5b's
+ pattern.
+
+---
+
+## What you'll be doing in the first 30 minutes
+
+1. Read this handoff in full.
+2. Read `docs/research/2026-05-09-phase-n5b-handoff.md` for the structural
+ pattern.
+3. Read `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` for the captured
+ numbers A.5 inherits.
+4. Read `memory/project_phase_n5b_state.md` for gotchas.
+5. Verify build is green: `dotnet build`.
+6. Verify N.5b ship is intact: `dotnet test --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"` (target β₯114 passing, 0 failures).
+7. Capture a baseline radius=5 frame trace yourself (one launch, 30s
+ standstill at Holtburg dueling field) so you have a "before" number
+ in your own measurement environment, not just trusting N.5b's number.
+8. Invoke `superpowers:brainstorming` with the user. Walk through the
+ 8 brainstorm questions above. Present each with options + my
+ recommendation; don't prejudge.
+9. After agreement, write the spec; then the plan; then execute
+ task-by-task using `superpowers:subagent-driven-development`.
+
+Don't skip the brainstorm. The Nβ/Nβ values, the bucketing structure
+trade-offs, and the worker-thread design are real decisions with
+downstream consequences that need user input β not "the agent makes a
+call and goes."
+
+---
+
+## Things to NOT do
+
+- **Don't raise `ACDREAM_STREAM_RADIUS` without A.5's tiered loading
+ in place.** The entity-cull cliff is immediate and severe (8 FPS at
+ naive radius=15).
+- **Don't put scenery in the far tier just to "look more retail" without
+ a billboard/impostor pipeline.** Full-detail scenery in the far tier
+ is what causes the cull cliff.
+- **Don't move `LandblockMesh.Build` to a worker thread without first
+ auditing `TerrainBlendingContext` + `_surfaceCache` for thread
+ safety.** Concurrent writes to the surfaceCache will produce
+ silently-wrong terrain blending.
+- **Don't break the N.5b conformance sentinel.** If A.5 changes how
+ meshes are built (e.g., for the worker thread), the conformance
+ test must still pass β it's the load-bearing physics β visual Z
+ agreement guard.
+- **Don't bundle GPU-side culling, persistent-mapped buffers, or shadow
+ mapping into A.5.** Those are N.6+ territory; A.5 is "make the world
+ look big and not stutter."
+- **Don't ship without honest perf numbers.** If A.5 doesn't actually
+ hit its FPS target, document why and ship N.6 next instead of
+ papering over it. The N.5b precedent is honest reporting.
+- **Don't skip the visual verification gate.** Same lesson from N.5b's
+ black-terrain regression: "go" doesn't mean "verified." User must
+ actually launch the client at radius=Nβ and confirm the horizon
+ looks great + FPS hits target.
+
+---
+
+## Reference: where the FPS budget actually goes today
+
+For brainstorming purposes, the per-frame breakdown at radius=5 / Holtburg
+(real measurement, 2026-05-09):
+
+```
+~5,000 Β΅s total frame time (= 200 fps)
+βββ 4,300 Β΅s WbDrawDispatcher entity cull + dispatch β THE BOTTLENECK
+β ~16K entity AABB tests / frame
+β A.5's entity bucketing attacks this directly
+βββ 6 Β΅s TerrainModernRenderer
+β O(1) in radius. Won't grow with A.5. Already solved.
+βββ ~700 Β΅s Sky, particles, ImGui, audio, swap-buffers, misc
+β Mostly fixed cost; some VSync-related
+βββ rest GPU side (we don't measure this β query plumbing
+ deferred to N.6). Could be substantial.
+```
+
+The first action of A.5 is to recognize that the perf claim "810 fps"
+from N.5 was misleading. Don't repeat the mistake β measure the actual
+frame time, not just one subsystem.
+
+---
+
+Good luck. The phase is meaty (~2 weeks) but the structural work is
+well-shaped: tiered streaming has clear boundaries, entity bucketing is
+an isolated dispatcher change, off-thread mesh build is a well-understood
+worker pattern. The hard call is the Nβ/Nβ values, and that's a
+brainstorm question β bring it to the user with data.
diff --git a/docs/research/2026-05-10-phase-m-opcode-matrix.md b/docs/research/2026-05-10-phase-m-opcode-matrix.md
new file mode 100644
index 0000000..2e28b5c
--- /dev/null
+++ b/docs/research/2026-05-10-phase-m-opcode-matrix.md
@@ -0,0 +1,543 @@
+# Phase M β Network Opcode Coverage Matrix
+
+**Date:** 2026-05-10
+**Status:** Initial population complete (4 parallel research agents). Spot-check pass + intentional-divergence ratification owed before M.1 closes.
+**Companion spec:** [`docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md`](../superpowers/specs/2026-05-10-phase-m-network-stack-design.md)
+**Companion study:** [`docs/research/2026-05-10-holtburger-network-stack-study.md`](2026-05-10-holtburger-network-stack-study.md)
+
+This matrix is the **source of truth for Phase M completeness**. Every row defines: what the opcode is, who currently sends/receives it across our three reference sources, what acdream does today, and what Phase M must do. The spec's M.6 work plan reduces to "for every row where `acdream today` β `Phase M target`, implement the delta and add tests."
+
+---
+
+## Roll-up
+
+| Section | In-scope | Acdream today | Phase M delta |
+|---------|----------|---------------|---------------|
+| 1 β Transport flags | 22 | 14 parse / 5 build | 8 |
+| 2 β Optional-header fields | 12 | 10 partial | builder + decoder gaps |
+| 3 β GameMessage opcodes (top-level) | 51 | 21 implemented | 30 |
+| 4 β GameEvent sub-opcodes (inside 0xF7B0) | 103 | 27 parsed / 26 wired | 76 new (~50 deferable to gameplay phases) |
+| 5 β GameAction sub-opcodes (inside 0xF7B1) | 96 | 24 built / 8 live callers | 72 new + 16 dead-builder wirings |
+| **Total** | **~284** | **~96** | **~190** |
+
+Roughly **34% complete by raw opcode count.** The biggest single Phase-M unblocking step is wiring the 16 dead builders in section 5 (Phase B.4 surface β Use / UseWithTarget / Allegiance / Inventory / Social / Cast / Appraise / etc.).
+
+---
+
+## Cell-value vocabulary
+
+| Code | Meaning |
+|------|---------|
+| `P` | Parses inbound |
+| `B` | Builds outbound |
+| `PB` | Parses + builds (both directions) |
+| `W` | Wired β typed handler exists AND state is updated by it |
+| `H` | (ACE only) Server has a handler that processes this client-sent opcode |
+| `β` | Not implemented |
+| `N/A` | Not applicable for this side (e.g., server-only message in ACE column) |
+| `?` | Could not determine β needs verification |
+
+**Phase M target column:**
+
+| Target | Meaning |
+|--------|---------|
+| `PB+W` | Must parse, build (if outbound), wire to typed event by phase end |
+| `PB` | Must parse + build, no wiring required |
+| `P+W` | Inbound only, must parse + dispatch typed event |
+| `B+W` | Outbound only, must build + have a live caller |
+| `B` | Build only, no live caller required (typed for future use) |
+| `βdefer:` | Explicitly deferred to a named gameplay phase |
+| `βskip:` | Out of scope, with justification |
+
+---
+
+## Section 1 β Transport flags
+
+In-scope: 22. Implemented in acdream: 14 (parse path + 5 build path). Phase M target delta: 8 (4 inbound parse gaps to wire, 4 outbound builders, plus 6 to retire/skip-justify).
+
+| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
+|---|---|---|---|---|---|---|---|---|
+| `0x00000000` | N/A | None | β | N/A | N/A | N/A | N/A | Identity flag value [^t-a] |
+| `0x00000001` | both | Retransmission | β | P (set on retx) | PB+W | β | PB+W | We never echo/honor this bit [^t-b] |
+| `0x00000002` | both | EncryptedChecksum | `FlowQueue::EncryptChecksum` | PB | PB+W | PB+W | PB+W | Codec covers in/out + ISAAC |
+| `0x00000004` | both | BlobFragments | `MessageFragment` group | PB+W | PB+W | PB+W | PB+W | Fragment list parsed/built |
+| `0x00000100` | inbound | ServerSwitch | `ClientNet::HandleServerSwitch` | P (size-skip) | PB | P (size-skip) | P+W | Handler missing; just consumes 8 bytes |
+| `0x00000200` | inbound | LogonServerAddr | β | β | β | β | βdefer:M2 | Login-server bounce; no client logic yet [^t-c] |
+| `0x00000400` | inbound | EmptyHeader1 | `CEmptyHeader<0x400,2>` | β | β | β | βskip:dead-flag | Retail struct exists, never sent |
+| `0x00000800` | inbound | Referral | `ClientNet::HandleReferral` | β | B only (server) | β | βdefer:M2 | Server-only path until login bounce |
+| `0x00001000` | both | RequestRetransmit | `FlowQueue::TransmitNaks` | PB+W | PB+W | P (size-skip) | PB+W | NAK list parsed but ignored [^t-d] |
+| `0x00002000` | both | RejectRetransmit | `FlowQueue::EnqueueEmptyAck` | P+W | PB | P (size-skip) | P+W | Inbound only (server tells us "no") |
+| `0x00004000` | both | AckSequence | `FlowQueue::EnqueueAcks` | PB+W | PB+W | PB+W | PB+W | Per-packet ack pump shipped |
+| `0x00008000` | both | Disconnect | `Client::Disconnect` | β | P+W | B only | P+W | Inbound parse-and-tear-down missing [^t-e] |
+| `0x00010000` | outbound | LoginRequest | `ClientNet::SendLoginRequest` | B | P+W | B | B | Auth-only, parsed by server [^t-f] |
+| `0x00020000` | inbound | WorldLoginRequest | `CEmptyHeader<0x20000,1>` | β | P (8 bytes) | P (size-skip) | P (size-skip) | Server-only on relay [^t-g] |
+| `0x00040000` | inbound | ConnectRequest | `ClientNet::HandleConnectionRequest` | P+W | B | P+W | P+W | Handshake oracle, ISAAC seeded |
+| `0x00080000` | outbound | ConnectResponse | `ClientNet::SendConnectAck` | B | P+W | B | B | 8-byte cookie echo |
+| `0x00100000` | inbound | NetError | `NetError::UnPack` | β | β | β | P+W | Drop session + surface error to UI |
+| `0x00200000` | inbound | NetErrorDisconnect | `NetError::UnPack` | β | P+W | β | P+W | Same parse, hard-disconnect variant |
+| `0x00400000` | inbound | CICMDCommand | β | P (size-skip) | P+W | P (size-skip) | βdefer:M3 | Server-debug only; not honored by retail clients |
+| `0x01000000` | inbound | TimeSync | `ClientNet::HandleTimeSynch` | P+W | P+W | P+W | P+W | Drives `WorldTimeService` |
+| `0x02000000` | inbound | EchoRequest | `CEchoRequestHeader::CreateFromData` | P+W (mirrors out) | P+W | P (no reply) | PB+W | Must build EchoResponse mirror [^t-h] |
+| `0x04000000` | outbound | EchoResponse | β | B | B | β | B | Reply path for incoming EchoRequest |
+| `0x08000000` | both | Flow | `FlowQueue::TransmitNewPackets` | P (size-skip) | P+W | P (size-skip) | βdefer:M2 | Throttle hint; safe to ignore until M2 |
+
+**Footnotes:**
+
+[^t-a]: The `None=0` value isn't a wire bit, but it's in our enum so callers can default-initialize headers β keep it.
+[^t-b]: ACE sets `Retransmission` when re-sending a cached packet; clients should accept it as informational. We currently treat the bit as a no-op (works because we don't dedupe on it).
+[^t-c]: A login-server-side handshake step; only relevant when ACE adds login-bounce, which it doesn't today.
+[^t-d]: We need to actually retransmit on inbound NAK and need to send NAKs for our own missing inbound. M3 reliability-core phase.
+[^t-e]: Inbound `Disconnect` must close the session cleanly and notify upper layers; right now the connection just times out on client side too.
+[^t-f]: `LoginRequest` is a server-decode case but our codec consumes it on encode for hashing.
+[^t-g]: Retail server uses this for world-server entry confirmation; the holtburger ref has no parse, ACE writer-side is `Pack`. Our consumer just skips 8 bytes for hashing.
+[^t-h]: Servers do periodically EchoRequest to the client; we must mirror the 4-byte client-time as an `EchoResponse` per `FlowQueue::DequeueAck` semantics.
+
+---
+
+## Section 2 β Optional-header fields
+
+In-scope: 12. Implemented in acdream: 12 of 12 sized-skip; 6 of 12 surface decoded fields. Phase M target delta: needs (a) builders for the ones we only parse, (b) ConnectRequest + EchoRequest builder paths for symmetric tests, (c) golden-vector test file.
+
+| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
+|---|---|---|---|---|---|---|---|---|
+| `0x100` | inbound | ServerSwitch (8 bytes) | `UCServerSwitchStruct` | P (skip) | PB | P (skip) | P (decode)+W | Decode `serverIp:u32, port:u16, pad:u16` [^o-a] |
+| `0x1000` | inbound | RequestRetransmit (4+N\*4) | `FlowQueue::TransmitNaks` | PB | PB | P (parsed list) | PB+W | List stored; build path missing |
+| `0x2000` | inbound | RejectRetransmit (4+N\*4) | `FlowQueue::CompileEmptyAcks` | P | PB | P (size-skip) | P (decode)+W | List currently consumed without storage |
+| `0x4000` | both | AckSequence (4 bytes) | `FlowQueue::EnqueueAcks` | PB | PB | PB | PB | Stored as `AckSequence:u32` |
+| `0x10000` | outbound | LoginRequest (rest of pkt) | `ClientNet::SendLoginRequest` | B | P (full) | B (via `LoginRequest.Build`) | B | Variable-length tail; raw bytes hashed |
+| `0x20000` | inbound | WorldLoginRequest (8 bytes) | `CEmptyHeader<0x20000,1>` | β | P (8B peek) | P (size-skip) | P (decode)+W | Decode purpose unknown, store raw |
+| `0x40000` | inbound | ConnectRequest (32 bytes) | `CConnectHeader` | P+W | B (server) | P+W | PB | We need encode path for round-trip tests |
+| `0x80000` | outbound | ConnectResponse (8 bytes) | β | B | P (8B peek) | B | PB | Decode on inbound test fixtures |
+| `0x400000` | inbound | CICMDCommand (8 bytes) | β | P (skip) | P (8B) | P (size-skip) | βdefer:M3 | Decode + handler deferred |
+| `0x1000000` | inbound | TimeSync (8 bytes) | `CTimeSyncHeader` | P+W | P+W | P+W | PB | Add build for symmetry; double LE |
+| `0x2000000` | inbound | EchoRequest (4 bytes) | `CEchoRequestHeader` | P+W | P+W | P (no reply) | PB+W | Wire to `SendEchoResponse` builder |
+| `0x8000000` | both | Flow (6 bytes) | `UCFlowStruct` | P (skip) | P+W | P (decode) | βdefer:M2 | `FlowBytes:u32, FlowInterval:u16` decoded |
+
+**Footnotes:**
+
+[^o-a]: ServerSwitch struct layout per retail `UCServerSwitchStruct` β confirmed via named-retail symbol `?CreateFromData@?$COnePrimHeader@$0BAA@$0GA@UCServerSwitchStruct@@@@`. M3 needs the IP/port to actually re-target the socket; today we'd silently drop traffic from a relocated server.
+
+**Cross-cutting Phase M deliverables for sections 1+2:**
+
+1. **Goldens fixture file** β `tests/AcDream.Core.Net.Tests/Packets/PacketHeaderOptionalTests.cs` does not exist; only indirect coverage via `PacketCodecTests` and `ConnectRequestTests`. M needs one fixture per non-skip flag covering parse + build symmetry.
+2. **Typed events** β currently the only `WorldSession`-side flag-driven event is `ServerTimeUpdated` (from `TimeSync`). Phase M target adds: `ServerSwitchRequested(ip, port)`, `ServerDisconnect(reason)`, `ServerNetError(NetErrorCode, message)`, `EchoRequested(clientTime)` (internal), `RetransmitRequested(seqs)`, `RetransmitRejected(seqs)`.
+3. **`PacketHeaderOptional` storage gaps** β `RejectRetransmit` list is consumed but discarded; `WorldLoginRequest` 8-byte body is skipped; `CICMDCommand` 8-byte body is skipped; `ConnectResponse` 8-byte cookie is decoded only inside `Connect()`'s send path, not on inbound parse. M target: lift each into a typed property on `PacketHeaderOptional`.
+4. **Builder-side parity** β `PacketHeaderOptional.Parse` exists; there is no `PacketHeaderOptional.Build` β every outbound flag's body bytes are hand-rolled at the call site (`SendAck`, `Connect`, `Dispose`). Phase M should add a single `Build(PacketHeaderFlags, body fields)` to mirror parse.
+
+---
+
+## Section 3 β GameMessage opcodes (top-level)
+
+In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30.
+
+| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
+|------|-----------|------|---------------------|------------|-----|---------------|----------------|-------|
+| 0x0000 | both | None | β | PB | N/A | β | βskip:heartbeat-only | Internal/heartbeat sentinel |
+| 0x0024 | inbound | InventoryRemoveObject | β | P | B | β | P+W | Out of bubble or destroyed |
+| 0x0197 | inbound | SetStackSize | β | P | B | β | P+W | Container stack size delta |
+| 0x019E | inbound | PlayerKilled | β | PB | B | P+W | P+W | victim+killer broadcast |
+| 0x01E0 | inbound | EmoteText | `CM_Communication::DispatchUI_HearEmote` | PB | B | P+W | P+W | Server-driven 3rd-person emote |
+| 0x01E2 | inbound | SoulEmote | `CM_Communication::DispatchUI_HearSoulEmote` | PB | B | P+W | P+W | Complex emote w/ animation |
+| 0x02BB | inbound | HearSpeech | `ClientCommunicationSystem::Handle_Communication__HearSpeech` | PB | B | P+W | P+W | Local chat |
+| 0x02BC | inbound | HearRangedSpeech | `ClientCommunicationSystem::Handle_Communication__HearRangedSpeech` | PB | B | P+W | P+W | Shouts; same parser as 0x02BB |
+| 0x02CD | inbound | PrivateUpdatePropertyInt | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt` | PB | B | β | P+W | Owner-only int property |
+| 0x02CE | inbound | PublicUpdatePropertyInt | β | PB | B | β | P+W | Broadcast int property |
+| 0x02CF | inbound | PrivateUpdatePropertyInt64 | β | PB | B | β | P+W | Owner-only int64 |
+| 0x02D0 | inbound | PublicUpdatePropertyInt64 | β | PB | B | β | P+W | Broadcast int64 |
+| 0x02D1 | inbound | PrivateUpdatePropertyBool | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateBool` | PB | B | β | P+W | Owner-only bool |
+| 0x02D2 | inbound | PublicUpdatePropertyBool | β | PB | B | β | P+W | Broadcast bool |
+| 0x02D3 | inbound | PrivateUpdatePropertyFloat | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateFloat` | PB | B | β | P+W | Owner-only float |
+| 0x02D4 | inbound | PublicUpdatePropertyFloat | β | PB | B | β | P+W | Broadcast float |
+| 0x02D5 | inbound | PrivateUpdatePropertyString | β | PB | B | β | P+W | Owner-only string |
+| 0x02D6 | inbound | PublicUpdatePropertyString | β | PB | B | β | P+W | Broadcast string |
+| 0x02D7 | inbound | PrivateUpdatePropertyDataID | β | PB | B | β | P+W | Owner-only DataID |
+| 0x02D8 | inbound | PublicUpdatePropertyDataID | β | PB | B | β | P+W | Broadcast DataID |
+| 0x02D9 | inbound | PrivateUpdatePropertyInstanceID | `CM_Qualities::DispatchUI_PrivateUpdateInstanceID` | PB | B | β | P+W | Owner-only InstanceID |
+| 0x02DA | inbound | PublicUpdateInstanceID | β | PB | B | β | P+W | Broadcast InstanceID |
+| 0x02DB | inbound | PrivateUpdatePosition | `CM_Qualities::DispatchUI_PrivateUpdatePosition` | PB | B | β | βdefer:F.x | Owner-only position; redundant with 0xF748 |
+| 0x02DC | inbound | PublicUpdatePosition | β | PB | B | β | βdefer:F.x | Public position; redundant with 0xF748 |
+| 0x02DD | inbound | PrivateUpdateSkill | β | PB | B | β | P+W | Owner-only skill XP |
+| 0x02DE | inbound | PublicUpdateSkill | β | PB | B | β | P+W | Public skill |
+| 0x02DF | inbound | PrivateUpdateSkillLevel | β | PB | B | β | P+W | Owner-only skill base level |
+| 0x02E0 | inbound | PublicUpdateSkillLevel | β | PB | B | β | P+W | Public skill base level |
+| 0x02E3 | inbound | PrivateUpdateAttribute | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateAttribute` | PB | B | β | P+W | Strength/Stamina/etc base |
+| 0x02E4 | inbound | PublicUpdateAttribute | β | PB | B | β | P+W | Public attribute |
+| 0x02E7 | inbound | PrivateUpdateVital | β | PB | B | P+W | P+W | Max HP/Stam/Mana β vitals panel |
+| 0x02E8 | inbound | PublicUpdateVital | β | PB | B | β | P+W | Public vital |
+| 0x02E9 | inbound | PrivateUpdateAttribute2ndLevel | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateAttribute2ndLevel` | PB [^m-1] | B | P+W | P+W | Current-only vital delta |
+| 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | β | B | P+W | P+W | Fog presets / sound cues |
+| 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update |
+| 0xF643 | inbound | CharacterCreateResponse | β | PB | B | β | βdefer:char-creation | Char-creation flow not yet built |
+| 0xF653 | outbound | CharacterLogOff | β | PB | P | B | PB+W | Sent on Dispose; ACE accepts |
+| 0xF655 | both | CharacterDelete | β | PB | P | β | βdefer:char-mgmt | Char-management UI deferred |
+| 0xF656 | outbound | CharacterCreate | β | PB | P | β | βdefer:char-creation | Char-creation flow not yet built |
+| 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake |
+| 0xF658 | inbound | CharacterList | `CPlayerSystem::Handle_Login__CharacterSet` | PB | B | P+W | P+W | Login char picker |
+| 0xF659 | inbound | CharacterError | `CPlayerSystem::Handle_CharacterError` | PB | B | β | P+W | Login/restore failures |
+| 0xF6EA | both | ForceObjectDescSend | β | PB | P | β | βdefer:F.x | Server requests client re-send ObjDesc; rare |
+| 0xF745 | inbound | CreateObject (ObjectCreate) | `SmartBox::HandleCreateObject` | PB | B | P+W | P+W | Spawn entity in bubble |
+| 0xF746 | inbound | PlayerCreate | `SmartBox::HandleCreatePlayer` | PB | B | P+W [^m-3] | P+W | Triggers LoginComplete |
+| 0xF747 | inbound | DeleteObject (ObjectDelete) | `SmartBox::HandleDeleteObject` | PB | B | P+W | P+W | Despawn |
+| 0xF748 | inbound | UpdatePosition | `CM_Qualities::DispatchUI_UpdatePosition` | PB | B | P+W | P+W | Periodic position sync |
+| 0xF749 | inbound | ParentEvent | `SmartBox::HandleParentEvent` | PB | B | β | P+W | Equip/wield parent change |
+| 0xF74A | inbound | PickupEvent | `SmartBox::HandlePickupEvent` | PB | B | β | P+W | Pickup confirmation |
+| 0xF74B | inbound | SetState | `SmartBox::HandleSetState` | PB | B | β | P+W | Door open/close, container state |
+| 0xF74C | inbound | UpdateMotion (Motion) | β | PB | B | P+W | P+W | Animation cycle change |
+| 0xF74E | inbound | VectorUpdate | `SmartBox::HandleVectorUpdate` | PB | B | P+W | P+W | Remote jump velocity, missile arc |
+| 0xF750 | inbound | Sound | `SmartBox::HandleSoundEvent` | PB | B | β | P+W | Positional sound trigger |
+| 0xF751 | inbound | PlayerTeleport | `SmartBox::HandlePlayerTeleport` | PB | B | P+W | P+W | Portal/teleport screen |
+| 0xF752 | inbound | AutonomyLevel | `CommandInterpreter::SetAutonomyLevel` | P [^m-4] | β | β | P+W | Server tells client physics-trust level |
+| 0xF753 | both | AutonomousPosition | `CM_Movement::Event_AutonomousPosition` | PB | β | B | PB+W | Outbound built; inbound parser missing |
+| 0xF754 | inbound | PlayScript (PlayScriptId) | `SmartBox::HandlePlayScriptID` | β | β | P+W [^m-5] | P+W | Inline parser; lightning, spell FX, emotes |
+| 0xF755 | inbound | PlayEffect | β | PB | B | β | P+W | Particle/visual scripts; ACE uses for PlayScript wrapper |
+| 0xF7B0 | inbound | GameEvent (envelope) | β | PB | B | P+W | P+W | Envelope for sub-opcodes (see Β§4) |
+| 0xF7B1 | outbound | GameAction (envelope) | β | PB | P | B+W | PB+W | Envelope for sub-opcodes (see Β§5) |
+| 0xF7C1 | inbound | AccountBanned | β | β | B | β | βdefer:F.x | ACE-only, rarely seen |
+| 0xF7C8 | outbound | CharacterEnterWorldRequest | β | PB | P | B | PB+W | Built; sent before 0xF657 |
+| 0xF7CC | both | GetServerVersion | `Proto_UI::SendAdminGetServerVersion` | β | P | β | βdefer:F.x | Admin-only |
+| 0xF7CD | both | FriendsOld | β | β | P | β | βdefer:F.x | Obsolete; ACE drops it |
+| 0xF7D9 | outbound | CharacterRestore | β | PB | P | β | βdefer:char-mgmt | Char-management UI deferred |
+| 0xF7DB | inbound | UpdateObject | `SmartBox::HandleUpdateObject` | PB | B | β | P+W | Heavy re-send of object visual+physics |
+| 0xF7DC | inbound | AccountBoot | `CPlayerSystem::Handle_AccountBooted` | PB | B | β | P+W | Kicked from server |
+| 0xF7DE | both | TurbineChat | `CCommunicationSystem::IsUsingTurbineChat` | PB | PB [^m-6] | PB+W | PB+W | Global community chat |
+| 0xF7DF | inbound | CharacterEnterWorldServerReady | β | P [^m-7] | B | P+W [^m-8] | P+W | Handshake gate during enter-world |
+| 0xF7E0 | inbound | ServerMessage | β | PB | B | P+W | P+W | System message / announcements |
+| 0xF7E1 | inbound | ServerName | `ECM_Login::SendNotice_WorldName` | PB | B | β | P+W | Shard name during login |
+| 0xF7E2 | both | DDD_DataMessage | β | β | β | β | βdefer:dat-streaming | DDD download channel (we ship dats locally) |
+| 0xF7E3 | both | DDD_RequestDataMessage | β | β | P | β | βdefer:dat-streaming | Client requests dat data |
+| 0xF7E4 | both | DDD_ErrorMessage | β | β | β | β | βdefer:dat-streaming | DDD error channel |
+| 0xF7E5 | inbound | DDD_Interrogation | `DDD_InterrogationMessage::Serialize` | PB [^m-9] | B | P+W | P+W | Server asks "what dat versions?" |
+| 0xF7E6 | outbound | DDD_InterrogationResponse | β | PB | P | B | PB+W | Built; sent in response to 0xF7E5 |
+| 0xF7E7 | both | DDD_BeginDDD | β | β | β | β | βdefer:dat-streaming | DDD start |
+| 0xF7E8 | both | DDD_BeginPullDDD | β | β | β | β | βdefer:dat-streaming | DDD pull start |
+| 0xF7E9 | both | DDD_IterationData | β | β | β | β | βdefer:dat-streaming | DDD chunk iteration |
+| 0xF7EA | inbound | DDD_EndDDD | β | β | P | β | βdefer:dat-streaming | DDD end signal |
+
+**Footnotes:**
+
+[^m-1]: ACE calls 0x02E9 `PrivateUpdateAttribute2ndLevel`; holtburger calls it `PrivateUpdateVitalCurrent` (current-only delta).
+[^m-2]: Retail-side trigger of the enter-world flow; the wire opcode 0xF657 is constructed from the request.
+[^m-3]: PlayerCreate fires LoginComplete when guid matches own char; CreateObject body is parsed for the player too.
+[^m-4]: AutonomyLevel is in holtburger's `GameMessage` enum + unpack/pack, but its enum value (0xF752) is mapped via opcode dispatch.
+[^m-5]: 0xF754 PlayScript is parsed inline in `WorldSession.cs:850` (no dedicated `Messages/PlayScript.cs`); routed to `PlayScriptReceived` event for VFX runtime.
+[^m-6]: ACE handles inbound TurbineChat via `TurbineChatHandler` and emits outbound via `GameMessageTurbineChat`, hence both directions.
+[^m-7]: CharacterEnterWorldServerReady is unit variant in holtburger (no payload); only an opcode marker.
+[^m-8]: acdream uses 0xF7DF as a handshake gate (`WorldSession.cs:495`), no dedicated parser file.
+[^m-9]: DddInterrogation in holtburger is a unit variant β opcode marker only, no payload to parse.
+
+**Caveats and unknowns:**
+- `0xF7C1 AccountBanned` is in ACE's enum + has a `GameMessageAccountBanned.cs`, but holtburger has it commented out. Marked `βdefer` since the channel exists in retail but rarely fires.
+- `0xF7CC GetServerVersion`, `0xF7CD FriendsOld`: ACE has handlers for them (i.e. accepts them inbound from a client that sends them), but no acdream sends them today. Listed as `βdefer`.
+- `0xF619 PositionAndMovement`: holtburger documents this as a "ghost" opcode (defined but never emitted by ACE/retail). Excluded from the table β confirmed dead code per holtburger comment + grep on ACE shows no `Writer.Write` site.
+- `0xF754 PlayScriptId` vs `0xF755 PlayEffect`: ACE has the `Script.cs` GameMessage tagged with `PlayEffect (0xF755)`, while retail's `SmartBox::HandlePlayScriptID` is the 0xF754 handler. acdream's inline parser at `WorldSession.cs:850` reads `[u32 opcode][u32 guid][u32 scriptId]` matching the 0xF754 layout.
+
+---
+
+## Section 4 β GameEvent sub-opcodes (inside 0xF7B0 envelope)
+
+In-scope: 103. Implemented (parsed) in acdream today: 27. Wired (`W`) in acdream today: 26. Phase M target delta: 76 new parsers + ~50 deferred to later phases.
+
+All rows are `inbound` direction (GameEvents are serverβclient only).
+
+| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
+|---|---|---|---|---|---|---|---|---|
+| 0x0003 | inbound | AllegianceUpdateAborted | `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdateAborted` | β | W | β | βdefer:Allegiance | scope deferred β no allegiance UI yet |
+| 0x0004 | inbound | PopupString | `ClientCommunicationSystem::Handle_Communication__PopUpString` | W | W | W | W | modal text β ChatLog.OnPopup |
+| 0x0013 | inbound | PlayerDescription | `CPlayerSystem::Handle_PlayerDescription` | W | W | W | W | full local-player snapshot at login [^e-a] |
+| 0x0020 | inbound | AllegianceUpdate | `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdate` | β | W | β | βdefer:Allegiance | needs CAllegianceProfile parser |
+| 0x0021 | inbound | FriendsListUpdate | `CM_Social::SendNotice_UpdateFriendsList` | β | W | β | P+W | FriendDataList; small parser, high UX value |
+| 0x0022 | inbound | InventoryPutObjInContainer | β (CM_Inventory) | W | W | W | W | (item, container, slot) β items.MoveItem |
+| 0x0023 | inbound | WieldObject | β (CM_Inventory) | W | W | W | W | server-driven equip |
+| 0x0029 | inbound | CharacterTitle | `CM_Social::SendNotice_AddCharacterTitle` | β | W | β | βdefer:Social | gmCharacterTitleUI |
+| 0x002B | inbound | UpdateTitle | `CM_Social::SendNotice_SetDisplayCharacterTitle` | β | W | β | βdefer:Social | titles UI not yet built |
+| 0x0052 | inbound | CloseGroundContainer | β (gmInventoryUI) | W | W | P | P+W | parser exists, needs ItemRepository wiring |
+| 0x0062 | inbound | ApproachVendor | β (CM_Vendor) | W | W | β | βdefer:VendorPanel | needs VendorProfile + ItemProfile list parser |
+| 0x0075 | inbound | StartBarber | `ClientUISystem::Handle_Character__StartBarber` | β | W | β | βdefer:Barber | gmBarberUI not yet built |
+| 0x00A0 | inbound | InventoryServerSaveFailed | β (CM_Inventory) | W | W | P | P+W | parser exists; needs revert hook |
+| 0x00A3 | inbound | FellowshipQuit | `ClientFellowshipSystem::Handle_Fellowship__Quit` | W | W | β | βdefer:Fellowship | scope deferred β no fellowship state |
+| 0x00A4 | inbound | FellowshipDismiss | `ClientFellowshipSystem::Handle_Fellowship__Dismiss` | W | W | β | βdefer:Fellowship | scope deferred |
+| 0x00B4 | inbound | BookDataResponse | `CM_Writing::Event_BookData` | W | W | β | βdefer:Books | gmBookUI not yet built |
+| 0x00B5 | inbound | BookModifyPageResponse | `CM_Writing::Event_BookModifyPage` | β | W | β | βdefer:Books | |
+| 0x00B6 | inbound | BookAddPageResponse | `CM_Writing::SendNotice_BookAddPageResponse` | β | W | β | βdefer:Books | |
+| 0x00B7 | inbound | BookDeletePageResponse | `CM_Writing::SendNotice_BookDeletePageResponse` | β | W | β | βdefer:Books | |
+| 0x00B8 | inbound | BookPageDataResponse | `CM_Writing::SendNotice_BookPageDataResponse` | W | W | β | βdefer:Books | |
+| 0x00C3 | inbound | GetInscriptionResponse | β | β | W | β | βdefer:Books | inscription on caster items |
+| 0x00C9 | inbound | IdentifyObjectResponse | `ClientUISystem::Handle_Item__AppraiseDone` [^e-b] | W | W | W | W | AppraiseInfoParser feeds ItemRepository |
+| 0x0147 | inbound | ChannelBroadcast | `ClientCommunicationSystem::Handle_Communication__ChannelBroadcast` | W | W | W | W | (channelId, sender, msg) β ChatLog |
+| 0x0148 | inbound | ChannelList | `ClientCommunicationSystem::Handle_Communication__ChannelList` | β | W | β | P+W | PackableList; admin/list response |
+| 0x0149 | inbound | ChannelIndex | `ClientCommunicationSystem::Handle_Communication__ChannelIndex` | β | W | β | P+W | PackableList |
+| 0x0196 | inbound | ViewContents | `ClientUISystem::OnViewContents` | W | W | β | P+W | server view of remote container β needed for sidepacks |
+| 0x019A | inbound | InventoryPutObjectIn3D | β (CM_Inventory) | W | W | P | P+W | parser exists; needs spawn-into-world wiring |
+| 0x01A7 | inbound | AttackDone | β | W | W | W | W | combat seq complete |
+| 0x01A8 | inbound | MagicRemoveSpell | `ClientMagicSystem::Handle_Magic__RemoveSpell` | W | W | W | W | spell removed from spellbook |
+| 0x01AC | inbound | VictimNotification | `ClientCombatSystem::HandleVictimNotificationEvent` | W | W | W | W | death msg for victim |
+| 0x01AD | inbound | KillerNotification | `ClientCombatSystem::HandleKillerNotificationEvent` | W | W | W | W | death msg for killer |
+| 0x01B1 | inbound | AttackerNotification | `ClientCombatSystem::HandleAttackerNotificationEvent` | W | W | W | W | "you hit X" |
+| 0x01B2 | inbound | DefenderNotification | `ClientCombatSystem::HandleDefenderNotificationEvent` | W | W | W | W | "X hit you" |
+| 0x01B3 | inbound | EvasionAttackerNotification | `ClientCombatSystem::HandleEvasionAttackerNotificationEvent` | W | W | W | W | "X evaded" |
+| 0x01B4 | inbound | EvasionDefenderNotification | `ClientCombatSystem::HandleEvasionDefenderNotificationEvent` | W | W | W | W | "you evaded X" |
+| 0x01B8 | inbound | CombatCommenceAttack | β | W | W | W | W | empty payload |
+| 0x01C0 | inbound | UpdateHealth | `CM_Combat::SendNotice_UpdateObjectHealth` | W | W | W | W | (guid, healthPct) β CombatState |
+| 0x01C3 | inbound | QueryAgeResponse | `ClientCommunicationSystem::Handle_Character__QueryAgeResponse` | β | W | β | P | small string parser; chat panel display |
+| 0x01C7 | inbound | UseDone | `ClientUISystem::Handle_Item__UseDone` | W | W | P | P+W | parser exists; needs InteractionState wiring |
+| 0x01C8 | inbound | AllegianceUpdateDone | β | β | W | β | βdefer:Allegiance | |
+| 0x01C9 | inbound | FellowshipFellowUpdateDone | `ClientFellowshipSystem::Handle_Fellowship__FellowUpdateDone` | W | W | β | βdefer:Fellowship | empty payload |
+| 0x01CA | inbound | FellowshipFellowStatsDone | `ClientFellowshipSystem::Handle_Fellowship__FellowStatsDone` | W | W | β | βdefer:Fellowship | empty payload |
+| 0x01CB | inbound | ItemAppraiseDone | `ClientUISystem::Handle_Item__AppraiseDone` | β | W | β | P | post-IdentifyObjectResponse signal |
+| 0x01E2 | inbound | Emote | `ClientCommunicationSystem::Handle_Communication__HearEmote` [^e-c] | β | W | β | P | "*X waves*" β chat broadcast |
+| 0x01EA | inbound | PingResponse | `ClientUISystem::Handle_Character__ReturnPing` | W | W | P | P+W | parser exists; needs latency/heartbeat wiring |
+| 0x01F4 | inbound | SetSquelchDB | `ClientCommunicationSystem::Handle_Communication__SetSquelchDB` | β | W | β | βdefer:SquelchUI | SquelchDB blob; ignore-list state |
+| 0x01FD | inbound | RegisterTrade | `ClientTradeSystem::Handle_Trade__Recv_RegisterTrade` | W | W | β | βdefer:TradePanel | (guid, accepterGuid, ackTimer) |
+| 0x01FE | inbound | OpenTrade | `ClientTradeSystem::Handle_Trade__Recv_OpenTrade` | W | W | β | βdefer:TradePanel | initiator guid |
+| 0x01FF | inbound | CloseTrade | `ClientTradeSystem::Handle_Trade__Recv_CloseTrade` | W | W | β | βdefer:TradePanel | closer guid |
+| 0x0200 | inbound | AddToTrade | `ClientTradeSystem::Handle_Trade__Recv_AddToTrade` | W | W | P | βdefer:TradePanel | parser exists; needs TradeState |
+| 0x0201 | inbound | RemoveFromTrade | `ClientTradeSystem::Handle_Trade__Recv_RemoveFromTrade` | β | W | β | βdefer:TradePanel | (initiatorGuid, itemGuid) |
+| 0x0202 | inbound | AcceptTrade | `ClientTradeSystem::Handle_Trade__Recv_AcceptTrade` | W | W | P | βdefer:TradePanel | parser exists |
+| 0x0203 | inbound | DeclineTrade | `ClientTradeSystem::Handle_Trade__Recv_DeclineTrade` | W | W | β | βdefer:TradePanel | initiator guid |
+| 0x0205 | inbound | ResetTrade | `ClientTradeSystem::Handle_Trade__Recv_ResetTrade` | W | W | β | βdefer:TradePanel | reset to-trade list |
+| 0x0207 | inbound | TradeFailure | `ClientTradeSystem::Handle_Trade__Recv_TradeFailure` | W | W | P | βdefer:TradePanel | parser exists |
+| 0x0208 | inbound | ClearTradeAcceptance | `ClientTradeSystem::Handle_Trade__Recv_ClearTradeAcceptance` | W | W | β | βdefer:TradePanel | empty payload |
+| 0x021D | inbound | HouseProfile | `ClientHousingSystem::Handle_House__Recv_HouseProfile` | β | W | β | βdefer:Housing | HouseProfile blob |
+| 0x0225 | inbound | HouseData | `ClientHousingSystem::Handle_House__Recv_HouseData` | β | W | β | βdefer:Housing | HouseData blob |
+| 0x0226 | inbound | HouseStatus | `ClientHousingSystem::Handle_House__Recv_HouseStatus` | β | W | β | βdefer:Housing | scalar status code |
+| 0x0227 | inbound | UpdateRentTime | `ClientHousingSystem::Handle_House__Recv_UpdateRentTime` | β | W | β | βdefer:Housing | i32 timestamp |
+| 0x0228 | inbound | UpdateRentPayment | `ClientHousingSystem::Handle_House__Recv_UpdateRentPayment` | β | W | β | βdefer:Housing | HousePaymentList |
+| 0x0248 | inbound | HouseUpdateRestrictions | `ClientHousingSystem::Handle_House__Recv_UpdateRestrictions` | β | W | β | βdefer:Housing | RestrictionDB blob |
+| 0x0257 | inbound | UpdateHAR | `ClientHousingSystem::Handle_House__Recv_UpdateHAR` | β | W | β | βdefer:Housing | HAR blob |
+| 0x0259 | inbound | HouseTransaction | `ClientHousingSystem::Handle_House__Recv_HouseTransaction` | β | W | β | βdefer:Housing | scalar txn code |
+| 0x0264 | inbound | QueryItemManaResponse | `ClientUISystem::Handle_Item__QueryItemManaResponse` | W | W | P | P+W | parser exists; needs ItemRepository wiring |
+| 0x0271 | inbound | AvailableHouses | `ClientHousingSystem::Handle_House__Recv_AvailableHouses` | β | W | β | βdefer:Housing | PackableList + flag |
+| 0x0274 | inbound | CharacterConfirmationRequest | `ClientUISystem::Handle_Character__ConfirmationRequest` | W | W | P | P+W | parser exists; needs modal-confirm wiring |
+| 0x0276 | inbound | CharacterConfirmationDone | `ClientUISystem::Handle_Character__ConfirmationDone` | W | W | β | P+W | (type, contextId); confirms client ACK |
+| 0x027A | inbound | AllegianceLoginNotification | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent` | β | W | β | βdefer:Allegiance | (guid, login/logout flag) |
+| 0x027C | inbound | AllegianceInfoResponse | `ClientAllegianceSystem::Handle_Allegiance__AllegianceInfoResponseEvent` | β | W | β | βdefer:Allegiance | CAllegianceProfile |
+| 0x0281 | inbound | JoinGameResponse | `ClientMiniGameSystem::Handle_Game__Recv_JoinGameResponse` | β | W | β | βdefer:MiniGame | chess/dice/etc β minimal value |
+| 0x0282 | inbound | StartGame | `ClientMiniGameSystem::Handle_Game__Recv_StartGame` | W | W | β | βdefer:MiniGame | empty payload |
+| 0x0283 | inbound | MoveResponse | `ClientMiniGameSystem::Handle_Game__Recv_MoveResponse` | β | W | β | βdefer:MiniGame | minigame move ack |
+| 0x0284 | inbound | OpponentTurn | `ClientMiniGameSystem::Handle_Game__Recv_OpponentTurn` | β | W | β | βdefer:MiniGame | GameMoveData blob |
+| 0x0285 | inbound | OpponentStalemate | `ClientMiniGameSystem::Handle_Game__Recv_OppenentStalemateState` | β | W | β | βdefer:MiniGame | typo preserved (retail name) |
+| 0x028A | inbound | WeenieError | `ClientCommunicationSystem::Handle_Communication__WeenieError` | W | W | W | W | error code β ChatLog.OnWeenieError |
+| 0x028B | inbound | WeenieErrorWithString | `ClientCommunicationSystem::Handle_Communication__WeenieErrorWithString` | W | W | W | W | (code, interp) β ChatLog |
+| 0x028C | inbound | GameOver | `ClientMiniGameSystem::Handle_Game__Recv_GameOver` | β | W | β | βdefer:MiniGame | (gameId, winner) |
+| 0x0295 | inbound | SetTurbineChatChannels | `ClientCommunicationSystem::Handle_Communication__Recv_ChatRoomTracker` [^e-d] | W | W | W | W | per-room ids β TurbineChatState |
+| 0x02AE | inbound | AdminQueryPluginList | β (admin tooling) | β | W | β | βskip:admin-only | server-admin path; not retail-emitted to player |
+| 0x02B1 | inbound | AdminQueryPlugin | β | β | W | β | βskip:admin-only | |
+| 0x02B3 | inbound | AdminQueryPluginResponse | β | β | W | β | βskip:admin-only | |
+| 0x02B4 | inbound | SalvageOperationsResult | `ClientUISystem::Handle_Inventory__Recv_SalvageOperationsResultData` | β | W | β | βdefer:SalvageUI | SalvageOperationsResultData blob |
+| 0x02BD | inbound | Tell | β (CM_Communication) | W | W | W | W | direct whisper β ChatLog |
+| 0x02BE | inbound | FellowshipFullUpdate | `ClientFellowshipSystem::Handle_Fellowship__FullUpdate` | W | W | β | βdefer:Fellowship | CFellowship blob |
+| 0x02BF | inbound | FellowshipDisband | `ClientFellowshipSystem::Handle_Fellowship__Disband` | W | W | β | βdefer:Fellowship | empty payload |
+| 0x02C0 | inbound | FellowshipUpdateFellow | `ClientFellowshipSystem::Handle_Fellowship__UpdateFellow` | W | W | β | βdefer:Fellowship | (memberGuid, Fellow, flag) |
+| 0x02C1 | inbound | MagicUpdateSpell | `ClientMagicSystem::Handle_Magic__UpdateSpell` | W | W | W | W | learned spellId β Spellbook |
+| 0x02C2 | inbound | MagicUpdateEnchantment | `ClientMagicSystem::Handle_Magic__UpdateEnchantment` | W | W | W | W | Enchantment blob β Spellbook |
+| 0x02C3 | inbound | MagicRemoveEnchantment | `ClientMagicSystem::Handle_Magic__RemoveEnchantment` | W | W | W | W | (layerId, spellId) |
+| 0x02C4 | inbound | MagicUpdateMultipleEnchantments | `ClientMagicSystem::Handle_Magic__UpdateMultipleEnchantments` | W | W | β | P+W | PackableList |
+| 0x02C5 | inbound | MagicRemoveMultipleEnchantments | `ClientMagicSystem::Handle_Magic__RemoveMultipleEnchantments` | W | W | β | P+W | PackableList |
+| 0x02C6 | inbound | MagicPurgeEnchantments | `ClientMagicSystem::Handle_Magic__PurgeEnchantments` | W | W | W | W | empty payload β Spellbook.OnPurgeAll |
+| 0x02C7 | inbound | MagicDispelEnchantment | `ClientMagicSystem::Handle_Magic__DispelEnchantment` | W | W | W | W | shared parser w/ MagicRemoveEnchantment |
+| 0x02C8 | inbound | MagicDispelMultipleEnchantments | `ClientMagicSystem::Handle_Magic__DispelMultipleEnchantments` | W | W | β | P+W | PackableList |
+| 0x02C9 | inbound | PortalStormBrewing | `ClientUISystem::Handle_Misc__PortalStormBrewing` | β | W | β | P+W | float intensity β ChatLog system message |
+| 0x02CA | inbound | PortalStormImminent | `ClientUISystem::Handle_Misc__PortalStormImminent` | β | W | β | P+W | float intensity |
+| 0x02CB | inbound | PortalStorm | `ClientUISystem::Handle_Misc__PortalStorm` | β | W | β | P+W | empty payload β actual storm trigger |
+| 0x02CC | inbound | PortalStormSubsided | `ClientUISystem::Handle_Misc__PortalStormSubsided` | β | W | β | P+W | empty payload |
+| 0x02EB | inbound | CommunicationTransientString | `ClientCommunicationSystem::Handle_Communication__TransientString` | W | W | W | W | (msg, chatType) β ChatLog system msg |
+| 0x0312 | inbound | MagicPurgeBadEnchantments | `ClientMagicSystem::Handle_Magic__PurgeBadEnchantments` | W | W | β | P+W | empty payload |
+| 0x0314 | inbound | SendClientContractTrackerTable | `ClientUISystem::Handle_Social__SendClientContractTrackerTable` | β | W | β | βdefer:Quests | CContractTrackerTable blob |
+| 0x0315 | inbound | SendClientContractTracker | `ClientUISystem::Handle_Social__SendClientContractTracker` | β | W | β | βdefer:Quests | (CContractTracker, flag, flag) |
+
+**Footnotes:**
+
+[^e-a]: PlayerDescription has its own dedicated parser (`PlayerDescriptionParser.TryParse`) rather than living in `GameEvents.cs`. Wires into `LocalPlayerState` (vitals 7/8/9), `Spellbook` (learned spells + enchantments), `ItemRepository` (inventory + equipped), and the `onSkillsUpdated` callback (Run/Jump skills for movement).
+[^e-b]: IdentifyObjectResponse uses `AppraiseInfoParser.TryParse` (separate file) rather than the simple header-only parser in `GameEvents.cs`. Returns full property bundle (int / int64 / bool / float / string / DID tables) plus SpellBook list. The retail handler `Handle_Item__AppraiseDone` (0x01CB) is the post-arrival completion signal, not the data carrier itself.
+[^e-c]: 0x01E2 Emote sub-opcode is distinct from `HearEmote` (top-level GameMessage 0x02BC); the sub-opcode form is documented in ACE's `GameEventType.cs` but the named-retail decomp doesn't expose a dedicated handler β likely re-routed through the chat broadcast path.
+[^e-d]: Named retail's `Recv_ChatRoomTracker` is the underlying handler symbol; ACE/Holtburger renamed to `SetTurbineChatChannels` for clarity. Same wire payload (per-room session ids for General/Trade/LFG/Roleplay/Society/Olthoi/Allegiance).
+
+---
+
+## Section 5 β GameAction sub-opcodes (inside 0xF7B1 envelope)
+
+In-scope: 96. Implemented (built) in acdream: 24. Live callers in acdream: 8. Phase M target delta: 72 new builders + golden-vector tests.
+
+All rows are `outbound` direction (GameActions are clientβserver only).
+
+| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
+|------|-----------|------|---------------------|------------|-----|---------------|----------------|-------|
+| 0x0005 | outbound | SetSingleCharacterOption | β | W | H | β | B | Per-option toggle; sibling of 0x01A1 bitmap |
+| 0x0008 | outbound | TargetedMeleeAttack | `CM_Combat::Event_TargetedMeleeAttack` | W | H | W | B+W | Wired in WorldSession.SendMeleeAttack |
+| 0x000A | outbound | TargetedMissileAttack | `CM_Combat::Event_TargetedMissileAttack` | W | H | W | B+W | Wired in WorldSession.SendMissileAttack |
+| 0x000F | outbound | SetAfkMode | `CM_Communication::Event_SetAFKMode` | β | H | β | B | Toggle AFK |
+| 0x0010 | outbound | SetAfkMessage | `CM_Communication::Event_SetAFKMessage` | β | H | β | B | Custom AFK string |
+| 0x0015 | outbound | Talk | `CM_Communication::Event_Talk` | W | H | W | B+W | Wired in WorldSession.SendTalk |
+| 0x0017 | outbound | RemoveFriend | `CM_Social::Event_RemoveFriend` | β | H | β | B | Friends list mutation |
+| 0x0018 | outbound | AddFriend | `CM_Social::Event_AddFriend` | β | H | β | B | Friends list mutation |
+| 0x0019 | outbound | PutItemInContainer | `CM_Inventory::Event_PutItemInContainer` | W | H | β | B | Inventory move; high priority |
+| 0x001A | outbound | GetAndWieldItem | `CM_Inventory::Event_GetAndWieldItem` | W | H | β | B | Equip item |
+| 0x001B | outbound | DropItem | `CM_Inventory::Event_DropItem` | W | H | β | B | Drop to ground |
+| 0x001D | outbound | SwearAllegiance | `CM_Allegiance::Event_SwearAllegiance` | W | H | B | B+W | AllegianceRequests dead [^a-1] |
+| 0x001E | outbound | BreakAllegiance | `CM_Allegiance::Event_BreakAllegiance` | W | H | B | B+W | AllegianceRequests dead [^a-1] |
+| 0x001F | outbound | AllegianceUpdateRequest | β | β | H | β | B | Refresh allegiance tree |
+| 0x0025 | outbound | RemoveAllFriends | β | β | H | β | B | Clear friends list |
+| 0x0026 | outbound | TeleToPklArena | β | W | H | β | B | PK-lite arena recall |
+| 0x0027 | outbound | TeleToPkArena | β | β | H | β | B | PK arena recall |
+| 0x002C | outbound | TitleSet | β | β | H | β | B | Equip title |
+| 0x0030 | outbound | QueryAllegianceName | `CM_Allegiance::Event_QueryAllegianceName` | β | H | β | B | β |
+| 0x0031 | outbound | ClearAllegianceName | `CM_Allegiance::Event_ClearAllegianceName` | β | H | β | B | Officer-only |
+| 0x0032 | outbound | TalkDirect | `CM_Communication::Event_TalkDirect` | β | H | β | B | Targeted /say (rarely used) |
+| 0x0033 | outbound | SetAllegianceName | `CM_Allegiance::Event_SetAllegianceName` | β | H | β | B | Monarch-only |
+| 0x0035 | outbound | UseWithTarget | `CM_Inventory::Event_UseWithTargetEvent` | W | H | B | B+W | InteractRequests dead [^a-1] |
+| 0x0036 | outbound | Use | `CM_Inventory::Event_UseEvent` | W | H | B | B+W | InteractRequests dead [^a-1] |
+| 0x003B | outbound | SetAllegianceOfficer | `CM_Allegiance::Event_SetAllegianceOfficer` | β | H | β | B | β |
+| 0x003C | outbound | SetAllegianceOfficerTitle | `CM_Allegiance::Event_SetAllegianceOfficerTitle` | β | H | β | B | β |
+| 0x003D | outbound | ListAllegianceOfficerTitles | `CM_Allegiance::Event_ListAllegianceOfficerTitles` | β | H | β | B | β |
+| 0x003E | outbound | ClearAllegianceOfficerTitles | `CM_Allegiance::Event_ClearAllegianceOfficerTitles` | β | H | β | B | β |
+| 0x003F | outbound | DoAllegianceLockAction | `CM_Allegiance::Event_DoAllegianceLockAction` | β | H | β | B | Lock recruitment |
+| 0x0040 | outbound | SetAllegianceApprovedVassal | `CM_Allegiance::Event_SetAllegianceApprovedVassal` | β | β | β | B | β |
+| 0x0041 | outbound | AllegianceChatGag | `CM_Allegiance::Event_AllegianceChatGag` | β | H | β | B | β |
+| 0x0042 | outbound | DoAllegianceHouseAction | `CM_Allegiance::Event_DoAllegianceHouseAction` | β | H | β | B | β |
+| 0x0044 | outbound | RaiseVital | β | W | H | B | B+W | CharacterActions builder dead [^a-1] |
+| 0x0045 | outbound | RaiseAttribute | β | W | H | B | B+W | CharacterActions builder dead [^a-1] |
+| 0x0046 | outbound | RaiseSkill | β | W | H | B | B+W | CharacterActions builder dead [^a-1] |
+| 0x0047 | outbound | TrainSkill | `CM_Train::Event_TrainSkill` | W | H | B | B+W | CharacterActions builder dead [^a-1] |
+| 0x0048 | outbound | CastUntargetedSpell | `CM_Magic::Event_CastUntargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
+| 0x004A | outbound | CastTargetedSpell | `CM_Magic::Event_CastTargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
+| 0x0053 | outbound | ChangeCombatMode | `CM_Combat::Event_ChangeCombatMode` | W | H | W | B+W | Wired in WorldSession.SendChangeCombatMode |
+| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x0055 | outbound | StackableSplitToContainer | `CM_Inventory::Event_StackableSplitToContainer` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x0056 | outbound | StackableSplitTo3D | `CM_Inventory::Event_StackableSplitTo3D` | β | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x0058 | outbound | ModifyCharacterSquelch | `CM_Communication::Event_ModifyCharacterSquelch` | β | H | β | B | Mute one player |
+| 0x0059 | outbound | ModifyAccountSquelch | `CM_Communication::Event_ModifyAccountSquelch` | β | H | β | B | Mute account |
+| 0x005B | outbound | ModifyGlobalSquelch | `CM_Communication::Event_ModifyGlobalSquelch` | β | H | β | B | Mute pattern |
+| 0x005D | outbound | Tell | β | W | H | W | B+W | Wired in WorldSession.SendTell [^a-2] |
+| 0x005F | outbound | Buy | `CM_Vendor::Event_Buy` | W | H | β | B | Vendor purchase |
+| 0x0060 | outbound | Sell | `CM_Vendor::Event_Sell` | W | H | β | B | Vendor sell |
+| 0x0063 | outbound | TeleToLifestone | `CM_Character::Event_TeleToLifestone` | W | H | B | B+W | InteractRequests builder dead [^a-1] |
+| 0x00A1 | outbound | LoginComplete | `CM_Character::Event_LoginCompleteNotification` | W | H | W | B+W | Wired in GameWindow.cs:4423 |
+| 0x00A2 | outbound | FellowshipCreate | `CM_Fellowship::Event_Create` | W | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x00A3 | outbound | FellowshipQuit | `CM_Fellowship::Event_Quit` | W | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x00A4 | outbound | FellowshipDismiss | `CM_Fellowship::Event_Dismiss` | W | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x00A5 | outbound | FellowshipRecruit | `CM_Fellowship::Event_Recruit` | W | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x00A6 | outbound | FellowshipUpdateRequest | `CM_Fellowship::Event_UpdateRequest` | W | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x00AA | outbound | BookData | `CM_Writing::Event_BookData` | β | H | β | B | Open book contents |
+| 0x00AB | outbound | BookModifyPage | `CM_Writing::Event_BookModifyPage` | β | H | β | B | Edit page text |
+| 0x00AC | outbound | BookAddPage | `CM_Writing::Event_BookAddPage` | β | H | β | B | β |
+| 0x00AD | outbound | BookDeletePage | `CM_Writing::Event_BookDeletePage` | β | H | β | B | β |
+| 0x00AE | outbound | BookPageData | `CM_Writing::Event_BookPageData` | W | β | β | B | Read one page |
+| 0x00B1 | outbound | TeleToPoi | β | β | β | B | B | InventoryActions builder dead; ACE handler unclear [^a-1][^a-3] |
+| 0x00BF | outbound | SetInscription | `CM_Writing::Event_SetInscription` | β | β | β | B | Inscribe item |
+| 0x00C8 | outbound | IdentifyObject | `CM_Item::Event_Appraise` | W | H | B | B+W | AppraiseRequest builder dead [^a-1] |
+| 0x00CD | outbound | GiveObjectRequest | `CM_Inventory::Event_GiveObjectRequest` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x00D6 | outbound | AdvocateTeleport | β | β | H | β | B | GM-only teleport |
+| 0x0140 | outbound | AbuseLogRequest | `CM_Character::Event_AbuseLogRequest` | β | β | β | B | Player report tool |
+| 0x0145 | outbound | AddChannel | `CM_Communication::Event_ChannelList` | β | H | B | B+W | SocialActions builder dead [^a-1][^a-4] |
+| 0x0146 | outbound | RemoveChannel | β | β | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x0147 | outbound | ChatChannel | `CM_Communication::Event_ChannelBroadcast` | W | H | W | B+W | Wired in WorldSession.SendChannel; same code as inbound 0x0147 [^a-5] |
+| 0x0148 | outbound | ListChannels | β | β | β | β | B | β |
+| 0x0149 | outbound | IndexChannels | `CM_Communication::Event_ChannelIndex` | β | β | β | B | β |
+| 0x0195 | outbound | NoLongerViewingContents | `CM_Inventory::Event_NoLongerViewingContents` | W | H | β | B | Container UI close |
+| 0x019B | outbound | StackableSplitToWield | `CM_Inventory::Event_StackableSplitToWield` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x019C | outbound | AddShortcut | `CM_Character::Event_AddShortCut` | β | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x019D | outbound | RemoveShortcut | `CM_Character::Event_RemoveShortCut` | β | H | B | B+W | InventoryActions builder dead [^a-1] |
+| 0x01A1 | outbound | SetCharacterOptions | β | β | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x01A8 | outbound | RemoveSpellC2S | `CM_Magic::Event_RemoveSpell` | β | H | β | B | Self-cancel buff |
+| 0x01B7 | outbound | CancelAttack | `CM_Combat::Event_CancelAttack` | W | H | W | B+W | Wired in WorldSession.SendCancelAttack |
+| 0x01BF | outbound | QueryHealth | `CM_Combat::Event_QueryHealth` | W | H | B | B+W | SocialActions builder dead [^a-1] |
+| 0x01C2 | outbound | QueryAge | `CM_Character::Event_QueryAge` | β | H | β | B | β |
+| 0x01C4 | outbound | QueryBirth | `CM_Character::Event_QueryBirth` | β | H | β | B | β |
+| 0x01DF | outbound | Emote | `CM_Communication::Event_Emote` | W | H | β | B | Custom /e text |
+| 0x01E1 | outbound | SoulEmote | `CM_Communication::Event_SoulEmote` | W | H | β | B | /soulemote |
+| 0x01E3 | outbound | AddSpellFavorite | `CM_Character::Event_AddSpellFavorite` | β | H | β | B | Spellbook pin |
+| 0x01E4 | outbound | RemoveSpellFavorite | `CM_Character::Event_RemoveSpellFavorite` | β | β | β | B | Spellbook unpin |
+| 0x01E9 | outbound | PingRequest | β | W | H | B | B+W | SocialActions builder dead; keepalive [^a-1] |
+| 0x01F6 | outbound | OpenTradeNegotiations | `CM_Trade::Event_OpenTradeNegotiations` | W | H | β | B | Begin trade |
+| 0x01F7 | outbound | CloseTradeNegotiations | `CM_Trade::Event_CloseTradeNegotiations` | W | H | β | B | Cancel trade |
+| 0x01F8 | outbound | AddToTrade | `CM_Trade::Event_AddToTrade` | W | H | β | B | Add item to trade |
+| 0x01FA | outbound | AcceptTrade | `CM_Trade::Event_AcceptTrade` | W | H | β | B | Confirm trade |
+| 0x01FB | outbound | DeclineTrade | `CM_Trade::Event_DeclineTrade` | W | H | β | B | Reject trade |
+| 0x0204 | outbound | ResetTrade | `CM_Trade::Event_ResetTrade` | W | H | β | B | Clear pending items |
+| 0x0216 | outbound | ClearPlayerConsentList | `CM_Character::Event_ClearPlayerConsentList` | β | H | β | B | Resurrection consent |
+| 0x0217 | outbound | DisplayPlayerConsentList | `CM_Character::Event_DisplayPlayerConsentList` | β | H | β | B | β |
+| 0x0218 | outbound | RemoveFromPlayerConsentList | `CM_Character::Event_RemoveFromPlayerConsentList` | β | β | β | B | β |
+| 0x0219 | outbound | AddPlayerPermission | `CM_Character::Event_AddPlayerPermission` | W | H | β | B | Storage / consent perm |
+| 0x021A | outbound | RemovePlayerPermission | `CM_Character::Event_RemovePlayerPermission` | W | H | β | B | β |
+| 0x021C | outbound | BuyHouse | `CM_House::Event_BuyHouse` | β | H | β | βdefer:Phase Q | Housing β out of M baseline scope |
+| 0x021E | outbound | HouseQuery | β | β | H | β | βdefer:Phase Q | Housing |
+| 0x021F | outbound | AbandonHouse | `CM_House::Event_AbandonHouse` | β | H | β | βdefer:Phase Q | Housing |
+| 0x0221 | outbound | RentHouse | `CM_House::Event_RentHouse` | β | β | β | βdefer:Phase Q | Housing |
+| 0x0224 | outbound | SetDesiredComponentLevel | β | β | β | β | B | Component-buy preference |
+| 0x0245 | outbound | AddPermanentGuest | `CM_House::Event_AddPermanentGuest_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x0246 | outbound | RemovePermanentGuest | `CM_House::Event_RemovePermanentGuest_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x0247 | outbound | SetOpenHouseStatus | `CM_House::Event_SetOpenHouseStatus_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x0249 | outbound | ChangeStoragePermission | `CM_House::Event_ChangeStoragePermission_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x024A | outbound | BootSpecificHouseGuest | `CM_House::Event_BootSpecificHouseGuest_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x024C | outbound | RemoveAllStoragePermission | `CM_House::Event_RemoveAllStoragePermission` | β | H | β | βdefer:Phase Q | Housing |
+| 0x024D | outbound | RequestFullGuestList | `CM_House::Event_RequestFullGuestList_Event` | β | β | β | βdefer:Phase Q | Housing |
+| 0x0254 | outbound | SetMotd | `CM_Allegiance::Event_SetMotd` | β | β | β | B | Allegiance message-of-the-day |
+| 0x0255 | outbound | QueryMotd | `CM_Allegiance::Event_QueryMotd` | β | β | β | B | β |
+| 0x0256 | outbound | ClearMotd | `CM_Allegiance::Event_ClearMotd` | β | H | β | B | β |
+| 0x0258 | outbound | QueryLord | `CM_House::Event_QueryLord` | β | β | β | βdefer:Phase Q | Housing |
+| 0x025C | outbound | AddAllStoragePermission | `CM_House::Event_AddAllStoragePermission` | β | β | β | βdefer:Phase Q | Housing |
+| 0x025E | outbound | RemoveAllPermanentGuests | `CM_House::Event_RemoveAllPermanentGuests_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x025F | outbound | BootEveryone | `CM_House::Event_BootEveryone_Event` | β | H | β | βdefer:Phase Q | Housing |
+| 0x0262 | outbound | TeleToHouse | `CM_House::Event_TeleToHouse_Event` | β | β | β | βdefer:Phase Q | Housing |
+| 0x0263 | outbound | QueryItemMana | `CM_Item::Event_QueryItemMana` | W | H | β | B | Mana-meter check |
+| 0x0266 | outbound | SetHooksVisibility | `CM_House::Event_SetHooksVisibility` | β | H | β | βdefer:Phase Q | Housing |
+| 0x0267 | outbound | ModifyAllegianceGuestPermission | `CM_House::Event_ModifyAllegianceGuestPermission` | β | β | β | βdefer:Phase Q | Housing |
+| 0x0268 | outbound | ModifyAllegianceStoragePermission | `CM_House::Event_ModifyAllegianceStoragePermission` | β | β | β | βdefer:Phase Q | Housing |
+| 0x0269 | outbound | ChessJoin | β | β | H | β | βskip:minigame | Chess |
+| 0x026A | outbound | ChessQuit | β | β | H | β | βskip:minigame | Chess |
+| 0x026B | outbound | ChessMove | β | β | H | β | βskip:minigame | Chess |
+| 0x026D | outbound | ChessMovePass | β | β | H | β | βskip:minigame | Chess |
+| 0x026E | outbound | ChessStalemate | β | β | H | β | βskip:minigame | Chess |
+| 0x0270 | outbound | ListAvailableHouses | `CM_House::Event_ListAvailableHouses` | β | β | β | βdefer:Phase Q | Housing |
+| 0x0275 | outbound | ConfirmationResponse | `CM_Character::Event_ConfirmationResponse` | W | H | β | B | Yes/No popups |
+| 0x0277 | outbound | BreakAllegianceBoot | `CM_Allegiance::Event_BreakAllegianceBoot` | β | H | β | B | Officer kick |
+| 0x0278 | outbound | TeleToMansion | `CM_House::Event_TeleToMansion_Event` | W | β | β | βdefer:Phase Q | Housing recall |
+| 0x0279 | outbound | Suicide | `CM_Character::Event_Suicide` | W | β | β | B | /suicide cmd |
+| 0x027B | outbound | AllegianceInfoRequest | `CM_Allegiance::Event_AllegianceInfoRequest` | β | H | β | B | Tree info |
+| 0x027D | outbound | CreateTinkeringTool / SalvageItemsWith | `CM_Inventory::Event_CreateTinkeringTool` | W | H | β | B | Salvage UI [^a-6] |
+| 0x0286 | outbound | SpellbookFilter | `CM_Character::Event_SpellbookFilterEvent` | β | β | β | B | School filter |
+| 0x028D | outbound | TeleToMarketPlace | β | W | β | β | B | MP recall |
+| 0x028F | outbound | EnterPkLite | β | W | β | β | B | PK-lite toggle |
+| 0x0290 | outbound | FellowshipAssignNewLeader | `CM_Fellowship::Event_AssignNewLeader` | W | H | β | B | β |
+| 0x0291 | outbound | FellowshipChangeOpenness | `CM_Fellowship::Event_ChangeFellowOpeness` | β | H | β | B | β |
+| 0x02A0 | outbound | AllegianceChatBoot | `CM_Allegiance::Event_AllegianceChatBoot` | β | β | β | B | Officer chat-mute |
+| 0x02A1 | outbound | AddAllegianceBan | `CM_Allegiance::Event_AddAllegianceBan` | β | H | β | B | β |
+| 0x02A2 | outbound | RemoveAllegianceBan | `CM_Allegiance::Event_RemoveAllegianceBan` | β | β | β | B | β |
+| 0x02A3 | outbound | ListAllegianceBans | `CM_Allegiance::Event_ListAllegianceBans` | β | β | β | B | β |
+| 0x02A5 | outbound | RemoveAllegianceOfficer | `CM_Allegiance::Event_RemoveAllegianceOfficer` | β | H | β | B | β |
+| 0x02A6 | outbound | ListAllegianceOfficers | `CM_Allegiance::Event_ListAllegianceOfficers` | β | β | β | B | β |
+| 0x02A7 | outbound | ClearAllegianceOfficers | `CM_Allegiance::Event_ClearAllegianceOfficers` | β | β | β | B | β |
+| 0x02AB | outbound | RecallAllegianceHometown | `CM_Allegiance::Event_RecallAllegianceHometown` | β | β | β | B | Bind to monarch lifestone |
+| 0x02AF | outbound | QueryPluginListResponse | `CM_Admin::Event_QueryPluginListResponse` | β | β | β | βskip:plugin-c2s | Decal-era plugin probe |
+| 0x02B2 | outbound | QueryPluginResponse | `CM_Admin::Event_QueryPluginResponse` | β | β | β | βskip:plugin-c2s | Decal-era plugin probe |
+| 0x0311 | outbound | FinishBarber | `CM_Character::Event_FinishBarber` | β | H | β | B | Char appearance commit |
+| 0x0316 | outbound | AbandonContract | `CM_Social::Event_AbandonContract` | β | H | β | B | Drop quest |
+
+**Footnotes:**
+
+[^a-1]: "Builder dead" = the byte-array builder is implemented in `src/AcDream.Core.Net/Messages/.cs` but no caller in `src/AcDream.App/` or a `WorldSession.Send*` wrapper invokes it. Phase M wires these to game-state actions (UI clicks, command bus, key bindings) and adds golden-vector tests against holtburger fixtures.
+[^a-2]: ACE's wire field order for Tell is `message FIRST then target` (see `ChatRequests.BuildTell` doc comment). Sept-2013 PDB has no `Event_Tell` symbol β it routes through `CM_Communication::Event_TalkDirectByName` plus a server-side rename.
+[^a-3]: TeleToPoi (0x00B1) is listed in `InventoryActions.cs` but not in ACE's `GameActionType` enum. Cross-reference holtburger to confirm; may be a dead-letter opcode that retail's vendored 2013 ACE branch dropped. Verify before shipping the test vector.
+[^a-4]: AddChannel (0x0145) β named-retail's matching symbol is `Event_ChannelList` (0x0148 according to retail enum), so the symbol mapping is approximate; AddChannel in pseudo-C may be unsymbolicated. Confirm by greping `acclient_2013_pseudo_c.txt` before publishing.
+[^a-5]: 0x0147 ChannelBroadcast is the same numeric code in both directions (outbound GameAction = client sends to channel; inbound GameEvent = server broadcasts to channel members). Listed under outbound here per Section-5 scope; inbound version is in Β§4.
+[^a-6]: ACE GameActionType lists 0x027D as `CreateTinkeringTool`; holtburger names the same opcode `SalvageItemsWith`. Both behaviors funnel through the salvage UI in retail. Either name is acceptable in acdream; pick one and leave the other as an alias constant.
+
+---
+
+## Source attribution
+
+- **Holtburger** β `references/holtburger/` at `629695a` (2026-05-10). Primary client-behavior oracle.
+- **ACE** β `references/ACE/Source/ACE.Server/Network/`. Server-side authority for GameMessages, GameEvents, GameActions, and accept rules.
+- **Named retail decomp** β `docs/research/named-retail/` (Sept 2013 EoR PDB + Binary Ninja pseudo-C). Wire-format ground truth for the 2013 client.
+- **acdream current state** β `src/AcDream.Core.Net/` and `src/AcDream.App/`. Inventoried by parallel agents on 2026-05-10.
+
+## Caveats
+
+This is the **initial population**, produced by four parallel research agents (one per opcode class) on 2026-05-10. Spot-check pass + intentional-divergence ratification is owed before M.1 closes. Specifically:
+
+- A handful of named-retail symbol citations are tentative (marked in footnotes); spot-check by greping `acclient_2013_pseudo_c.txt` and `symbols.json`.
+- Holtburger / ACE / acdream cells were determined by reading the actual code (not guessing); when an agent couldn't determine a value, it used `?`. The `?` cells need a follow-up read.
+- "Dead builder" calls (rows where acdream `B` but Phase M target is `B+W`) are based on a grep for `WorldSession.Send*` patterns and `worldSession.Send` calls in `src/AcDream.App/`. Edge cases (call sites in test code, command-bus indirection) may have been missed.
+- Total opcode count in scope (~284) is approximate; deduplication of cross-section codes (e.g., 0x0147 in Β§4 and Β§5) is tracked in footnotes but the headline count treats them as distinct rows.
+
+This matrix lives on as a long-term reference. Phase M.6 implementation tracks progress against it; gameplay phases consuming Phase M will reference the rows they wire as part of their phase acceptance.
diff --git a/docs/research/2026-05-10-post-a5-polish-handoff.md b/docs/research/2026-05-10-post-a5-polish-handoff.md
new file mode 100644
index 0000000..cb52b99
--- /dev/null
+++ b/docs/research/2026-05-10-post-a5-polish-handoff.md
@@ -0,0 +1,307 @@
+# Phase Post-A.5 Polish β Cold-Start Handoff
+
+**Created:** 2026-05-10, immediately after A.5 SHIP + merge to main (`d3d78fa`).
+**Audience:** the next agent picking up post-A.5 polish work.
+**Purpose:** give you everything you need to start the polish phase cold, without spelunking through the A.5 session's 200+ messages.
+
+---
+
+## TL;DR
+
+A.5 just shipped. Two-tier streaming is live (Nβ=4 near, Nβ=12 far) with a 2.3 km fog horizon, off-thread mesh build, entity dispatcher tightening, mipmaps + 16x AF, MSAA 4x + A2C foliage, depth-write audit, BUDGET_OVER diag, and a full Quality Preset system (Low/Medium/High/Ultra) with env-var overrides + F11 mid-session re-apply.
+
+**A.5 was an enormous phase** (29 numbered tasks + T22.5 mid-execution scope add + Bug A + Bug B post-T26 fixes). Spec at `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md` (~700 lines). Plan at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md` (~2400 lines).
+
+**Three things were intentionally deferred to this phase:**
+
+1. **Lifestone visual missing (ISSUE #52).** The Holtburg lifestone β a known visual landmark β hasn't been rendering since earlier in A.5 development. User confirmed they noticed it earlier but didn't flag it; deferred to post-ship. **Highest user-perception value to fix.**
+
+2. **JobKind plumbing through `BuildLandblockForStreaming` (ISSUE #54).** Bug A's fix patches at the worker output by stripping entities from far-tier `LoadedLandblock`s after the full load runs. The worker still wastes CPU on hydration + scenery generation that gets thrown away. Cleaner fix: make the worker SKIP that work for far-tier loads. ~30 min - 1 hour. **Smallest cleanup, biggest worker-thread efficiency win.**
+
+3. **Tier 1 entity-classification cache retry (ISSUE #53).** First attempt (commit `3639a6f`, reverted at `9b49009`) cached `meshRef.PartTransform` which is mutated per frame for animated entities β froze animations. Retry needs a careful read of `AnimationSequencer` + `AnimationHookRouter` first to map ALL the per-frame mutations of MeshRef state, then design a cache that bypasses animated entities OR caches only the animation-invariant subset. **Biggest perf headroom available** β math says it should drop the entity dispatcher from 3.5ms to 1-1.5ms, hitting the spec's 2.0ms budget.
+
+The phase is sized ~1 week if all three land cleanly. Could be longer if Tier 1's animation audit reveals something subtle.
+
+---
+
+## Where A.5 left things
+
+### Branch state
+
+- `main` is at `d3d78fa` ("Merge branch 'claude/hopeful-darwin-ae8b87' β Phase A.5 SHIP + Quality Preset system").
+- A.5 SHIP commit at `9245db5` (one commit before the merge bubble).
+- Roadmap entry: A.5 moved from "Phases ahead" β "Phases already shipped" table.
+- CLAUDE.md "Currently in flight" updated to "Post-A.5 polish β Tier 1 retry + lifestone fix + JobKind plumbing".
+
+### What works in A.5 (final post-fix state)
+
+- **Two-tier streaming end-to-end:** `StreamingRegion` with `RecenterTo` returning a 5-list `TwoTierDiff` (ToLoadFar/ToLoadNear/ToPromote/ToDemote/ToUnload) with hysteresis radius+2 on both tiers; `StreamingController.Tick` routes by `LandblockStreamJobKind`; `LandblockStreamer` worker thread does dat reads + mesh build off the render thread.
+- **Bug A fixed:** `LandblockStreamer.HandleJob` strips entities for `LoadFar` results before posting Loaded. Far-tier ships terrain only as the spec promised.
+- **Bug B fixed:** `WalkEntities` uses `_walkScratch` field reused across frames, no per-frame List allocation.
+- **Quality Preset system:** Low / Medium / High / Ultra presets with per-preset radii + MSAA + anisotropic + A2C + max-completions. 6 env-var overrides per field. F11 β Display tab dropdown for mid-session change. `DisplaySettings.Quality` persists in settings.json. `GameWindow.ReapplyQualityPreset` rebuilds the streaming pipeline for radius changes.
+- **Visual quality stack:** mipmaps + 16x anisotropic on TerrainAtlas. MSAA 4x + alpha-to-coverage on foliage shader. Depth-write audit + lock-in test (5 cases).
+- **Fog horizon:** FogStart = Nβ Γ 192m Γ 0.7 β 538m. FogEnd = Nβ Γ 192m Γ 0.95 β 2188m. Tunable via `ACDREAM_FOG_START_MULT` / `ACDREAM_FOG_END_MULT`.
+- **DIAG:** `[WB-DIAG]` and `[TERRAIN-DIAG]` flag `BUDGET_OVER` when median exceeds the per-subsystem spec budget (entity 2.0ms, terrain 1.0ms).
+
+### Final perf state at A.5 SHIP (horizon-safe Quality preset)
+
+User hardware: AMD Radeon RX 9070 XT, 240 Hz @ 2560Γ1440.
+
+Settings tested: `NEAR_RADIUS=4, FAR_RADIUS=12, MSAA=0, A2C=0, ANISOTROPIC=4, MAX_COMPLETIONS=2`.
+
+| Subsystem | cpu_us median | cpu_us p95 |
+|---|---|---|
+| Entity dispatcher | ~3500 Β΅s (3.5 ms) | ~4000 Β΅s |
+| Terrain dispatcher | ~21 Β΅s | ~26 Β΅s |
+
+Total frame time math: ~4-5 ms = ~200-240 FPS at standstill. User reported "Better now" β not the 240Hz spec target but a 5Γ improvement from the broken pre-Bug-A state (~40 FPS).
+
+The 1.5ms gap to the 2.0ms entity dispatcher budget is what Tier 1 closes (per ISSUE #53 + the perf-tier roadmap).
+
+### What was NOT validated at SHIP
+
+- **Full High preset (radius=4/12, MSAA 4x, A2C on, anisotropic 16x).** Crashed the entire OS at first attempt earlier in A.5 development. Bug A was likely the trigger (CPU dispatcher saturating + GPU command queue overflowing). With Bug A fixed, this likely works β but never re-tested. **Re-testing is part of this phase's stretch goal.**
+- **Visual gate at full quality.** Same β only validated at horizon-safe settings.
+- **Walking trace at any preset.** Brief walking observed but not metric-captured.
+
+### Three high-value gotchas captured in A.5 memory
+
+These are at `~/.claude/projects/.../memory/project_phase_a5_state.md`:
+
+1. **Worker-side JobKind routing was the load-bearing far-tier optimization.** T13/T16 wired the controller side; the worker never branched on Kind. ~5x perf regression that wasn't caught by spec/code reviews.
+2. **WalkEntities's "extract a list-producing helper" pattern is a perf antipattern.** ~480 KB / frame allocation. Implementer flagged "future N.6 optimization" in self-review; review should have caught that "future" was actually "now."
+3. **Caching mutable per-frame state silently breaks animation.** Tier 1's first attempt. The "trust MeshRefs as the source of truth" comment in the dispatcher is true but misleading β MeshRefs IS the source of truth, but it's mutated EVERY frame for animated entities.
+
+(Full memory entry has 5 gotchas; these three are the load-bearing ones for post-A.5.)
+
+---
+
+## Files to read before brainstorming
+
+In rough order:
+
+1. **`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`** β A.5 spec, full design rationale + Quality Preset system (Β§4.10) + acceptance criteria reshape (Β§2). Skim for vocabulary; read Β§4.10 in full.
+2. **`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`** β Tier 2 (static/dynamic split) + Tier 3 (GPU compute culling) roadmap. Read for context on where Tier 1 fits in the perf optimization tower.
+3. **`docs/ISSUES.md` issues #52, #53, #54** β the three deferred items in tactical-list form.
+4. **`memory/project_phase_a5_state.md`** β the 5 gotchas. Critical for avoiding the same traps in this phase.
+5. **`src/AcDream.App/Streaming/LandblockStreamer.cs`** β `HandleJob` is where Bug A's patch lives + where ISSUE #54's cleaner fix will go.
+6. **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** β `WalkEntities` + `Draw`'s inner loop. Where Tier 1's retry will operate.
+7. **`src/AcDream.Core/Physics/AnimationSequencer.cs`** β the per-frame animation engine. Read this BEFORE designing Tier 1's retry. Pay specific attention to anywhere it touches `meshRef.PartTransform` or any other field that the dispatcher reads.
+8. **`src/AcDream.App/Animation/AnimationHookRouter.cs`** (or similar) β the hook fan-out from animation events. Same audit reason as #7.
+
+---
+
+## Per-priority detail
+
+### Priority 1 β Lifestone missing (ISSUE #52)
+
+**Estimated effort:** 1-3 hours. Could be a 1-line fix or could surface a deeper issue.
+
+The Holtburg lifestone is a Setup-multi-part entity (the spinning blue crystal pillar). User reports it hasn't been rendering since earlier in A.5 development. They noticed but didn't flag during the session.
+
+Hypotheses:
+
+- **Bug A's strip caught a near-tier entity.** The current strip in `LandblockStreamer.HandleJob` only fires when `tier == LandblockStreamTier.Far`. Holtburg's lifestone is in a near-tier LB (Holtburg's center, ~LB 0xA9B4). Should NOT have been stripped. But verify β maybe the LB's tier resolution at first-tick is wrong.
+- **Earlier visual regression from a different commit.** User said it was missing in earlier runs too. Could be from N.5b, an N.5b follow-up, or even older. Requires a `git log -- docs/ISSUES.md` correlation with visible state.
+- **Setup-rendering edge case.** The lifestone has unusual properties (animated rotation, particle effects on top). Maybe it's a Setup with some sub-mesh that the dispatcher's `SetupParts` walk filters out.
+- **Dat-state mismatch.** The lifestone's GfxObj id might be in a part of the dat that's failing decode.
+
+**Investigation steps:**
+
+1. Launch the client + walk to Holtburg lifestone position.
+2. Check `[WB-DIAG]` for `meshMissing` count β if non-zero, some entity's mesh isn't loading.
+3. Use the cdb attach toolchain (per CLAUDE.md "Retail debugger toolchain") if needed to compare vs retail's lifestone rendering.
+4. Compare to ACViewer / WorldBuilder to see if the lifestone renders there. If yes, our renderer has a regression. If no, the issue is dat-side or in shared decode logic.
+5. Identify the GfxObj/Setup id for the lifestone (likely well-known retail ID; check `docs/research/named-retail/` or ACViewer reference).
+6. Trace: does `_meshAdapter.TryGetRenderData(lifestoneId)` return non-null? Does the resulting `renderData.Batches` have entries?
+
+**Acceptance:** lifestone renders correctly (visible spinning blue crystal at the Holtburg town center).
+
+### Priority 2 β JobKind plumbing through `BuildLandblockForStreaming` (ISSUE #54)
+
+**Estimated effort:** 30 min - 1 hour.
+
+Currently `LandblockStreamer.HandleJob` strips entities POST-load for far-tier:
+
+```csharp
+case LandblockStreamJob.Load load:
+ var lb = _loadLandblock(load.LandblockId); // full load
+ var mesh = _buildMeshOrNull(load.LandblockId, lb);
+ var tier = load.Kind == LandblockStreamJobKind.LoadFar ? Far : Near;
+ if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
+ {
+ // Strip entities β far-tier ships terrain only.
+ lb = new LoadedLandblock(...empty entities...);
+ }
+ _outbox.Writer.TryWrite(new Loaded(... lb, mesh ...));
+ break;
+```
+
+The full `_loadLandblock` does:
+1. Read `LandBlock` heightmap (cheap).
+2. Read `LandBlockInfo` (medium).
+3. `LandblockLoader.BuildEntitiesFromInfo` (extract stabs/buildings).
+4. Hydrate stab/building meshRefs (medium).
+5. Run scenery generation (heavy β ~50-200 procedural entities Γ meshRef hydration).
+6. Build interior cell entities.
+
+For far-tier, only step 1 is needed. Steps 2-6 are wasted CPU on the worker thread.
+
+**Refactor plan:**
+
+1. Change the streamer's `_loadLandblock` factory to take `LandblockStreamJobKind`:
+ ```csharp
+ private readonly Func _loadLandblock;
+ ```
+2. In `GameWindow`, the factory closure branches:
+ ```csharp
+ loadLandblock: (id, kind) => kind == LandblockStreamJobKind.LoadFar
+ ? BuildLandblockHeightmapOnly(id)
+ : BuildLandblockForStreaming(id),
+ ```
+3. New `BuildLandblockHeightmapOnly` returns a `LoadedLandblock` with the heightmap dat record + empty entity list. Cheap β no LandBlockInfo read, no scenery generation.
+4. Remove the post-load strip in `HandleJob` (no longer needed).
+5. Worker-thread CPU drops measurably; horizon fill on first traversal speeds up.
+
+**Acceptance:**
+- Build green; existing 999+ tests pass.
+- Streaming worker thread is measurably faster on first-traversal (the user can validate with `[WB-DIAG]` worker queue depth or just feel the responsiveness when walking into virgin region).
+- Visible behavior unchanged β far tier looks the same as before.
+
+### Priority 3 β Tier 1 entity-classification cache retry (ISSUE #53)
+
+**Estimated effort:** ~5-7 days. Substantial because the audit step is critical.
+
+This is the BIG perf win remaining for A.5's CPU dispatcher. Math says entity dispatcher 3.5ms β 1-1.5ms = ~300-400 FPS at standstill. Drops the dispatcher inside the spec's 2.0ms budget.
+
+**The first attempt's failure (commit 3639a6f, reverted at 9b49009):**
+
+Cached `meshRef.PartTransform` baked into per-(entity, batch) classification at first-frame visit. For static entities, this is stable forever. For animated entities, `meshRef.PartTransform` is updated EVERY FRAME by `AnimationSequencer` to apply the current skeletal pose. The cache froze the pose.
+
+User-visible symptoms:
+- NPCs / players stop animating.
+- Some buildings (likely those mistakenly in `animatedEntityIds`) draw at wrong positions.
+
+**The retry's audit step (do this BEFORE designing the cache):**
+
+Read `src/AcDream.Core/Physics/AnimationSequencer.cs` and trace EVERY assignment to `meshRef.PartTransform` (and any other field on `MeshRef`, `WorldEntity`, or related state that the dispatcher reads). Likely write sites:
+- `AnimationSequencer.TickAnimations` per-frame skeletal pose update
+- `AnimationHookRouter` for hooks like `AnimSetPose`
+- Live network handlers that mutate `entity.Position` / `entity.Rotation` (T18 already migrated these to `SetPosition` for AABB invalidation; double-check)
+- `EntitySpawnAdapter` for ObjDescEvent / palette swap
+
+For each write site, decide: is this entity STATIC (write only at spawn) or DYNAMIC (write per-frame or in response to network events)?
+
+**Cache design options after the audit:**
+
+(a) **Static-only cache.** Only cache entities where `animatedEntityIds.Contains(entity.Id) == false`. Animated entities use today's per-frame classification path. Cleanest, but requires `animatedEntityIds` to be a stable signal (it is β `_animatedEntities` dict in GameWindow is the source).
+
+(b) **Dynamic-aware cache with invalidation hooks.** Cache everything but expose `InvalidateEntity(uint)` / `RefreshEntityPalette(uint)` for the dispatcher's invalidation. Wire from the network layer (palette swap fires invalidation; ObjDesc event fires invalidation). More complex but might let animated entities also benefit.
+
+(c) **Static-only + animated-bypass + diagnostic check.** Like (a), but in DEBUG builds, log a warning every frame if a cached entity's `meshRef.PartTransform` differs from the cached value (catches mis-classified dynamics). Belt-and-suspenders.
+
+Recommendation: start with (a). Ship Tier 1 for static entities only. Animated path stays slow but correct. If perf gate finds the static-only Tier 1 isn't enough, escalate to (c) for safety + (b) later.
+
+**Acceptance:**
+- Build green; existing 999+ tests pass.
+- 1-3 new tests covering: cache hit for static entity, cache bypass for animated entity, cache invalidation on entity remove.
+- Visual gate: launch + walk Holtburg β North Yanshi at horizon-safe preset; confirm:
+ - Animation works (NPCs, player character animate normally)
+ - Buildings at correct positions
+ - Lifestone (still depending on Priority 1 fix) renders correctly
+ - No new visual regressions
+- Perf gate (with `[WB-DIAG]`):
+ - Entity dispatcher cpu_us median drops from ~3.5ms to β€2.0ms (matches spec budget).
+ - p95 stays β€ 2.5ms.
+
+---
+
+## What's NOT in this phase
+
+- **Tier 2 (static/dynamic split with persistent groups).** Separate ~2-week phase. See `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`.
+- **Tier 3 (GPU compute culling).** Separate ~1-month phase. Same roadmap.
+- **Full High preset crash investigation beyond casual retest.** Stretch goal: re-test the High preset with Bug A + B fixed, see if it's stable now. If it crashes, file a new issue and continue. Don't deep-dive in this phase.
+- **EnvCell modern path migration, Sky/Particles modern path, Shadow mapping** β all later phases.
+- **N.6 perf polish (the previously-flagged "next phase").** N.6 was the original CLAUDE.md "Currently in flight" target before A.5. Most of N.6's scope was rolled into A.5 (perf-tier work). What's left of N.6 (persistent-mapped indirect buffer, GPU-side culling) overlaps with Tier 2/3 and should be re-scoped after Tier 1 lands.
+
+---
+
+## Acceptance criteria (whole phase)
+
+- All three priorities (Lifestone, JobKind, Tier 1) shipped or one is explicitly deferred with documented reasoning.
+- Build green throughout. ~999+ tests pass; 8 pre-existing physics/input failures stay at 8.
+- N.5b conformance sentinel intact (TerrainSlot, TerrainModernConformance, Wb*, MatrixComposition, TextureCacheBindless, SplitFormulaDivergence β all clean).
+- Visual gate: lifestone renders; animation works; horizon visible at ~2.3km; smooth walking trace; no new artifacts.
+- Perf gate (post-Tier-1): entity dispatcher cpu_us median β€ 2.0ms at horizon-safe preset, ~250-300 FPS at standstill.
+- Memory entry written + roadmap "shipped" row updated for the polish phase.
+
+---
+
+## What you'll be doing in the first 30 minutes
+
+1. Read this handoff in full.
+2. Verify build green: `dotnet build`. Verify ~999 tests pass: `dotnet test --no-build`.
+3. Read `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md` Β§2, Β§4.10, Β§11 (deferred section).
+4. Read `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` Tier 1 section.
+5. Read `docs/ISSUES.md` issues #52, #53, #54 in full.
+6. Read `memory/project_phase_a5_state.md` (5 gotchas).
+7. Read `src/AcDream.App/Streaming/LandblockStreamer.cs` HandleJob method.
+8. Read `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` Draw + WalkEntitiesInto methods.
+9. Skim `src/AcDream.Core/Physics/AnimationSequencer.cs` for write-sites of `meshRef.PartTransform` (Tier 1 retry's audit prerequisite).
+10. Decide: which priority to start with? Recommendation order: 1 (lifestone, fast win), 2 (JobKind, easy cleanup), 3 (Tier 1, biggest perf win + most complex).
+11. Brainstorm with the user on the chosen priority before writing code.
+12. Write a small spec or just the implementation if the priority is small (1 + 2 are small enough to skip a formal spec). Tier 1 (priority 3) needs a spec because of the audit + invalidation design.
+
+Don't skip the audit step on Tier 1. The first attempt failed because of an incomplete read of the animation mutation graph; the second attempt should not repeat that.
+
+---
+
+## Things to NOT do
+
+- **Don't rush Tier 1.** Audit first. Write down which entities are static vs dynamic. Write tests that specifically verify animated entities still animate after caching is enabled.
+- **Don't bundle Tier 2 or Tier 3 into this phase.** Those are dedicated multi-week phases with their own brainstorm + spec + plan cycles.
+- **Don't break the N.5b conformance sentinel.** Run the filter on every commit:
+ ```
+ dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
+ ```
+ Expect 89+ passing, 0 failures.
+- **Don't skip the visual gate.** Lifestone fix specifically requires looking at the lifestone in-game. Tier 1 retry requires confirming animation works on a moving NPC.
+- **Don't delete the `_walkScratch` field** added in Bug B fix. It's load-bearing β without it, Tier 1 retry would re-introduce the per-frame allocation bug.
+- **Don't re-add the `Tier1` cache that was reverted.** Start the retry with a fresh design after the animation audit. Cherry-picking the reverted code will re-introduce the bug.
+
+---
+
+## Reference: A.5's commit chain
+
+Final A.5 commit chain on `claude/hopeful-darwin-ae8b87` (merged into main at `d3d78fa`):
+
+| SHA | Subject |
+|---|---|
+| 9245db5 | phase(A.5): SHIP β two-tier streaming + horizon LOD + Quality Preset system |
+| d93d823 | docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship |
+| a28a5b7 | docs(A.5 T27): spec + plan amendments for T22.5 + ship |
+| 9b49009 | Revert "feat(perf): Tier 1 entity classification cache" |
+| 3639a6f | feat(perf): Tier 1 entity classification cache (REVERTED) |
+| 462f9d6 | docs(perf): roadmap for Tier 2 + Tier 3 entity-dispatcher optimizations |
+| 0ad8c99 | fix(A.5): WalkEntities scratch-list pattern (Bug B β T17 GC pressure) |
+| 9217fd9 | fix(A.5): strip far-tier entities in worker (Bug A β far tier optimization) |
+| 28d2c60 | feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2) |
+| afa4200 | feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2) |
+| c473fee | feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] / [TERRAIN-DIAG] |
+| 3b684db | feat(A.5 T22): fog wired from Nβ/Nβ + ACDREAM_FOG_*_MULT env vars |
+| 1488ec6 | test(A.5 T21): lock in depth-write attribution per translucency kind |
+| 26b2871 | feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage |
+| 4b84e56 | feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas |
+| (...60+ commits earlier in the chain through T1-T18) | (see full log on the merge bubble) |
+
+The merge bubble preserves the full chain. To inspect any A.5 commit:
+```
+git log d3d78fa^..d3d78fa
+git show
+```
+
+---
+
+Good luck. The phase is well-bounded; the audit step on Tier 1 is the single highest-leverage thing to invest in. The lifestone and JobKind cleanup should be quick wins. After this phase ships, the project is in a great position β A.5 + polish + Tier 2/3 roadmap covers the rendering + perf work for the next several months.
+
+Holler at the user if any of the three priorities reveals scope you didn't expect.
diff --git a/docs/research/2026-05-10-tier1-mutation-audit.md b/docs/research/2026-05-10-tier1-mutation-audit.md
new file mode 100644
index 0000000..f206bf4
--- /dev/null
+++ b/docs/research/2026-05-10-tier1-mutation-audit.md
@@ -0,0 +1,246 @@
+# Tier 1 entity-classification cache β mutation audit
+
+**Created:** 2026-05-10, opening move of the ISSUE #53 retry session.
+**Purpose:** enumerate every code path that writes to `WorldEntity.MeshRefs` (the dispatcher's load-bearing per-entity input) and every adjacent state read by `WbDrawDispatcher.ClassifyBatches` / model-matrix composition, classify each as STATIC or DYNAMIC, and design the cache invalidation surface BEFORE touching renderer code.
+
+This audit is the load-bearing prerequisite the prior Tier 1 attempt (commit `3639a6f`, reverted at `9b49009`) skipped. Cache design follows from the audit, not the other way around.
+
+---
+
+## TL;DR β the invariant
+
+> **An entity's `MeshRefs` reference, `Position`, `Rotation`, `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, and `Scale` are stable from spawn to despawn IF AND ONLY IF the entity is NOT in `GameWindow._animatedEntities`.**
+
+That is the invariant the cache rides on. Animated entities (player + remote NPCs/players + animated dat scenery like the lifestone crystal) get a fresh `MeshRefs` list every frame from `TickAnimations` plus per-frame `Position`/`Rotation` writes from physics/dead-reckoning. Everything else β stabs, scenery, cell-mesh entities, interior static objects β touches none of those fields after construction.
+
+The cache should hold per-entity classification ONLY for entities whose `Id` is not in `_animatedEntities` at lookup time. Animated entities go through today's per-frame classification path unchanged.
+
+---
+
+## Β§1. `entity.MeshRefs = ...` write sites (the core question)
+
+`WorldEntity.MeshRefs` is `IReadOnlyList` with a `set` accessor (see [src/AcDream.Core/World/WorldEntity.cs:28](../../src/AcDream.Core/World/WorldEntity.cs#L28)). `MeshRef` itself is a `readonly record struct` ([src/AcDream.Core/World/MeshRef.cs:15](../../src/AcDream.Core/World/MeshRef.cs#L15)) β its fields cannot be mutated in place. So every "MeshRefs change" is a whole-list replacement, not a per-element edit.
+
+Six write sites total in `src/`. Five STATIC, one DYNAMIC.
+
+### Site 1 β `OnLiveEntitySpawnedLocked` (server-spawned entity hydration)
+
+**[src/AcDream.App/Rendering/GameWindow.cs:2578](../../src/AcDream.App/Rendering/GameWindow.cs#L2578)** β `MeshRefs = meshRefs` in the `WorldEntity { β¦ }` constructor.
+
+**Classification:** **STATIC** at first spawn.
+
+**Trigger:** server's `0xF745 CreateObject` for any entity (NPC, monster, player, item, statue, lifestone). Also re-runs from `OnLiveAppearanceUpdated` (server's `0xF625 ObjDescEvent`) β spawn dedup at top of `OnLiveEntitySpawnedLocked` invokes `RemoveLiveEntityByServerGuid`, then re-spawns. Each invocation gets a NEW local `entity.Id` from `_liveEntityIdCounter++` (line 2573).
+
+**Implication for cache:** ObjDescEvent isn't a "mutate existing entity" event β it's a despawn+respawn pair. The despawn path (next subsection) clears the cache for the old Id; the respawn populates fresh under the new Id. The cache never sees a stale entry for a still-active Id from this path.
+
+**Pre-construction `parts[β¦]` mutations** at lines 2333 and 2365 (AnimPartChanges + DIDDegrade resolver) edit the *local* `parts` list before it becomes the `meshRefs` argument; they're not separate write sites.
+
+### Site 2 β `BuildLandblockForStreaming` (stab hydration)
+
+**[src/AcDream.App/Rendering/GameWindow.cs:4748](../../src/AcDream.App/Rendering/GameWindow.cs#L4748)** β `MeshRefs = meshRefs` constructing dat-stab entities.
+
+**Classification:** **STATIC** at hydration. Worker-thread only.
+
+**Trigger:** streaming worker's near-tier load path (`LandblockStreamJobKind.LoadNear` or `PromoteToNear`). Single-GfxObj stabs use `Matrix4x4.Identity`; multi-part Setups go through `SetupMesh.Flatten` to produce per-part MeshRefs.
+
+**Lifetime:** lives until the entity's owning landblock is demoted (NearβFar) or unloaded β see Site invalidation Β§3.2.
+
+### Site 3 β `BuildSceneryEntitiesForStreaming` (procedural scenery)
+
+**[src/AcDream.App/Rendering/GameWindow.cs:4951](../../src/AcDream.App/Rendering/GameWindow.cs#L4951)** β `MeshRefs = meshRefs` for trees / rocks / bushes / fences.
+
+**Classification:** **STATIC** at hydration. Worker-thread only.
+
+**Lifetime:** identical to Site 2.
+
+### Site 4 β Interior cell-mesh entity
+
+**[src/AcDream.App/Rendering/GameWindow.cs:5023](../../src/AcDream.App/Rendering/GameWindow.cs#L5023)** β `MeshRefs = new[] { cellMeshRef }` for the EnvCell's own room geometry as a renderable entity.
+
+**Classification:** **STATIC** at hydration.
+
+### Site 5 β Interior static-object entity
+
+**[src/AcDream.App/Rendering/GameWindow.cs:5083](../../src/AcDream.App/Rendering/GameWindow.cs#L5083)** β `MeshRefs = meshRefs` for static objects placed inside an EnvCell (furniture, fixtures).
+
+**Classification:** **STATIC** at hydration.
+
+### Site 6 β `TickAnimations` per-frame rebuild
+
+**[src/AcDream.App/Rendering/GameWindow.cs:7580](../../src/AcDream.App/Rendering/GameWindow.cs#L7580)** β `ae.Entity.MeshRefs = newMeshRefs;` after constructing a fresh `List(partCount)` at line 7501 from `sequencer.Advance(dt)` output.
+
+**Classification:** **DYNAMIC** every frame.
+
+**Trigger:** per-frame iteration over `_animatedEntities.Values` inside `TickAnimations`. If `entity.Id β _animatedEntities`, this loop runs for that entity every frame (subject to motion-table presence). If `entity.Id β _animatedEntities`, this loop never runs for it.
+
+**Consequence:** any cache that captures `entity.MeshRefs[i].PartTransform` for an entity in `_animatedEntities` will freeze the pose. **This is exactly what the prior Tier 1 attempt did.**
+
+---
+
+## Β§2. `_animatedEntities` membership transitions
+
+`_animatedEntities` at [GameWindow.cs:160](../../src/AcDream.App/Rendering/GameWindow.cs#L160) is the gating dict. The cache's "static" predicate is `! _animatedEntities.ContainsKey(entity.Id)`.
+
+### Population
+
+- **[GameWindow.cs:2724](../../src/AcDream.App/Rendering/GameWindow.cs#L2724)** β `_animatedEntities[entity.Id] = new AnimatedEntity { β¦ }` at server-spawn for entities with a non-empty motion table + a resolvable idle cycle.
+- **[GameWindow.cs:7685](../../src/AcDream.App/Rendering/GameWindow.cs#L7685)** β `_animatedEntities[pe.Id] = ae;` in `UpdatePlayerAnimation` to *re-add* the local player entity if a prior `UpdateMotion` removed it (the "Phase 6.8 stationary remove" pattern). This is the only path that can flip an entity from STATIC to ANIMATED mid-life.
+
+### Removal
+
+- **[GameWindow.cs:2935](../../src/AcDream.App/Rendering/GameWindow.cs#L2935)** β `_animatedEntities.Remove(existingEntity.Id)` inside `RemoveLiveEntityByServerGuid`. Fires for `0xF747 DeleteObject` and as the dedup leg of `OnLiveAppearanceUpdated`.
+
+### Cache implication
+
+Membership IS NOT cached by the dispatcher. The cache lookup checks `_animatedEntities.ContainsKey(entity.Id)` at lookup time. If the player flips STATICβANIMATED mid-session (Site 7685 above), a stale cache entry would still exist for the player Id but never be read; the next despawn (Site 2935) clears it. No special-casing needed.
+
+The reverse flip (ANIMATEDβSTATIC, e.g. a ground-state demote) leaves no cache entry; the dispatcher takes the cache-miss path on the first frame and populates fresh. Also no special-casing needed.
+
+---
+
+## Β§3. Position / Rotation write sites (matters for the cached model matrix)
+
+The dispatcher composes `model = meshRef.PartTransform * entityWorld` for non-Setup entities, and `model = restPose * meshRef.PartTransform * entityWorld` for Setup multi-parts (with `entityWorld = Rotation Γ Translation`). If `Position` or `Rotation` changes for a STATIC entity, a cached model matrix would be stale.
+
+Audit shows: **every Position/Rotation write site in `GameWindow.cs` operates on entities that are in `_animatedEntities`.** Static entities never have these fields touched after construction.
+
+| Line | Context | Animated? |
+|---|---|---|
+| 3992-3993 | `entity.SetPosition(worldPos); entity.Rotation = rot;` (player physics snap-on-arrival) | YES β `entity` is the local player |
+| 4116 | `entity.SetPosition(rmState.Body.Position);` (remote dead-reckon snap branch) | YES β remote NPC/player |
+| 4230 | same context, near-enqueue branch | YES |
+| 4362-4363 | remote dead-reckon physics tick body sync | YES |
+| 4407-4408 | local player position snap (teleport / GoHome) | YES |
+| 7045-7046 | `ae.Entity.SetPosition(rm.Body.Position); ae.Entity.Rotation = rm.Body.Orientation;` (TickAnimations body sync) | YES β `ae.Entity` is in `_animatedEntities` by definition |
+| 7373-7374 | same body-sync context, fall-through path | YES |
+
+No Position/Rotation writes happen on entities that are NOT in `_animatedEntities`. Confirmed via grep.
+
+---
+
+## Β§4. Other entity fields read by the dispatcher
+
+`WbDrawDispatcher.Draw` and `ClassifyBatches` read these `WorldEntity` fields beyond `MeshRefs`, `Position`, `Rotation`:
+
+| Field | Mutability | Cache impact |
+|---|---|---|
+| `PaletteOverride` | `init`-only ([WorldEntity.cs:37](../../src/AcDream.Core/World/WorldEntity.cs#L37)) | Stable post-spawn β safe to fold into cache key / texHandle resolution |
+| `HiddenPartsMask` | `init`-only ([WorldEntity.cs:73](../../src/AcDream.Core/World/WorldEntity.cs#L73)) | Stable; doesn't apply to dispatcher anyway (animation tick handles part-hide via `s_hidePartIndex` debug global, animated path only) |
+| `ParentCellId` | `init`-only ([WorldEntity.cs:45](../../src/AcDream.Core/World/WorldEntity.cs#L45)) | Stable; visibility filter input |
+| `AabbMin/AabbMax/AabbDirty` | Mutated lazily by `RefreshAabb` ([WorldEntity.cs:79-91](../../src/AcDream.Core/World/WorldEntity.cs#L79)) on `AabbDirty` flag, set by `SetPosition` | Read by `WalkEntitiesInto`, NOT used by classification. AABB stays static for static entities (Position never changes β never marked dirty after first refresh) |
+| `MeshRefs[i].SurfaceOverrides` | `init`-only on the MeshRef record struct | Stable for the lifetime of the MeshRef list (Sites 1-5) |
+| `MeshRefs[i].GfxObjId` | Stable (`readonly record struct`) | Forms part of the cache key |
+| `MeshRefs[i].PartTransform` | Stable for STATIC entities (the list is replaced atomically in Site 6 only for ANIMATED entities) | Cacheable for STATIC entities |
+
+No hidden mutability surface. The cache is safe for entities outside `_animatedEntities`.
+
+---
+
+## Β§5. Cache invalidation events (the wire-up)
+
+The cache is keyed by `entity.Id`. Only TWO event sources can invalidate a cached entry:
+
+### Β§5.1 Per-entity despawn (live server entities)
+
+**[GameWindow.cs:2933-2935](../../src/AcDream.App/Rendering/GameWindow.cs#L2933)** β `_worldState.RemoveEntityByServerGuid(serverGuid); _worldGameState.RemoveById(...); _animatedEntities.Remove(...);`
+
+This block fires for:
+- `0xF747 DeleteObject` (server explicitly says entity is gone).
+- `0xF625 ObjDescEvent` (dedup leg before respawn).
+
+**Hook:** add `_wbDrawDispatcher.InvalidateEntity(existingEntity.Id)` to this block.
+
+### Β§5.2 Landblock demote / unload (static dat entities)
+
+**[src/AcDream.App/Streaming/GpuWorldState.cs:373](../../src/AcDream.App/Streaming/GpuWorldState.cs#L373)** β `RemoveEntitiesFromLandblock(landblockId)` clears the entity list for a landblock. Called from `StreamingController.Tick` at [StreamingController.cs:116](../../src/AcDream.App/Streaming/StreamingController.cs#L116) for `ToDemote` (NearβFar) and via `_enqueueUnload` for `ToUnload`.
+
+**Hook:** add `_wbDrawDispatcher.InvalidateLandblock(landblockId)` adjacent to the `RemoveEntitiesFromLandblock` call. Walk the LB's pre-removal entity list; invalidate each Id.
+
+Implementation note: `RemoveEntitiesFromLandblock` already has the entity list in scope before zeroing it β adding the invalidation walk inside the method (or via a callback) is cheap. Alternative: `StreamingController` walks the LB's entries before invoking `RemoveEntitiesFromLandblock`. Either works; brainstorming will pick.
+
+### Β§5.3 No other invalidation paths needed
+
+Confirmed:
+- `MarkPersistent` ([GameWindow.cs:2024](../../src/AcDream.App/Rendering/GameWindow.cs#L2024)) β keeps player Id pinned across LB unloads. No MeshRefs change.
+- `DrainRescued` ([GameWindow.cs:5885](../../src/AcDream.App/Rendering/GameWindow.cs#L5885)) β re-attaches rescued persistent entities. No MeshRefs change.
+- `RelocateEntity` ([GameWindow.cs:6026](../../src/AcDream.App/Rendering/GameWindow.cs#L6026)) β moves entity between landblocks. Doesn't change MeshRefs/Position/Rotation. Safe.
+- `AddEntitiesToExistingLandblock` ([GpuWorldState.cs:401](../../src/AcDream.App/Streaming/GpuWorldState.cs#L401)) β FarβNear promotion adds entities. New entries get cache-miss naturally.
+
+`AnimationSequencer` ([src/AcDream.Core/Physics/AnimationSequencer.cs](../../src/AcDream.Core/Physics/AnimationSequencer.cs)) does NOT write to `entity.MeshRefs` or `entity.Position`/`entity.Rotation` directly. It produces `PartTransform[]` frames consumed by `TickAnimations`. Confirmed via grep β only docstring mention of `MeshRef`. Sequencer is safe to ignore for cache design.
+
+`Core` library has zero `entity.MeshRefs = ...` writes. All writes are in the App layer, all in `GameWindow.cs`. Confirmed via grep.
+
+---
+
+## Β§6. Recommended cache shape (for brainstorming, not yet committed)
+
+Pre-spec recommendation; final design picks settle in the brainstorming session.
+
+```csharp
+// Per-(entity, partIdx, batchIdx) classification result.
+private readonly record struct CachedBatch(
+ GroupKey Key, // bucket identity
+ ulong BindlessTextureHandle, // resolved texture (via palette + override)
+ Matrix4x4 RestPose); // meshRef.PartTransform (or restPose * meshRef.PartTransform for Setup)
+
+// Per-entity cache value.
+private sealed class EntityCache
+{
+ public List Batches = new(); // ordered: (part, batch) flat
+ public uint LandblockHint; // for InvalidateLandblock
+}
+
+// Cache state.
+private readonly Dictionary _entityCache = new();
+
+// Hot path:
+// if (_animatedEntities.ContainsKey(entity.Id)) β today's path (full ClassifyBatches)
+// else if (_entityCache.TryGetValue(entity.Id, out var cached)) β
+// for each batch: append (cached.RestPose * entityWorld) to its group's matrices
+// else β ClassifyBatches once, populate cache, then same fast path next frame.
+```
+
+**Per-frame static cost:** dictionary lookup + per-batch matrix multiply + matrices.Add. No texture resolution, no group-key construction, no metaTable lookup.
+
+**Worst case:** if every entity is animated (e.g. a city full of NPCs), the cache adds one `ContainsKey` lookup per visible entity vs today's path. Negligible overhead. In practice ~10K entities total at radius=12 with ~50 animated β 99.5% cache hit rate on the static path.
+
+**Risk surface:** the cache invariant rests on TWO claims, both verified in the audit above:
+1. STATIC entity Position / Rotation never mutate post-spawn. Verified Β§3.
+2. STATIC entity MeshRefs reference never changes post-spawn. Verified Β§1 (only Site 6 writes, only for animated entities).
+
+If either claim breaks in a future change (e.g. someone adds an "earthquake" effect that mutates static-tree positions), the cache will quietly serve stale matrices. Defense:
+- **DEBUG-only assertion** in the cache hit path: `Debug.Assert(!_animatedEntities.ContainsKey(entity.Id))`.
+- **DEBUG-only cross-check**: in DEBUG builds, in the cache-hit path, also recompute the live model matrix and compare against `cached.RestPose * entityWorld`. Log a warning if they differ. Catches the "someone added a new mutation site" failure mode without paying the cost in Release.
+
+(Belt-and-suspenders option (c) from the original handoff. Recommended for the first retry given the prior bug.)
+
+---
+
+## Β§7. What does NOT need to be in the cache design
+
+- **Texture invalidation on bindless handle change.** Bindless handles are issued on first texture upload and remain valid for the texture's lifetime. `TextureCache` doesn't evict entries during normal play (only on shutdown). Static-entity texture handles never change.
+- **GfxObj re-decode.** `WbMeshAdapter.TryGetRenderData` returns the same `ObjectRenderData` instance for a given `gfxObjId` for the session. Static-entity batches never change.
+- **`SurfaceOverrides` reactivation.** Init-only on `MeshRef`, set at Site 1's hydration time, stable for the MeshRef's lifetime.
+- **Per-frame `Time` / `dt` inputs.** The dispatcher doesn't read time. Texture animation (e.g. animated UV scrolls) happens in the shader from `gl_Time`-equivalent uniforms, not from cached state.
+
+---
+
+## Β§8. Open questions for brainstorming
+
+These need a user decision before I write the spec:
+
+1. **Where do `InvalidateEntity` / `InvalidateLandblock` live?** On `WbDrawDispatcher` (cache lives there)? On a new `EntityClassificationCache` class injected into the dispatcher (separation of concerns; testable in isolation)? My lean: separate class, dispatcher gets it via ctor.
+2. **Static-only (option a) vs static-only + DEBUG cross-check (option c)?** Cross-check costs nothing in Release and catches the exact bug class that bit us last time. My lean: option (c).
+3. **Cache the full model matrix or the rest pose?** Full matrix saves a per-frame multiply but bakes Position/Rotation into the cache (theoretically violatable). Rest pose is safer + costs ~one mat4 mult per batch. My lean: rest pose.
+4. **Setup multi-part flattening: cache the per-part `setupPart.PartTransform * meshRef.PartTransform` product?** Today's `Draw` walks `renderData.SetupParts` per-frame even though that list is per-GfxObj-immutable. The cache could pre-flatten into the batch list. My lean: yes β that's where the visible CPU win is.
+5. **Test plan: where do new tests live?** `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`? Pure-CPU tests on the cache class without GL state? My lean: yes, separate test file in the existing Wb test directory.
+
+---
+
+## Β§9. Sentinel + baseline (verified at audit start, 2026-05-10)
+
+- `dotnet build`: green (after `git submodule update --init` for the WorldBuilder ref tree, which was missing in this fresh worktree).
+- `dotnet test --no-build`: 1688 passing, 8 pre-existing failures in `AcDream.Core.Tests`. Matches the post-#52/#54 baseline in the handoff.
+- N.5b sentinel filter (`TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence`): 94/94 passing. Matches the post-#52/#54 baseline.
+
+These are the floors the Tier 1 retry must keep clean throughout.
diff --git a/docs/research/2026-05-10-tier1-retry-handoff.md b/docs/research/2026-05-10-tier1-retry-handoff.md
new file mode 100644
index 0000000..4612468
--- /dev/null
+++ b/docs/research/2026-05-10-tier1-retry-handoff.md
@@ -0,0 +1,203 @@
+# Phase Post-A.5 β Tier 1 Retry (ISSUE #53) β Cold-Start Handoff
+
+**Created:** 2026-05-10, immediately after closing ISSUES #52 (lifestone) + #54 (JobKind plumbing) and merging to main.
+**Audience:** the next agent picking up Priority 3 of the Post-A.5 polish phase.
+**Purpose:** drop straight into the Tier 1 entity-classification cache retry without re-litigating what the prior session settled.
+
+---
+
+## TL;DR
+
+Post-A.5 polish was sized at three priorities. **2 of 3 shipped to main** during the 2026-05-10 session; **only Priority 3 (Tier 1 retry, ISSUE #53) remains.** Tier 1 is the biggest perf headroom in the post-A.5 phase: it should drop the entity dispatcher cpu_us median from ~3.5 ms to ~1-1.5 ms, putting the dispatcher inside the spec's 2.0 ms budget and unlocking ~300-400 FPS at standstill.
+
+The first Tier 1 attempt (commit `3639a6f`, reverted at `9b49009`) broke animation. The next attempt MUST start with an animation-mutation audit. **This handoff has the audit pre-started** β there's specific evidence captured below that the previous handoff didn't have.
+
+Sized: ~5-7 days including audit + design + spec + implementation + visual gate.
+
+---
+
+## Where main is
+
+- **`main` HEAD: `da08490`** β Merge of `claude/cranky-varahamihira-fe423f`. Includes the lifestone fix + JobKind plumbing.
+- **CLAUDE.md "Currently in flight"** updated to *"Post-A.5 polish β Tier 1 retry (only remaining priority)"*.
+- **`docs/ISSUES.md`** has both #52 and #54 in *Recently closed* with full root-cause writeups; only #53 remains in *Active issues*.
+- **N.5b conformance sentinel: 94/94.** Full suite: 1688/1696 passing (8 pre-existing physics/input failures unchanged across all session work).
+
+Recent commit chain on main (newest first):
+
+| SHA | Subject |
+|---|---|
+| `da08490` | Merge branch 'claude/cranky-varahamihira-fe423f' β Post-A.5 polish: close #52 (lifestone) + #54 (JobKind plumbing) |
+| `9a55354` | docs(post-A.5 #54): close JobKind plumbing issue + update CLAUDE.md flight status |
+| `bf31e59` | fix(streaming): close #54 β plumb JobKind through BuildLandblockForStreaming |
+| `b19f1d1` | docs(post-A.5 #52): close lifestone issue + update CLAUDE.md flight status |
+| `e40159f` | fix(render): close #52 β lifestone visible (alpha-test + cull + uDrawIDOffset) |
+| `c111312` | docs(post-A.5): cold-start handoff for the next session (the prior handoff this work used) |
+
+---
+
+## What shipped this session
+
+### Priority 1 β ISSUE #52 (lifestone missing) β closed by `e40159f`
+
+Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit `dcae2b6`, 2026-05-08):
+
+1. **Alpha-test discard** in `mesh_modern.frag` transparent pass killed high-Ξ± pixels of dat-flagged transparent surfaces. The lifestone crystal core (surface `0x080011DE`) decoded with Ξ±β₯0.95, so 100% of fragments were discarded. Fix: remove `Ξ± >= 0.95 discard` from transparent pass; keep `Ξ± < 0.05 discard` as a fragment-cost optimization.
+2. **Cull state regression**: `WbDrawDispatcher.Draw` Phase 8 had no GL cull state β Phase 9.2's `Enable(CullFace) + Back + CCW` setup (commit `6f1971a`, 2026-04-11) was lost when the legacy `StaticMeshRenderer` was deleted. Closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's GL state at the top of Phase 8.
+3. **`uDrawIDOffset` indexing bug**: `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect`, so the transparent pass was reading `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[opaqueCount..end)`. The lifestone flickered to whatever opaque batch sorted to index 0 each frame. Fix: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, set per-pass in dispatcher (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WB's `BaseObjectRenderManager.cs:845`.
+
+User-confirmed visually via `+Acdream` test character at the Holtburg outdoor lifestone (Z=94 platform).
+
+### Priority 2 β ISSUE #54 (JobKind plumbing) β closed by `bf31e59`
+
+`LandblockStreamer.cs` primary ctor signature changed from `Func` to `Func`. A back-compat overload preserves the old signature for the 5 ctor sites in `LandblockStreamerTests.cs` (no test changes needed). `BuildLandblockForStreaming(uint, JobKind)` in `GameWindow.cs` early-outs for `LoadFar` with a heightmap-only path. The Bug A post-load entity strip in `LandblockStreamer.HandleJob` is retained as a `Debug.Assert` + Release safety net.
+
+Per-LB worker cost on far-tier dropped from ~tens of ms (full hydration including `LandBlockInfo` + `SceneryGenerator` + interior cells) to ~sub-ms (single `LandBlock` dat read).
+
+### Memory entry from this session
+
+`feedback_wb_migration_state_audit.md` β captures the meta-lesson that WB-migration phases need a systematic GL-state and shader-uniform diff vs the legacy renderer being replaced. Future phases at risk: Sky/Particles modern path migration, EnvCell modern path, Shadow mapping. Also captures the workflow lesson: when the user says *"we had this nailed down before"*, the first move is `git log -- ` BEFORE adding new diagnostic instrumentation.
+
+---
+
+## Priority 3 β ISSUE #53 β Tier 1 entity-classification cache retry
+
+### What the first attempt was and why it failed
+
+Commit `3639a6f` (reverted at `9b49009`) cached `meshRef.PartTransform` baked into per-(entity, batch) classification at first-frame visit. For static entities this is stable; for animated entities the cache froze the pose and NPCs/players stopped animating. Some buildings also showed at wrong positions (likely entities incorrectly flagged as animated).
+
+The "trust MeshRefs as the source of truth" comment in the dispatcher gave false confidence. MeshRefs IS the source of truth, but it's mutated EVERY frame for animated entities.
+
+### The audit (PRE-STARTED in the prior session β read this carefully)
+
+The previous handoff and ISSUE #53 describe the bug as *"AnimationSequencer mutates `meshRef.PartTransform` every frame to apply the current skeletal pose."* **That framing is technically wrong** in a way that matters for the retry design. Discovered during the post-A.5 lifestone session:
+
+- `MeshRef` at `src/AcDream.Core/World/MeshRef.cs:15` is a `readonly record struct` β its fields **cannot be mutated in place**:
+ ```csharp
+ public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform)
+ ```
+- The actual per-frame mutation for animated entities is the **entire `MeshRefs` LIST replacement** at `src/AcDream.App/Rendering/GameWindow.cs:7474-7553`:
+ ```csharp
+ var newMeshRefs = new List(partCount);
+ // ... loop building per-part transforms from sequencer.Advance(dt) ...
+ ae.Entity.MeshRefs = newMeshRefs;
+ ```
+- The source of truth for *which* entities go through that per-frame path is the `_animatedEntities` dictionary at `GameWindow.cs:160`:
+ ```csharp
+ private readonly Dictionary _animatedEntities = new();
+ ```
+ Population: `_animatedEntities[entity.Id] = new AnimatedEntity{...}` at GameWindow.cs:2724 (spawn). Removal: `_animatedEntities.Remove(...)` at GameWindow.cs:2935 (despawn).
+
+**Therefore: a static entity is one whose `Id` is NOT in `_animatedEntities`.** Its MeshRefs list is the same instance from spawn until rare events (ObjDesc / palette swap / part hide). Other static-entity write sites that must be invalidation-aware:
+- `src/AcDream.App/Rendering/GameWindow.cs:2333` and `:2365` β ObjDescEvent / AnimPartChange events rebuild a `MeshRef` element. Network-driven, infrequent.
+- `src/AcDream.App/Rendering/GameWindow.cs:2524` β entity scale apply at spawn (one-shot).
+- Lines 4682-4924, 4996-5074 β dat-side hydration paths in OnLoad / scenery / interior. Spawn-time only.
+
+### What this means for cache design
+
+The cleanest design is now clearer than the original handoff suggested:
+
+**Recommended approach (option a from the original handoff): static-only cache with explicit invalidation hooks.**
+
+1. Cache the (entity, batch) β InstanceGroup-key + model-matrix mapping for entities where `_animatedEntities.ContainsKey(entity.Id) == false`.
+2. Animated entities skip the cache entirely; they go through today's per-frame `ClassifyBatches` path.
+3. Invalidate the cache for an entity on:
+ - **ObjDesc / AnimPartChange events** (`GameWindow.cs:2333, 2365`) β rebuild that entity's cache entry.
+ - **Palette override changes** (rare; usually only on initial server spawn or a re-equip event).
+ - **Entity despawn** β drop the cache entry.
+4. Static entities never animate. The dispatcher's per-frame work for cached entities reduces from "walk + classify all batches" to "walk + lookup-and-emit-pre-classified".
+
+Why this is safer than the first attempt: the first attempt cached the POSE (model matrix). This attempt would cache only the (group key, texture handle, blend mode, per-part `meshRef.PartTransform * entityWorld` for the spawn-time stable subset). Animation never enters the cache surface.
+
+### Cache design options reconsidered
+
+(a) **Static-only cache (recommended).** As described above. Clean invariant: animated entities skip the cache; static entities go through it. Requires careful enumeration of all writes to `entity.MeshRefs` for static entities (see audit list above) so each one fires invalidation.
+
+(b) **Dynamic-aware cache with invalidation hooks.** Cache everything but expose `InvalidateEntity(uint)` / `RefreshEntityPalette(uint)` hooks; wire from network handlers. More complex but might let some animated entities also benefit if their per-frame mutations are localized. NOT RECOMMENDED for a first retry β error-prone and the first attempt already failed at this scope.
+
+(c) **Static-only + animated-bypass + DEBUG cross-check.** Like (a), but in DEBUG builds, log a warning every frame if a cached entity's `MeshRefs` reference no longer matches the cached snapshot (catches mis-classified dynamics). Belt-and-suspenders. Recommended IF you're nervous about the audit being incomplete.
+
+### Acceptance criteria (from the original handoff, refined)
+
+- Build green; existing 999+ tests pass; 8 pre-existing physics/input failures stay at 8.
+- 1-3 new tests covering: cache hit for static entity (lookup), cache bypass for animated entity (no-op), cache invalidation on entity despawn, cache invalidation on ObjDesc/palette event.
+- N.5b conformance sentinel intact (89+ tests; in this session it's 94/94 β must stay clean).
+- Visual gate: launch + walk Holtburg β North Yanshi at horizon-safe preset; confirm:
+ - Animation works (NPCs, player character animate normally β including the lifestone crystal closed by #52).
+ - Buildings at correct positions.
+ - No new visual regressions.
+- Perf gate (with `[WB-DIAG]` under `ACDREAM_WB_DIAG=1`):
+ - Entity dispatcher cpu_us median drops from ~3.5 ms to β€2.0 ms (matches spec budget).
+ - p95 stays β€2.5 ms.
+
+---
+
+## Files to read before brainstorming
+
+In rough order:
+
+1. **This handoff** end-to-end β captures audit insights from the prior session that the original handoff didn't have.
+2. **`docs/research/2026-05-10-post-a5-polish-handoff.md`** β the prior handoff. Β§"Priority 3" has the original (slightly outdated) framing of the bug. Read for context but trust THIS handoff's audit insights over its.
+3. **`docs/ISSUES.md` issue #53** β the issue's own description (now updated post-#52/#54 close).
+4. **`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`** β A.5 spec for the entity dispatcher's data-flow context (esp. Β§4.10 Quality Preset and Β§11 deferred items).
+5. **`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`** β the perf-tier roadmap. Tier 1 is in scope; Tier 2 + Tier 3 are explicitly NOT (those are dedicated multi-week phases).
+6. **`memory/feedback_wb_migration_state_audit.md`** β the new memory entry on WB migration state-loss patterns. Tier 1 doesn't touch the WB migration directly, but the meta-lesson "audit before assume" is exactly what this priority needs.
+7. **`memory/project_phase_a5_state.md`** β the 5 gotchas. **Critical for avoiding the same traps**, especially #3 (caching mutable per-frame state breaks animation silently) β the exact bug the first Tier 1 attempt hit.
+8. **`src/AcDream.Core/World/MeshRef.cs`** β confirm the `readonly record struct` shape; understand that "mutating PartTransform" actually means "replacing the whole MeshRef record."
+9. **`src/AcDream.App/Rendering/GameWindow.cs:7340-7560`** β the per-frame animation rebuild loop. Read this end-to-end for the audit. Find every line that writes to `entity.MeshRefs` for animated entities.
+10. **`src/AcDream.App/Rendering/GameWindow.cs:160` + lines 2710-2760, 2920-2940** β `_animatedEntities` declaration + spawn/despawn population.
+11. **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** β `Draw` and `ClassifyBatches`. Where the cache will land.
+12. **`src/AcDream.Core/Physics/AnimationSequencer.cs`** β the per-frame animation engine. Audit any field it mutates that the dispatcher reads.
+13. **`src/AcDream.Core/Physics/AnimationHookRouter.cs`** β secondary mutation source via animation hooks.
+
+---
+
+## Workflow for the next session
+
+1. **Read this handoff in full.**
+2. **Verify build green:** `dotnet build`. Verify ~1688 tests pass: `dotnet test --no-build`. Verify N.5b sentinel: filter `TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence` β expect 94 passing.
+3. **Read the files above** in order. Especially deep on Β§"Files to read" #8-#13.
+4. **Audit step (1-2 days):** open a fresh research note `docs/research/2026-05-10-tier1-mutation-audit.md` and write down:
+ - Every code path that writes `entity.MeshRefs = ...` for any entity.
+ - Tag each as **STATIC** (one-shot at spawn or rare event) or **DYNAMIC** (per-frame).
+ - For each STATIC write, identify the trigger (network event, scale apply, etc.) and design the invalidation hook.
+ - For each DYNAMIC write, confirm it fires only for entities in `_animatedEntities` (which means cache bypass is the right answer).
+5. **Spec (~1 day):** brainstorm the cache design with the user (use `superpowers:brainstorming`). Write `docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md`. Include the audit findings, the chosen cache approach (probably option (a)), the invariants, the invalidation API, the test plan, the perf-gate measurement plan.
+6. **Implement (~2-3 days):** TDD via `superpowers:test-driven-development`. Tests first for cache hit/miss/invalidation, then implementation in `WbDrawDispatcher`. Wire invalidation hooks into the relevant write sites in `GameWindow.cs`.
+7. **Visual gate:** launch + walk; confirm animation works on a moving NPC; confirm static buildings/scenery still render at correct positions; confirm lifestone (closed by #52) still renders.
+8. **Perf gate:** capture `[WB-DIAG]` cpu_us median + p95 with `ACDREAM_WB_DIAG=1` at horizon-safe preset (NEAR=4, FAR=12). Compare to today's ~3.5 ms baseline; expect β€2.0 ms.
+9. **Ship:** commit, close #53 in ISSUES.md, update CLAUDE.md "Currently in flight" (this would close out the post-A.5 polish phase entirely), update memory with any new gotchas captured during the audit/implementation.
+10. **Next phase after #53 ships:** N.6 (perf polish) per the roadmap. Or escalate to Tier 2 (static/dynamic split with persistent groups) per `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` if Tier 1 alone doesn't hit the perf target.
+
+---
+
+## Things to NOT do
+
+- **Don't skip the audit.** The whole reason the first attempt failed was that the audit was implicit and incomplete. The audit step should produce a written list of every MeshRefs write site, classified static vs dynamic, before any cache code is written.
+- **Don't bundle Tier 2 or Tier 3 into this phase.** Those are dedicated multi-week phases per `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`. If the audit reveals Tier 1 alone can't hit the perf target, file a follow-up issue and escalate as a separate phase.
+- **Don't re-add the `Tier1` cache that was reverted.** Start fresh after the audit. Cherry-picking commit `3639a6f` reintroduces the animation freeze.
+- **Don't break the N.5b conformance sentinel.** Run the filter on every commit:
+ ```
+ dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
+ ```
+ Expect 94 passing, 0 failures.
+- **Don't skip the visual gate.** Animation has been the highest-risk regression in this codebase repeatedly (Tier 1 first attempt, the lifestone crystal in this session, the foundry statue earlier). Confirm visually with a moving animated NPC, a stationary building, and the lifestone before declaring done.
+- **Don't trust "it was working in prod before."** That was the first Tier 1 attempt's posture. The audit is what makes it actually safe.
+
+---
+
+## Reference: Tier 1 perf math
+
+Per the perf-tier roadmap and A.5 final state:
+- **Today** (post-A.5 ship + #52/#54): entity dispatcher cpu_us median ~3.5 ms at radius=12 on Radeon RX 9070 XT @ 1440p. ~200-240 FPS at standstill.
+- **After Tier 1**: ~1.0-1.5 ms median expected. ~300-400 FPS at standstill. Inside the spec's 2.0 ms budget.
+- **After Tier 2 (separate phase)**: ~0.5-1.0 ms. ~400-600 FPS.
+- **After Tier 3 (GPU compute culling, separate phase)**: ~0.05 ms. ~600-1000+ FPS.
+
+Tier 1 is the lowest-risk, highest-leverage perf win remaining for the post-A.5 polish phase.
+
+---
+
+Good luck. The audit is the load-bearing thing β invest in it. The implementation is mechanical once the audit is solid.
+
+Holler at the user if any of the audit reveals a write site that doesn't fit the static/dynamic dichotomy cleanly.
diff --git a/docs/research/2026-05-12-l2a-shipped-l2d-handoff.md b/docs/research/2026-05-12-l2a-shipped-l2d-handoff.md
new file mode 100644
index 0000000..9ecfcaa
--- /dev/null
+++ b/docs/research/2026-05-12-l2a-shipped-l2d-handoff.md
@@ -0,0 +1,176 @@
+# L.2a shipped β L.2d direction confirmed β Cold-Start Handoff
+
+**Created:** 2026-05-12 evening, immediately after the L.2a-slice-1/2/3 work landed and visual-verified.
+**Audience:** the next agent picking up Phase L.2 (Movement & Collision Conformance).
+**Purpose:** give you everything you need to start L.2d brainstorming cold, without spelunking through this session's transcript.
+
+---
+
+## TL;DR
+
+Phase L.2a (Truth & Diagnostics) shipped three slices tonight. They surfaced **three concrete L.2 findings** with reproducible evidence β converting "we should look at this someday" theories into "here is the entity id, here is the wall normal, here is the cell id." With those findings in hand, the next concrete physics work in the L.2 roadmap is **L.2d slice 1 β port `CBuildingObj` collision so doorway gaps are walkable.** Brainstorm + spec, then port.
+
+**Three slices shipped to `claude/intelligent-poitras-b2c4f9`:**
+
+| Commit | What | Why |
+|---|---|---|
+| [`ebef820`](.) | L.2a slice 1: `[resolve]` + `[cell-transit]` probes + DebugPanel mirror | Foundation for every later L.2 change to be evidence-driven |
+| [`e0c08bc`](.) | L.2a slice 2: surface hit object guid in `[resolve]` line | Tell us WHICH entity is the wall, not just the wall normal |
+| [`a068292`](.) | L.2a slice 3: populate the previously-stub `CollisionInfo.CollideObjectGuids` / `LastCollidedObjectGuid` | Slice 2 found these fields were declared but never written β fixed the structural gap |
+
+---
+
+## Three findings from the L.2a probes
+
+All produced by walking around Holtburg + pushing W into a Town doorway with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1`.
+
+### Finding 1 β L.2e cell-id format gap (DEFINITIVE)
+
+The player's tracked `CellId` is being recorded as a **bare low byte** (`0x00000029`), with no landblock prefix. AC cell ids are normally `0xLLLLCCCC` β landblock id (4 hex digits) + cell-within-landblock (4 hex digits, `0x0001-0x00FF` outdoor or `0x0100+` indoor).
+
+Evidence from a tonight log:
+
+```
+[cell-transit] 0x00000001 -> 0x00000029 pos=(132.585,21.015,94.000) reason=resolver
+```
+
+NPCs in the same area show MIXED forms in their resolve lines:
+- `cell=0xA9B3000E` β full landblock-prefixed (correct)
+- `cell=0x00000032` β bare low byte (matches the bug shape)
+
+Likely source: `ResolveOutdoorCellId(...)` at [src/AcDream.Core/Physics/PhysicsEngine.cs:687](src/AcDream.Core/Physics/PhysicsEngine.cs:687) β that's the function that ResolveWithTransition routes the output cell id through before returning. Worth grepping for its body.
+
+This is the L.2e blocker per the plan-of-record:
+> *"Update low outdoor cell id across 24m cell boundaries and landblock seams. Port the retail adjacent-cell search: `find_cell_list`, `check_other_cells`, and `adjust_check_pos`."*
+
+### Finding 2 β L.2c wall-slide is working
+
+The transition layer at this spot does the retail-faithful thing:
+
+```
+[resolve] ent=0x000F4240 in=(132.067,17.567,94.000) cell=0x00000029
+ tgt=(132.239,17.172,94.000)
+ out=(131.938,17.567,94.000) cell=0x00000029
+ ok=True groundedIn=True cp=valid hit=yes n=(0.00,1.00,0.00)
+ obj=0xA9B47900 walkable=True
+```
+
+- Wall normal `(0, 1, 0)` β vertical wall facing +Y, captured correctly.
+- `out` shows the position clamped along the wall: X slid back from 132.067 β 131.938, Y preserved.
+- `ok=True` β resolver completed normally (no `ok=False` anywhere in the trace, 0/140).
+
+**No L.2c work needed at this site.** Edge-slide / wall-slide port from earlier (per the plan-of-record's L.2c "Current shipped slice" note) is doing its job here.
+
+### Finding 3 β L.2d sub-direction = CBuildingObj port (NOT door-toggle)
+
+All 140 hit=yes lines in the doorway-push test came back with the **same dominant `obj=` attribution**:
+
+| obj | hits | range | what it is |
+|---|---|---|---|
+| **`0xA9B47900`** | **126** | `0xLLLLxxxx` (landblock-baked static) | The Holtburg building itself β its baked collision mesh |
+| `0x000F4245` | 14 | `0x000Fxxxx` (local-spawn entity) | An NPC standing near the doorway |
+
+`0xA9B4` matches the Holtburg landblock prefix we logged at startup (`loading world view centered on 0xA9B4FFFF`). The `0x7900` low bytes is its landblock-local entity id. **It's the building's baked collision shape β not a door entity, not a creature.**
+
+**Implication:** the "doorway is blocked" symptom is NOT a door-collision-not-toggled bug (which would have shown a door-range entity id, typically `0xCC0Cxxxx`). It's a **building-mesh fidelity issue**: the building's baked collision data we're loading represents the building as a solid block with no walkable opening where the visual doorway is.
+
+Two non-mutually-exclusive interpretations:
+1. **Collision-mesh extraction is wrong** β we load building geometry but don't respect the BSP nodes that encode doorway openings.
+2. **`CBuildingObj` + per-cell walkability is not ported** β retail uses a per-cell `CObjCell` structure that maps "this interior cell is reachable" / "this exterior cell connects to those interior cells." Without that, we treat the building as one opaque collision volume.
+
+The plan-of-record's L.2d goal:
+> *"Preserve enough building identity to model `CBuildingObj` collision and `bldg_check` behavior."*
+
+points at interpretation 2 as the canonical fix.
+
+---
+
+## What this session deliberately did NOT do
+
+- **Other L.2a slices** (contact-plane probe, ShadowObject hit log, water probe, real-DAT fixture-capture pipeline). Slice 1 + 2 + 3 cover the most-load-bearing case (resolver outcomes + cell transits + entity attribution). The remaining diagnostics serve future L.2 work and can ship opportunistically.
+- **L.2d implementation or brainstorm.** Deliberately parked for a fresh session with this evidence as cold-start context.
+- **L.2e implementation.** The cell-id format finding is filed but not investigated.
+- **Pre-existing test failures.** 8 tests fail at the branch base (none from these slices β verified by stash + rerun on every test cycle). Not from this slice. See "Open concerns" below.
+
+---
+
+## Branch state at handoff
+
+- Branch: `claude/intelligent-poitras-b2c4f9`
+- Three slice commits ahead of `eab347d` (the C.1.5b merge into main), plus a docs commit that adds this handoff + the next-session prompt + plan-of-record / CLAUDE.md updates.
+- Tonight's last code commit was `a068292` (L.2a slice 3); docs commit follows.
+- Worktree clean post-docs-commit; merge to main is the user's planned next operation.
+
+## What's now in the diagnostic surface
+
+Live env vars (both can be flipped at runtime via the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`):
+
+- **`ACDREAM_PROBE_RESOLVE=1`** β one `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call:
+ ```
+ [resolve] ent=0xEEEEEEEE in=(x,y,z) cell=0xCCCCCCCC tgt=(x,y,z) out=(x,y,z) cell=0xCCCCCCCC ok=Y/N groundedIn=Y/N cp=valid|lastKnown|none hit=yes n=(nx,ny,nz) obj=0xOOOOOOOO env nObj=N walkable=Y/N
+ ```
+ Heavy: fires for every entity's resolve per physics tick.
+- **`ACDREAM_PROBE_CELL=1`** β one `[cell-transit]` line per `PlayerMovementController.CellId` change:
+ ```
+ [cell-transit] 0xOLD -> 0xNEW pos=(x,y,z) reason=resolver|teleport
+ ```
+ Low volume β only fires on actual cell crossings.
+
+Both backed by `AcDream.Core.Physics.PhysicsDiagnostics` static class (initial from env var, set/get from anywhere at runtime).
+
+## Files changed in this session
+
+```
+src/AcDream.Core/Physics/PhysicsDiagnostics.cs (new)
+src/AcDream.Core/Physics/PhysicsEngine.cs (modified β probe emission)
+src/AcDream.Core/Physics/TransitionTypes.cs (modified β entity attribution plumbing)
+src/AcDream.App/Input/PlayerMovementController.cs (modified β UpdateCellId chokepoint)
+src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs (modified β Probe* forwarder props)
+src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs (modified β two new checkboxes)
+docs/plans/2026-04-29-movement-collision-conformance.md (modified β shipped-slice note + L.2d sub-direction)
+```
+
+## Open concerns flagged but NOT addressed in this session
+
+- **8 pre-existing test failures** on the branch base, verified by stash+rerun: `MotionInterpreterTests.GetMaxSpeed_*` (3), `PositionManagerTests.ComputeOffset_BothActive_Combined`, `PlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirection`, `DispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motion`, `BSPStepUpTests.{D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames,C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide}`. Most touch movement/physics code we're about to evolve in L.2b/L.2c/L.2d β **triage before further L.2 work** is recommended.
+- **Player entity id quirk.** Local player physics entity id observed as `0x000F4240` in the resolve probe, not the server guid `0x5000000A`. This is presumably the dat/local-spawn entity id β fine for diagnostic, worth keeping in mind for any future "is this the player?" check.
+
+## Cold-start checklist for L.2d brainstorm
+
+1. Read this handoff.
+2. Read [docs/plans/2026-04-29-movement-collision-conformance.md](docs/plans/2026-04-29-movement-collision-conformance.md) β focus on L.2d section.
+3. Read the L.2d named-retail anchors:
+ - `CCellStruct::point_in_cell`, `CCellStruct::sphere_intersects_cell`, `CCellStruct::box_intersects_cell`
+ - `CBuildingObj::find_building_collisions`
+ - `CObjCell::find_cell_list` (already shared with L.2e)
+ Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`.
+4. Read [src/AcDream.Core/Physics/TransitionTypes.cs:1386](src/AcDream.Core/Physics/TransitionTypes.cs:1386) β current `FindObjCollisions` loop, where building objects currently route through generic BSP/Cylinder paths.
+5. Read [src/AcDream.Core/Physics/PhysicsDataCache.cs](src/AcDream.Core/Physics/PhysicsDataCache.cs) β how we currently load BSP / GfxObj data; figure out if building-specific data (interior cells, `CBuildingObj`) is loaded but not consumed.
+6. Cross-reference WorldBuilder (`references/WorldBuilder/`) for any building-cell handling already present.
+7. Brainstorm the slice (`superpowers:brainstorming` if useful) β scope, named-retail anchors, conformance tests, real-DAT fixtures.
+8. Write a spec at `docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md`.
+9. Implement in slices with conformance citations in each commit.
+
+## Reproducing the doorway evidence
+
+In case you want to re-capture the trace:
+
+```powershell
+# In the project worktree
+$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_CELL = "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.log"
+```
+
+Walk acdream up to a Holtburg building doorway. Hold W into it for ~2 seconds. Close. Grep `launch.log` for:
+- `cell-transit` β cell tracking
+- `\[resolve\].*hit=yes` β wall hits with object attribution
+
+Wall entity should appear as `obj=0xA9B47XXX` for the same Holtburg building, OR a different `0xA9Bxxxxx` for other buildings in the area.
diff --git a/docs/research/2026-05-12-l2d-next-session-prompt.md b/docs/research/2026-05-12-l2d-next-session-prompt.md
new file mode 100644
index 0000000..bcb7395
--- /dev/null
+++ b/docs/research/2026-05-12-l2d-next-session-prompt.md
@@ -0,0 +1,51 @@
+# Copy-paste prompt β next session for L.2d brainstorm
+
+**This file is meant to be pasted verbatim into a new Claude Code session.** It assumes the next session starts on a freshly-merged `main` with the L.2a-slice-1/2/3 work already landed.
+
+---
+
+## Prompt to paste
+
+> You are picking up Phase L.2d (Movement & Collision Conformance β Shape Fidelity: Sphere / CylSphere / Building Objects) for the acdream project.
+>
+> The previous session shipped L.2a-slice-1/2/3 (resolver + cell-transit probes + entity attribution plumbing) and used the probes to settle the L.2d sub-direction call: **the wall blocking us at building doorways is a landblock-baked static (`0xA9B47900` for the Holtburg test building), NOT a door entity.** The fix is to port `CBuildingObj` + per-cell walkability so the building's baked collision mesh has walkable openings where doorways are. Door-state-toggle is NOT the issue.
+>
+> Before writing any code:
+>
+> 1. **Read the handoff:** `docs/research/2026-05-12-l2a-shipped-l2d-handoff.md` β full context, evidence, file pointers.
+> 2. **Read the plan-of-record:** `docs/plans/2026-04-29-movement-collision-conformance.md` β focus on L.2d, and notice that L.2c already shipped most of its work + L.2a is now ~75% covered.
+> 3. **Read the named-retail anchors** (grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`):
+> - `CCellStruct::point_in_cell`
+> - `CCellStruct::sphere_intersects_cell`
+> - `CCellStruct::box_intersects_cell`
+> - `CBuildingObj::find_building_collisions`
+> - `CObjCell::find_cell_list`
+> 4. **Read current code:**
+> - `src/AcDream.Core/Physics/TransitionTypes.cs:1386` β `FindObjCollisions` (where building objects currently flow through generic BSP path).
+> - `src/AcDream.Core/Physics/PhysicsDataCache.cs` β what building-specific data we already load vs ignore.
+> 5. **Cross-reference WorldBuilder** at `references/WorldBuilder/` for any building-cell handling we can crib.
+>
+> Your deliverable for this session:
+>
+> 1. A brainstorm using `superpowers:brainstorming` if scope is unclear, then
+> 2. A design spec at `docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md` covering:
+> - Named-retail anchors with line numbers from the PDB pseudo-C
+> - Component breakdown (CObjCell port, CBuildingObj port, integration with FindObjCollisions)
+> - Conformance test plan (synthetic + real-DAT fixtures at known Holtburg buildings)
+> - Slice plan (3-5 commits, each conformance-cited)
+> - Acceptance criteria
+> 3. After spec approval, implement slice 1.
+>
+> **Before implementation,** verify the L.2a probes still work β relaunch with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1 ACDREAM_DEVTOOLS=1`, walk up to the Holtburg test doorway, confirm `[resolve]` lines still show `obj=0xA9B4xxxx` for the wall hits. (Reproduction recipe in the handoff doc's last section.)
+>
+> Side note: **8 pre-existing test failures** exist on main (verified by stash+rerun in the prior session, none from L.2a slice work). Most touch movement/physics code we're about to evolve. **Triage them before sinking deep L.2d effort** β a recent baseline regression in this area could waste hours of L.2d work.
+
+---
+
+## Reading order if you only have 10 minutes
+
+1. `docs/research/2026-05-12-l2a-shipped-l2d-handoff.md` β TL;DR + Three findings sections (5 min).
+2. `docs/plans/2026-04-29-movement-collision-conformance.md` Β§L.2d (2 min).
+3. `src/AcDream.Core/Physics/TransitionTypes.cs:1386-1543` β current `FindObjCollisions` body (3 min).
+
+From there, decide whether to brainstorm or jump straight to the spec.
diff --git a/docs/research/2026-05-12-l2g-slice1-shipped-handoff.md b/docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
new file mode 100644
index 0000000..8a682cd
--- /dev/null
+++ b/docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
@@ -0,0 +1,241 @@
+# L.2g slice 1 shipped β handoff (code-complete; visual test deferred)
+
+**Date:** 2026-05-12 evening.
+**Branch:** `claude/gallant-mestorf-3bf2e3` (ready to merge to main).
+**Predecessors:**
+- [2026-05-13-l2d-slice1-shipped-handoff.md](2026-05-13-l2d-slice1-shipped-handoff.md) β the L.2d trace that identified Door entities as the Holtburg doorway blocker, motivating L.2g.
+- [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md) β the L.2g design spec (commit `2c10dd4`).
+- [docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md](../superpowers/plans/2026-05-12-phase-l2g-slice1.md) β the L.2g slice 1 implementation plan (commit `869677b`).
+
+---
+
+## TL;DR
+
+L.2g slice 1 **code is complete and unit-tested.** The four commits land
+the full inbound `SetState (0xF74B)` pipeline: parser β WorldSession
+event β GameWindow handler β `ShadowObjectRegistry.UpdatePhysicsState`.
+After this slice, the existing `CollisionExemption.ShouldSkip`
+short-circuit (cited at `acclient_2013_pseudo_c.txt:276782`) honors
+runtime ETHEREAL flips without any resolver-path edit.
+
+**The visual verification at Holtburg's inn doorway is deferred to the
+next session.** Cause: Phase B.4's outbound Use handler turns out to be
+unwired β clicking on a door silently does nothing because no
+production code subscribes to the `SelectLeft` / `SelectDblLeft` input
+actions. Without the outbound Use, the server never sees a "open the
+door" request, so the inbound SetState we just ported never fires.
+
+L.2g slice 1 is the inbound half of the round-trip. Phase **B.4b** (a
+small ~30-50 LOC slice) is the outbound half. Both halves are required
+for the M1 demo target *"open the inn door."* B.4b is the next session's
+work.
+
+---
+
+## What shipped on this branch
+
+| Commit | Subject |
+|---|---|
+| [`2459f28`](.) | `feat(phys L.2g slice 1): inbound SetState (0xF74B) parser` |
+| [`d538915`](.) | `feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState` |
+| [`536a608`](.) | `feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe` |
+| [`108e386`](.) | `feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log` |
+
+Plus docs/scaffolding earlier in the session:
+- `2c10dd4` β L.2g design spec + L.2 plan-of-record + milestones + CLAUDE.md updates.
+- `869677b` β L.2g slice 1 implementation plan (this doc's companion).
+
+**Build:** clean. **Tests:** 6 new tests pass (3 for parser, 3 for
+registry mutator). Full suite: 1037 pass / 8 pre-existing-baseline fail.
+No regressions. Per-commit + final integration code reviews all approved.
+
+---
+
+## What the code now does end-to-end
+
+When the server broadcasts a `SetState (0xF74B)`:
+
+1. **Parse** β `WorldSession`'s dispatcher routes opcode `0xF74B` into
+ `SetState.TryParse(body)`, which returns
+ `SetState.Parsed(Guid, PhysicsState, InstanceSequence, StateSequence)`.
+2. **Probe** (gated on `ACDREAM_PROBE_BUILDING=1`) β one-shot per
+ session, dumps the first message's body bytes as
+ `[setstate-hex] body.len=N first-N-bytes: 4B F7 ...` for wire-format
+ confidence.
+3. **Event** β `WorldSession.StateUpdated` fires with the parsed value.
+4. **Subscribe** β `GameWindow.OnLiveStateUpdated` (added to the live-
+ session attach block alongside `OnLiveVectorUpdated`) calls
+ `_physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState)`.
+5. **Mutate** β `ShadowObjectRegistry.UpdatePhysicsState` walks every
+ per-cell list the entity occupies and rewrites `list[i] with { State = newState }`.
+6. **Per-tick diagnostic** (same probe flag) β emits
+ `[setstate] guid=0x... state=0x... instSeq=... stateSeq=...` for the
+ greppable trail.
+7. **Resolver** β next physics tick, `FindObjCollisions` calls
+ `CollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState)`
+ on the entity. The check is unchanged from L.2d slice 1; it
+ short-circuits when `(state & ETHEREAL_PS) != 0 && (state & IGNORE_COLLISIONS_PS) != 0`.
+
+**Slice 0.5 freebie folded in:** all 6 `[entity-source]` probe-log
+sites in `GameWindow.cs` now emit `state=0x{state:X8} flags={flags}`
+so ETHEREAL flips are greppable end-to-end from spawn through state
+change.
+
+---
+
+## Why the visual test is deferred β the B.4 discovery
+
+Before launching the visual test, the user reported that right-click
+in-client was bound to camera orbit (correctly), and asked whether
+left-click should open a door. Investigation produced this finding:
+
+| Component | State |
+|---|---|
+| `InteractRequests.BuildUse(seq, guid)` wire builder | β
implemented + tested |
+| `SelectionState`, `WorldPicker` classes | β
exist in source |
+| `InputAction.SelectLeft` / `SelectDblLeft` / `SelectRight` enum | β
defined |
+| KeyBindings: LMB β `SelectLeft`, LMB-dblclick β `SelectDblLeft`, RMB β `SelectRight` | β
wired in `KeyBindings.cs:300-320` |
+| `GameWindow.OnInputAction` switch case for `Select*` | β **missing** |
+| Any production caller of `SelectionState`, `WorldPicker`, `InteractRequests.BuildUse` | β **none in `src/`** |
+
+The diagnostic line `[input] SelectLeft Press` fires on LMB-click β the
+dispatcher knows the action β but nothing downstream listens. The
+click silently does nothing. The R hotkey similarly does nothing
+because the corresponding `UseSelected` case is also absent from the
+switch.
+
+So the M1 outbound Use path is **half-shipped**: every component below
+the handler exists, but the handler that ties them together was never
+landed (despite a 2026-04-28 memory entry claiming "B.4 shipped").
+Phase B.4b is the slice that fixes this.
+
+This is **not** an L.2g defect. L.2g's code path is correct and unit-
+tested; it just can't be exercised at runtime until the outbound Use
+sends a SetState-triggering request to the server.
+
+---
+
+## Open notes from reviews (minor β defer to next polish pass)
+
+The per-commit and final integration code reviews approved every commit.
+Four observations flagged as Minor that are worth folding into a future
+polish pass:
+
+1. **`SetState.cs` "total body size" phrasing diverges from `VectorUpdate.cs`.**
+ New form: `"Total body size: 16 bytes (4-byte opcode + 12-byte payload)"`.
+ Sibling form: `"Total body size after opcode: 32 bytes"`. The new form
+ is more self-documenting, but the spec asked to align with the
+ sibling. Cosmetic.
+2. **`[setstate-hex]` log uses redundant `Math.Min(body.Length, 32)`.**
+ Called twice in the same line; could be hoisted to a local.
+ Harmless for a one-shot diagnostic.
+3. **`WorldSession.cs` uses the fully-qualified
+ `AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled`** instead
+ of adding `using AcDream.Core.Physics;` and using the short form.
+ Every other call site in `GameWindow.cs` and `BSPQuery.cs` uses the
+ unqualified form. Style inconsistency.
+4. **`[setstate]` diagnostic emits guid + state as hex but instSeq +
+ stateSeq as decimal.** Cosmetic.
+
+### One Important review note (worth following up explicitly)
+
+The final integration reviewer flagged: the test
+`UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits` asserts the
+cached state changes to `0x4` but does **not** verify the chain
+through `CollisionExemption.ShouldSkip`. That short-circuit requires
+**both** `ETHEREAL_PS (0x4)` AND `IGNORE_COLLISIONS_PS (0x10)` to be
+set simultaneously (`(state & 0x4) && (state & 0x10)`). A state of
+`0x4` alone does NOT exempt collision. Per the reviewer, ACE's
+`PhysicsObj.cs:787-791` may set both bits when doors open (broadcast
+value `0x14` or higher) β but this is not verified by the test suite.
+
+**The B.4b visual test will settle this definitively:** the slice-1
+hex-dump probe will capture the real `state=0x????????` wire value the
+first time a door opens. If ACE sends `0x14` or higher, the existing
+chain works as-is. If ACE sends `0x4` only, we need a tiny adjustment
+to `CollisionExemption.cs` (the `&&` would become `||`, OR we make the
+collision exemption fire on ETHEREAL alone, OR we widen the test).
+
+**Action for B.4b session:** after the door-open visual test, grep the
+launch log for `[setstate-hex]` and the `[setstate]` line that fires on
+the Use β confirm the state bits ACE actually sends. If `0x4` only,
+file a tiny L.2g slice 1b to widen `CollisionExemption.ShouldSkip` or
+the test's assertion.
+
+---
+
+## Next session
+
+**Pick: Phase B.4b β finish the outbound Use handler wiring.**
+
+Concretely:
+- Subscribe `InputAction.SelectDblLeft` in `GameWindow.OnInputAction`
+ switch.
+- Build a world ray from current mouse position
+ (`WorldPicker.BuildRay(mouse, vp, view, proj)`).
+- Pick the closest entity (`WorldPicker.Pick(ray, entities, cache, skipGuid, maxDist)`).
+- Store result in `_selection` (`SelectionState.Set(guid)`).
+- Call `InteractRequests.BuildUse(seq, guid)` + `_liveSession.SendGameMessage(body)`.
+- Probably also subscribe `InputAction.SelectLeft` for select-without-
+ use (single-click selects; double-click selects + uses).
+- Optionally subscribe `InputAction.UseSelected` (R hotkey) to send Use
+ on the already-selected guid.
+- Sequence-number management β there's a game-action sequence counter
+ on `WorldSession` already used by the outbound chat path; reuse it.
+
+Estimate: 30-50 LOC, 1-2 subagent-driven implementations + reviews, ~30 min.
+
+Once B.4b lands, **immediately re-run the Holtburg inn doorway visual
+test** with `ACDREAM_PROBE_BUILDING=1`. Both L.2g slice 1 + B.4b are
+verified by the same scenario; no separate L.2g visual test needed.
+
+---
+
+## Reproducibility
+
+Same launch recipe as L.2d slice 1 (see CLAUDE.md "Running the client
+against the live server"). For visual test once B.4b lands:
+
+```powershell
+$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 -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-l2g+b4b.log"
+```
+
+Then walk into the Holtburg inn doorway, double-left-click the door,
+wait for the swing animation, walk through. After 30s, watch the
+auto-close fire.
+
+After closing the client:
+
+```powershell
+Select-String -Path launch-l2g+b4b.log -Pattern "setstate-hex|setstate.*guid|entity-source.*Door|input.*SelectDblLeft"
+```
+
+Expected matches:
+- One `[setstate-hex] body.len=16 ...` line (confirms holtburger's 12-byte payload).
+- One `[entity-source] name=Door ... state=0x00000000 flags=None ...` at spawn.
+- An `[input] SelectDblLeft Press` when you double-click.
+- A `[setstate] guid=0x000F... state=0x????????` after the door opens.
+- A second `[setstate] guid=0x000F... state=0x00000000` ~30s later when auto-close fires.
+
+---
+
+## Worktree state at handoff
+
+- Branch `claude/gallant-mestorf-3bf2e3` ready to merge to main.
+- 6 commits ahead of main: `2c10dd4` (spec + docs), `869677b` (plan),
+ `2459f28` / `d538915` / `536a608` / `108e386` (L.2g slice 1 code).
+- One launch.log artifact (`launch-l2g-slice1.log`) in the working
+ tree from the attempted visual test β **not committed** (gitignored
+ or transient). Safe to discard; B.4b will produce a fresh log.
+
+User wants to start a fresh session for B.4b.
diff --git a/docs/research/2026-05-13-b4b-shipped-handoff.md b/docs/research/2026-05-13-b4b-shipped-handoff.md
new file mode 100644
index 0000000..ea4c8a0
--- /dev/null
+++ b/docs/research/2026-05-13-b4b-shipped-handoff.md
@@ -0,0 +1,417 @@
+# Phase B.4b shipped β handoff (visual-verified 2026-05-13)
+
+**Date:** 2026-05-13.
+**Branch:** `claude/compassionate-wilson-23ff99` (ready to merge to main; do NOT merge here β controller handles that after code review).
+**Predecessors:**
+- [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](2026-05-12-l2g-slice1-shipped-handoff.md) β L.2g slice 1 ship handoff that discovered the B.4 handler gap and deferred the Holtburg visual test to B.4b.
+- [docs/superpowers/specs/2026-05-13-phase-b4b-design.md](../superpowers/specs/2026-05-13-phase-b4b-design.md) β B.4b design spec.
+- [docs/superpowers/plans/2026-05-13-phase-b4b-plan.md](../superpowers/plans/2026-05-13-phase-b4b-plan.md) β B.4b implementation plan (6 tasks; Tasks 1-4 per plan + 2 bonus sets beyond the plan).
+
+---
+
+## TL;DR
+
+Phase B.4b **shipped end-to-end and is visual-verified 2026-05-13.** The M1
+demo target *"open the inn door"* is met. 9 commits on this branch implement
+and fix the complete round-trip: double-click door β `WorldPicker.Pick` β
+`InteractRequests.BuildUse` β ACE broadcasts `SetState (0xF74B)` with
+`ETHEREAL` bit β `ShadowObjectRegistry.UpdatePhysicsState` (L.2g slice 1)
+mutates cached state β `CollisionExemption.ShouldSkip` exempts the door β
+player walks through.
+
+The plan estimated "30-50 LOC, 1-2 subagent dispatches, ~30 min."
+Visual testing surfaced **four bonus discoveries** beyond the plan's
+Tasks 1-4:
+
+1. `InputDispatcher` had no double-click detection (the `SelectDblLeft`
+ binding was dead code β the dispatcher never produced `DoubleClick`
+ activations).
+2. `OnInputAction`'s early-return gate discarded `DoubleClick` activations
+ before the switch reached the `SelectDblLeft` case.
+3. L.2g `CollisionExemption.ShouldSkip` required **both** `ETHEREAL` +
+ `IGNORE_COLLISIONS` bits, but ACE's `Door.Open()` sends only `ETHEREAL`
+ (`state=0x0001000C`).
+4. `OnLiveStateUpdated` passed a server GUID to `ShadowObjectRegistry` which
+ is keyed by local entity ID β the registry lookup always missed β no-op
+ β the door never became passable. **This was the actual blocker the user
+ reported.**
+
+Fixes 1-4 were shipped as bonus commits 5-9 beyond the plan's Tasks 1-4.
+L.2g slice 1 and B.4b are now both fully verified by the same visual test.
+Issue #57 is closed. Issue #58 (door swing animation) is filed as M1-deferred
+polish.
+
+---
+
+## What shipped on this branch
+
+| # | Commit | Subject | Task |
+|---|---|---|---|
+| 1 | `f0b3bd9` | `feat(B.4b): WorldPicker.BuildRay β mouse-to-world ray unprojection` | Task 1 |
+| 2 | `221b641` | `feat(B.4b): WorldPicker.Pick β ray-sphere entity pick` | Task 2 |
+| 3 | `5821bdc` | `fix(B.4b): WorldPicker.Pick β handle inside-sphere origin + document normalize contract` | Task 2 review fix |
+| 4 | `7b4aff2` | `refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid` | Task 3 |
+| 5 | `89d82e1` | `feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker` | Task 4 |
+| 6 | `242ce70` | `feat(B.4b): InputDispatcher detects double-clicks` | Bonus: Task 4b |
+| 7 | `58b95bc` | `fix(B.4b): let DoubleClick activation pass the OnInputAction gate` | Bonus: Task 4c |
+| 8 | `a6e4b57` | `fix(phys L.2g slice 1b): widen CollisionExemption to ETHEREAL alone` | L.2g slice 1b |
+| 9 | `08be296` | `fix(phys L.2g slice 1c): translate ServerGuid -> entity.Id for ShadowObjectRegistry` | L.2g slice 1c |
+
+Plus plan/spec commits earlier in the branch session:
+- `4a1c594` β B.4b design spec.
+- `ffa404d` β corrected file paths in spec (WorldPicker is in `AcDream.Core.Selection`, not `AcDream.App/Rendering`).
+- `179e441` β B.4b implementation plan (6 tasks).
+
+**Build:** clean. **Tests:** 4 new double-click detection tests (commit `242ce70`, all pass). Full suite: builds green, no regressions. L.2g slice 1's 6 tests continue to pass.
+
+---
+
+## What the code does end-to-end
+
+When the user double-left-clicks a door entity in the Holtburg inn doorway,
+the following chain fires:
+
+1. **Double-click detection** β `InputDispatcher.OnMouseDown` checks the
+ elapsed time since the previous `MouseLeft` press. If β€500ms, the
+ activation kind is `DoubleClick`; otherwise `Press`. This is new as
+ of commit `242ce70`; prior to this the `SelectDblLeft` binding was dead
+ code (the dispatcher never produced `DoubleClick` activations).
+
+2. **Action dispatch** β `InputDispatcher` resolves the chord
+ `[MouseLeft, DoubleClick]` β `InputAction.SelectDblLeft` + activation
+ `DoubleClick`. The multicast `InputAction` event fires, logged as:
+ `[input] SelectDblLeft DoubleClick`.
+
+3. **OnInputAction gate** β `GameWindow.OnInputAction` receives the event.
+ Prior to commit `58b95bc`, an early-return guard (`if (activation != Press) return;`)
+ discarded all `DoubleClick` events. The fix widens the gate to
+ `if (activation != Press && activation != DoubleClick) return;`.
+ The switch now reaches the `SelectDblLeft` case.
+
+4. **Ray construction** β `WorldPicker.BuildRay(mousePos, viewport, viewMatrix, projMatrix)`
+ unprojects the cursor pixel into a world-space ray origin + direction,
+ using standard NDCβviewβworld unprojection. Numerically: the mouse pixel
+ is mapped to `[-1,+1]` NDC, transformed through `inverse(proj)` to get
+ a view-space direction, then through `inverse(view)` for world-space.
+
+5. **Entity pick** β `WorldPicker.Pick(ray, entities, maxDist=50m)` iterates
+ all entities in `_gpuWorldState.GetAllEntities()`, tests each against a
+ ray-sphere intersection with the entity's bounding radius, and returns
+ the closest hit. A special-case inside-sphere origin guard (commit `5821bdc`)
+ ensures the pick works even when the cursor origin is already inside an
+ entity's bounding sphere (common for large portals or doors at close range).
+ `[B.4b] pick guid=0x7A9B4015 name=Door` logged on hit.
+
+6. **Use message** β `GameWindow` stores `_selectedGuid = picked.Guid` and
+ calls `InteractRequests.BuildUse(seq, guid)`. The resulting `0xF7B1 / 0x0036`
+ game message is sent to ACE via `_liveSession.SendGameMessage(body)`.
+ `[B.4b] use guid=0x7A9B4015 seq=N` logged.
+
+7. **ACE processes the Use** β ACE's `Door.Open()` flips the door's physics
+ flags to `ETHEREAL | ...` and broadcasts `SetState (0xF74B)` with the
+ new state value.
+
+8. **SetState arrives** β `WorldSession.OnSetState` parses the 12-byte
+ payload (Guid + PhysicsState + InstanceSeq + StateSeq) and fires
+ `WorldSession.StateUpdated`. `GameWindow.OnLiveStateUpdated` handles it.
+ **New as of commit `08be296` (slice 1c):** the handler translates
+ `parsed.Guid` (server GUID `0x7A9B4015`) to `entity.Id` (local entity ID
+ `0x000F4245`) via `_entitiesByServerGuid` before calling
+ `ShadowObjectRegistry.UpdatePhysicsState`. Without this translation the
+ registry lookup always returned "not found" β a silent no-op.
+ Log: `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C`.
+
+9. **Collision exemption** β next physics tick, `FindObjCollisions` calls
+ `CollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState)`.
+ **New as of commit `a6e4b57` (slice 1b):** the check fires on
+ `(state & ETHEREAL_PS) != 0` alone (widened from the original `ETHEREAL &&
+ IGNORE_COLLISIONS` conjunction). Because ACE broadcasts only `ETHEREAL`
+ in the low bits (`state=0x0001000C`), the original conjunction never fired;
+ the door stayed solid.
+
+10. **Player walks through** β the resolver produces no wall-contact response
+ for the door's collision geometry. User confirms: "Now I can walk through."
+
+### Observed log evidence
+
+```
+[input] SelectDblLeft DoubleClick
+[B.4b] pick guid=0x7A9B4015 name=Door
+[B.4b] use guid=0x7A9B4015 seq=N
+[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C
+```
+
+Player walks through the closed door after the `setstate` line.
+
+---
+
+## The four bonus discoveries
+
+### 1. InputDispatcher had no double-click detection (`242ce70`)
+
+**Root cause:** `InputDispatcher.OnMouseDown` only looked up `Press` and
+`Hold` activations in the binding table. The `SelectDblLeft` binding was
+wired to the chord `[MouseLeft, DoubleClick]` in `KeyBindings.cs:300-320`
+(shipped in B.4, 2026-04-28), but the dispatcher's mouse-down handler
+never set activation to `DoubleClick` β it always produced `Press`.
+So `SelectDblLeft` was literally unreachable: the chord required
+`DoubleClick` to match, but the dispatcher never generated it.
+
+**Fix:** Added a `_lastMouseDownTime` (and `_lastMouseDownButton`) tracker
+to `InputDispatcher`. In `OnMouseDown`, if the same button fires within
+500ms of its last press, activation is `DoubleClick`; otherwise `Press`.
+500ms matches the standard Windows/macOS double-click threshold.
+
+**Rationale:** The fix is minimal and correct. A more faithful retail
+implementation might read the OS's configured double-click interval, but
+500ms is the retail default and was the right call for now. 4 new unit
+tests cover the timing logic: first click = Press, second click within
+500ms = DoubleClick, third click = Press again (resets the window), and
+button mismatch = Press.
+
+### 2. OnInputAction gate discarded DoubleClick activations (`58b95bc`)
+
+**Root cause:** Even after discovery #1 was fixed and `SelectDblLeft DoubleClick`
+fired from the dispatcher, the event handler had an early-return guard at
+the top of `GameWindow.OnInputAction`:
+
+```csharp
+if (activation != InputActivation.Press) return;
+```
+
+This guard was introduced to prevent `Hold` repetition from triggering
+switch cases intended for one-shot actions. It correctly blocked `Hold`
+but also blocked `DoubleClick` β so the `SelectDblLeft` case was still
+unreachable even after the dispatcher started generating `DoubleClick`.
+
+**Fix:** Widened the guard to let both `Press` and `DoubleClick` through:
+
+```csharp
+if (activation != InputActivation.Press && activation != InputActivation.DoubleClick) return;
+```
+
+**Rationale:** `DoubleClick` is semantically a one-shot activation (fires
+once per double-click gesture), so it belongs in the same pass-through
+group as `Press`. `Hold` repetition remains blocked.
+
+### 3. CollisionExemption required both ETHEREAL + IGNORE_COLLISIONS (`a6e4b57`)
+
+**Root cause:** The original `CollisionExemption.ShouldSkip` check was
+ported faithfully from `acclient_2013_pseudo_c.txt:276782`, which requires
+**both** `ETHEREAL_PS (0x4)` and `IGNORE_COLLISIONS_PS (0x10)` to be set
+simultaneously before short-circuiting collision detection. Retail servers
+send both bits when opening a door, so retail clients see `state β₯ 0x14`.
+
+However, ACE's `Door.Open()` broadcasts only the `ETHEREAL` bit in the
+low portion of the state word. The observed wire value was
+`state=0x0001000C`: bit `0x4` (ETHEREAL) is set, bit `0x10`
+(IGNORE_COLLISIONS) is not. The `&&` conjunction in `ShouldSkip` evaluated
+to false β door stayed solid even after the registry update.
+
+This was the exact scenario the L.2g slice 1 Important review note warned
+about (see L.2g handoff Β§"One Important review note"): *"ACE's
+`PhysicsObj.cs:787-791` may set both bits... but this is not verified by
+the test suite. The B.4b visual test will settle this definitively."*
+It settled as: ACE sends `0x4` alone, not `0x14`.
+
+**Fix:** Widened the short-circuit to fire on `ETHEREAL` alone:
+
+```csharp
+// Widened from (ETHEREAL && IGNORE_COLLISIONS) β ACE Door.Open() sends
+// ETHEREAL alone (state=0x0001000C); retail servers send both.
+// Pragmatic choice: exempt on ETHEREAL-bit-alone until full retail
+// obstruction_ethereal flag path is ported.
+if ((state & ETHEREAL_PS) != 0) return true;
+```
+
+**Rationale:** The deeper retail path (pseudo-C line 276795 sets
+`obstruction_ethereal=1` and routes through downstream movement handling)
+was not ported β that's a more invasive change requiring more testing. The
+pragmatic widening to ETHEREAL alone is correct for ACE's Door behavior and
+matches the spirit of the retail check (ETHEREAL means "pass through me").
+If a future retail-server emulator sends both bits, the widened check still
+fires (ETHEREAL is a subset of ETHEREAL+IGNORE_COLLISIONS).
+
+### 4. ServerGuid β entity.Id translation missing in OnLiveStateUpdated (`08be296`) β THE actual blocker
+
+**Root cause:** `ShadowObjectRegistry` is keyed by local `entity.Id` (the
+per-session integer ID assigned by `GpuWorldState` at entity registration,
+e.g. `0x000F4245`). The `GameWindow.OnLiveStateUpdated` handler was passing
+`parsed.Guid` β the **server GUID** broadcasted in the `SetState` packet
+(e.g. `0x7A9B4015`) β directly to `UpdatePhysicsState`. Because the registry
+has no entry keyed by server GUID, the lookup always returned "not found"
+and the state mutation was silently dropped. The registry stayed at
+`state=0x00000000` (closed, solid) regardless of how many times the door
+was clicked.
+
+This is why discoveries 1-3 alone were insufficient: even with double-click
+detection working, the correct gate firing, and `CollisionExemption`
+widened, the registry still held the stale closed state and the door
+stayed solid.
+
+**Fix:** Used the pre-existing `_entitiesByServerGuid` reverse-lookup
+dictionary on `GameWindow` (populated at entity registration in
+`OnLiveCreateObject` since Phase 6.6/6.7). `OnLiveStateUpdated` now does:
+
+```csharp
+if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
+ _physicsEngine.ShadowObjects.UpdatePhysicsState(entity.Id, parsed.PhysicsState);
+```
+
+The `entityId=` field was added to the `[setstate]` diagnostic log line
+specifically to make this translation visible and greppable:
+`[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C`.
+
+**Why this was missed:** L.2g slice 1's unit tests operated at the
+`ShadowObjectRegistry` level directly, calling `UpdatePhysicsState` with
+an `entity.Id` (not a server GUID). The integration was never exercised
+end-to-end before B.4b's visual test. The two tests `UpdatePhysicsState_FlipsEthereal_*`
+were correct in isolation; the broken layer was one level above them
+(the handler β registry call site).
+
+**Why the "multiple doors" misdiagnosis occurred:** Before slice 1c was
+identified, the `[resolve]` probes showed wall hits attributed to
+`obj=0x000F4245` while the clicked door's ServerGuid was `0x7A9B4015`.
+Initial read: "these are two different entities blocking the threshold."
+Slice 1c clarified: both IDs refer to the same door β `0x000F4245` is
+the local entity ID, `0x7A9B4015` is the server GUID for the same entity.
+The ID-space mismatch was the cause of both the collision-not-clearing
+AND the "different object" misread.
+
+---
+
+## Open notes / follow-ups
+
+### Door swing animation (#58)
+
+When ACE opens a door it broadcasts **two** packets, not one:
+
+1. `SetState (0xF74B)` β the collision-bit flip. **Handled by L.2g slice 1.**
+2. `UpdateMotion (0xF74D)` with stance/command `(NonCombat, On)` β the
+ swing animation cycle. **NOT handled.**
+
+acdream's `UpdateMotion` pipeline is currently scoped to player + creature
+animation (Phase L.3). Non-creature entities like doors do not receive
+cycle commands. The door therefore opens (becomes passable) but has no
+visible swing animation.
+
+Filed as **issue #58**. Scope is unknown β routing `UpdateMotion` to
+non-creature `WorldEntity` instances could be quick (few lines), or the
+`AnimationSequencer` may have creature-specific assumptions that require
+audit first. Filed as M1-deferred polish; it does not block the demo
+scenario.
+
+### Door toggle behavior
+
+ACE doors toggle on each Use: first double-click opens, subsequent
+double-click closes (re-sends `SetState` with `state=0x00000000`, restoring
+collision). This is correct ACE behavior and matches retail. No issue to file.
+
+Rapid double-clicks (faster than ACE's server-tick processing) will open
+then close in quick succession β each Use lands as a distinct game action.
+Expected behavior; no fix needed.
+
+### Multiple-door misdiagnosis (historical note)
+
+While slice 1c was still unidentified, the `[resolve]` diagnostic showed:
+
+```
+[resolve] ... obj=0x000F4245 wall hit
+[B.4b] use guid=0x7A9B4015 ...
+[setstate] guid=0x7A9B4015 state=0x0001000C
+[resolve] ... obj=0x000F4245 wall hit (unchanged!)
+```
+
+Initial misdiagnosis: there must be a *different* door entity (`0x000F4245`)
+blocking the threshold whose state was never updated. Slice 1c revealed:
+both IDs refer to the same door β one is the server GUID (network space),
+the other is the local entity ID (registry space). The registry update was
+targeting the server GUID (which missed), so the local-ID-keyed entry
+stayed solid.
+
+### Selection HUD / hover-highlight / brackets
+
+Out of B.4b scope per design spec Β§Non-goals. The `_selectedGuid` field on
+`GameWindow` is populated (stores the last-picked entity's server GUID), but
+nothing renders a selection bracket, hover highlight, or target nameplate.
+That is M2/M3 HUD work (Phase D.6).
+
+### BuildPickUp (F key) + UseWithTarget UX
+
+`InteractRequests.BuildPickUp` exists (as an alias of `BuildUse`). The
+`SelectionPickUp` input action and the F-key binding exist. But
+`OnInputAction` has no case for `SelectionPickUp` β pick-up-by-F-key is
+still unimplemented. Same for `UseWithTarget` (requires a secondary target
+selection UX). Both deferred to a follow-up phase; not M1-blocking.
+
+---
+
+## Next session
+
+**M1 demo progress as of this branch:**
+- β
"walk through Holtburg without getting stuck" β Phase L.2 in progress (outdoor collision works; CBuildingObj interior still deferred to L.2d).
+- β
"open the inn door" β **done** (B.4b, this branch).
+- β¬ "click an NPC" β pick + Use wiring exists now; depends on ACE NPC handler responding to Use.
+- β¬ "pick up an item" β `BuildPickUp` + F-key wiring not yet in `OnInputAction`.
+
+**Recommended next steps (in M1 critical-path order):**
+
+1. **Door swing animation (#58)** β cosmetic M1 polish. Route
+ `UpdateMotion (0xF74D)` to non-creature entities so the door visually
+ swings. Could be quick (30 min) or moderate (2 hrs with AnimationSequencer
+ audit). Worth a spike before committing to an estimate.
+
+2. **Chronic open-issue triage** β #2 (lightning), #4 (horizon-glow), #28
+ (aurora), #29 (cloud thinness), #37 (humanoid coat), #41 (remote-motion
+ blips) have been deferred since April/early-May. Link each to a future
+ phase or downgrade. ~1 hour. Not M1-blocking but surfaces the real backlog.
+
+3. **More Phase C visual-fidelity** β C.2 (dynamic point lights), C.3
+ (palette tuning), C.4 (double-sided translucent polys). World still reads
+ "old" without local lighting on fireplaces/lamps.
+
+---
+
+## Reproducibility
+
+Same launch recipe as before. For reproducing the visual test:
+
+```powershell
+$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 -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-b4b.log"
+```
+
+Walk to the Holtburg inn doorway. Double-left-click the closed Door. Walk
+through. Subsequent double-clicks will close and re-open (ACE toggle).
+
+After closing the client, grep for:
+
+```powershell
+Select-String -Path launch-b4b.log -Pattern "SelectDblLeft|pick guid|use guid|setstate.*entityId"
+```
+
+Expected:
+- `[input] SelectDblLeft DoubleClick` β dispatcher fires on second click within 500ms.
+- `[B.4b] pick guid=0x7A9B4015 name=Door` β ray hits the door.
+- `[B.4b] use guid=0x7A9B4015 seq=N` β Use message sent.
+- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` β ACE reply processed, translation confirmed.
+
+---
+
+## Worktree state at handoff
+
+- Branch `claude/compassionate-wilson-23ff99`.
+- 9 implementation commits + 3 plan/spec commits ahead of `eea9b4d`
+ (the L.2g slice 1 merge from the previous session).
+- Controller should run a code review, then merge to main.
+- Do NOT rebase or squash β each commit tells a diagnostic story that
+ the next phase's debugging may need.
diff --git a/docs/research/2026-05-13-b4c-shipped-handoff.md b/docs/research/2026-05-13-b4c-shipped-handoff.md
new file mode 100644
index 0000000..862e4be
--- /dev/null
+++ b/docs/research/2026-05-13-b4c-shipped-handoff.md
@@ -0,0 +1,346 @@
+# Phase B.4c shipped β handoff (visual-verified 2026-05-13)
+
+**Date:** 2026-05-13.
+**Branch:** `claude/phase-b4c-door-anim` (ready to merge to main; do NOT merge here β controller handles that after code review).
+**Predecessors:**
+- [docs/research/2026-05-13-b4b-shipped-handoff.md](2026-05-13-b4b-shipped-handoff.md) β B.4b shipped handoff; interaction was the upstream dependency (Use message, SetState handling, collision exemption, double-click detection β all shipped there).
+- [docs/superpowers/specs/2026-05-13-phase-b4c-design.md](../superpowers/specs/2026-05-13-phase-b4c-design.md) β B.4c design spec.
+- [docs/superpowers/plans/2026-05-13-phase-b4c-plan.md](../superpowers/plans/2026-05-13-phase-b4c-plan.md) β B.4c implementation plan (4 tasks).
+
+---
+
+## TL;DR
+
+Phase B.4c **shipped end-to-end and is visual-verified 2026-05-13.** The M1
+demo target *"open the inn door"* now has **full visual feedback** β the door
+swings open when double-clicked and swings closed again when ACE toggles it
+back. 4 implementation commits implement and fix door-specific spawn-time
+`AnimationSequencer` registration + `UpdateMotion` routing + stance-value
+correctness.
+
+The plan estimated "2 tasks, door spawn-time registration + UM diagnostic."
+Visual testing surfaced **two bonus discoveries** beyond the plan:
+
+1. The plan's `NonCombatStance` constant was wrong: `0x80000001` (from
+ creature motion table conventions) should be `0x8000003D` (from AC's
+ `MotionStance.NonCombat = 0x0000003D`). Wrong constant β wrong
+ `HasCycle` lookup β `SetCycle` never fires β sequencer empty β
+ per-frame part rebuild collapses to entity origin β doors render halfway
+ underground.
+2. The `AnimationSequencer`'s linkβcycle boundary transition produces a
+ brief one-frame flash through the prior pose at the end of the door-swing
+ animation. Not B.4c-specific β it is the sequencer's general link+cycle
+ queue mechanics. Deferred as issue #61.
+
+Issue #58 (door swing animation) is closed. Issues #61 + #62 (cycle-boundary
+flash; PARTSDIAG null-guard) are filed as M1-deferred polish.
+
+---
+
+## What shipped on this branch
+
+| # | Commit | Subject | Task |
+|---|---|---|---|
+| 1 | `9053860` | `feat(B.4c): door spawn-time AnimationSequencer with state-seeded initial cycle` | Task 1 |
+| 2 | `b89f004` | `feat(B.4c): [door-cycle] diagnostic in OnLiveMotionUpdated` | Task 2 |
+| 3 | `8a9b15e` | `refactor(B.4c): share IsDoorName predicate + durable comment + use UM locals` | Task 2 review |
+| 4 | `454d88e` | `fix(B.4c): correct NonCombat stance value (0x3D, not 0x01) + read spawn.MotionState` | Bonus: stance fix |
+
+Plus plan/spec commits earlier in the branch session:
+- `b4f131e` β B.4c design spec.
+- `6ae38f7` β B.4c implementation plan (4 tasks).
+
+**Build:** clean. **Tests:** existing test suite passes; no new unit tests added
+(the door-cycle registration path runs in-process with a live GameWindow; pure
+unit tests would require a MotionTable + AnimationSequencer integration harness).
+
+---
+
+## What the code does end-to-end
+
+When the world loads, any entity whose name contains "Door" (checked via the
+shared `GameWindow.IsDoorName(string)` helper, committed as part of Task 2
+review) is registered in the **door-animation side-track** at spawn time. This
+happens inside `GameWindow.OnLiveEntitySpawnedLocked`, which branches on
+`IsDoorSpawn(spawn)` before reaching the standard creature/player paths.
+
+### At world load (spawn time)
+
+1. `IsDoorSpawn(spawn)` β delegates to `IsDoorName(spawn.Name)`, which
+ returns `name == "Door"`. Detection by server-sent name string only.
+ Cheap, exact, no WeenieType lookup. If a future ACE localizes "Door"
+ or sends a different name, those entities silently won't animate β
+ acceptable per B.4c's "doors only at English Holtburg" scope.
+
+2. **Initial state seed** β the door's `PhysicsState` from `spawn` carries the
+ open/closed bit. The code reads `spawn.PhysicsState` (or
+ `spawn.MotionState?.Stance` as a fallback for unusual doors with explicit
+ stance data) to determine whether to seed the sequencer with the `Off`
+ (closed) or `On` (open) cycle.
+
+3. **AnimationSequencer registration** β a fresh `AnimationSequencer` is
+ created for the door entity's `MotionTableId` (from `spawn`). Then:
+ ```csharp
+ var style = 0x80000000u | (uint)MotionStance.NonCombat; // = 0x8000003D
+ var cycleCmd = isOpen ? MotionCommand.On : MotionCommand.Off;
+ sequencer.SetCycle(style, (uint)cycleCmd, speed: 0f);
+ ```
+ The fully-initialized `AnimatedEntity` (with the seeded `Sequencer`) is
+ registered into the existing `_animatedEntities` dict keyed by `entity.Id`
+ β same dict that holds creatures and the player. `Animation = null!`
+ (the null-forgiving suppression matches an existing pattern at
+ `GameWindow.cs:7885` for sequencer-driven entities where the legacy
+ `Animation` field is unused). At the first per-frame `Advance(dt)`
+ call from `TickAnimations`, the sequencer produces the correct
+ rest-pose frames for the door's current state.
+
+4. **Log evidence at spawn:**
+ ```
+ [door-anim] registered guid=0x7A9B403A entityId=0x000F4291 mtable=0x09000202 initialStyle=0x8000003D initialCycle=0x4000000C
+ ```
+ `0x4000000C` = `MotionCommand.Off` with the upper flag bits β the door is
+ closed at spawn, matching the initial world state.
+
+### When the door opens (UpdateMotion arrives)
+
+ACE broadcasts `UpdateMotion (0xF74D)` with `stance=0x003D` (NonCombat) and
+wire `cmd=0x000C` (which `MotionCommandResolver.ReconstructFullCommand`
+maps to full motion `0x4000000B` = `MotionCommand.On` = door open).
+
+B.4c does NOT add a new dispatch path here β the existing
+`OnLiveMotionUpdated` handler already routes via the `_animatedEntities`
+dict + per-entity `Sequencer`, the same code path creatures use. The
+only B.4c contribution at UM dispatch is the new `[door-cycle]`
+diagnostic gated on `IsDoorName(doorInfo.Name)`. Before B.4c, doors
+silently dropped at the `_animatedEntities.TryGetValue` check at
+`GameWindow.cs:3036` because doors weren't registered; B.4c's Task 1
+spawn-time branch fixed that.
+
+The sequencer transitions from the `Off` cycle (static closed pose) through
+the door-swing link animation to the `On` cycle (static open pose).
+
+**Log evidence:**
+```
+UM guid=0x7A9B403A mt=0x00 stance=0x003D cmd=0x000C spd=0.00 | seq now style=0x8000003D motion=0x4000000B
+[door-cycle] guid=0x7A9B403A stance=0x003D cmd=0x000C
+```
+The `[door-cycle]` line is the new B.4c diagnostic (gated on
+`ACDREAM_PROBE_BUILDING=1`). The `seq now motion=0x4000000B` shows the
+sequencer's current motion state after the `SetCycle` call.
+
+### SetState chain (from B.4b + L.2g, unchanged)
+
+Simultaneously with `UpdateMotion`, ACE also sends `SetState (0xF74B)`:
+```
+[setstate] guid=0x7A9B... state=0x0001000C
+```
+This is the B.4b / L.2g chain: `ShadowObjectRegistry.UpdatePhysicsState` flips
+the door's cached state, `CollisionExemption.ShouldSkip` exempts on ETHEREAL-alone,
+and the player can walk through. B.4c is additive β it only adds the animation
+layer; it does not touch the collision path.
+
+### When the door closes
+
+ACE toggles on the next Use: `UpdateMotion` with `cmd=0x000B` (Off = close).
+The sequencer transitions from the `On` cycle (open pose) through the door-swing
+link animation (reversed) to the `Off` cycle (closed pose).
+
+**Log evidence:**
+```
+UM guid=0x7A9B403A mt=... cmd=0x000B ... motion=0x4000000C
+[door-cycle] guid=0x7A9B... cmd=0x000B
+[setstate] guid=0x7A9B... state=0x00010008
+```
+
+### Per-frame mesh rebuild
+
+The door sequencer integrates into `GameWindow.TickAnimations` via the same
+`_animatedEntities` dict that holds creatures. Each frame, `ae.Sequencer.Advance(dt)`
+is called and the resulting per-part transforms drive the same `MeshRefs` rebuild
+that creature entities use (sequencer branch at `GameWindow.cs:7497`; doors
+never enter the legacy slerp `else` branch). This is the reason the stance-value
+bug produced underground doors: with the wrong style key (`0x80000001`)
+`HasCycle` returned false, the sequencer was empty at spawn, `Advance` returned
+identity frames, and the per-frame part-matrix rebuild received `Vector3.Zero /
+Quaternion.Identity` for every part β collapsing them all to the entity origin.
+
+---
+
+## The two bonus discoveries
+
+### 1. NonCombatStance constant was wrong: 0x01 vs 0x3D (`454d88e`) β THE render blocker
+
+**Root cause:** The B.4c design spec specified the initial-cycle style key as:
+```csharp
+uint style = 0x80000000u | (uint)MotionStance.NonCombat; // spec said 0x80000001
+```
+The spec's comment was wrong. `MotionStance.NonCombat` in acdream (and retail)
+is `0x0000003D`, not `0x00000001`. The value `0x01` is a creature-specific
+variant. The style key for the door's cycle lookup must be `0x8000003D`.
+
+With the wrong style key:
+- `sequencer.HasCycle(0x80000001, MotionCommand.Off)` β false.
+- `SetCycle(0x80000001, ...)` enqueued a cycle that was never reachable.
+- On first `Advance(dt)`, the sequencer returned 0 part-frames.
+- The per-frame mesh rebuild at `GameWindow.cs:7691` iterated 0 frames, leaving
+ every door part at the entity root origin (which is the door's structural
+ pivot, typically near the hinge). For inn doors this pivot is at roughly
+ floor level, so all the door's mesh parts collapsed to that single point,
+ rendering as a thin sliver partway underground.
+
+**Fix:** Corrected the constant. Additionally, added a defensive read of
+`spawn.MotionState?.Stance` as the source of the stance value where available,
+so unusual doors with explicit motion state (possible in custom ACE content) use
+their actual stance rather than the hardcoded NonCombat assumption:
+
+```csharp
+var stance = spawn.MotionState?.Stance ?? MotionStance.NonCombat;
+uint style = 0x80000000u | (uint)stance;
+```
+
+**Verification:** After this fix, the `[door-anim]` log line showed
+`initialStyle=0x8000003D` (correct), and doors appeared at the correct floor
+level and height at world load.
+
+### 2. AnimationSequencer linkβcycle boundary flash (deferred as #61)
+
+**Observed:** User reports "weird flapping at end of animation when it opens.
+It is like it flaps back to closed quickly then open. Like really quickly."
+Both open and close animations exhibit this flash.
+
+**Root cause hypothesis:** `AnimationSequencer.SetCycle` enqueues a transition
+link (the actual swing animation) followed by the target cycle (the door's
+rest pose β likely a single-frame static "open" or "closed" pose). At the linkβ
+cycle boundary, the sequencer evaluates the cycle's frame 0 before the cycle
+settles into its natural rest position. If the link's last frame and the
+cycle's frame 0 don't match exactly (which is common for one-shot door motions
+versus the continuous idle cycles the sequencer was designed for), the renderer
+sees one frame of the "wrong" pose at the link boundary.
+
+**Why not B.4c-specific:** This is the sequencer's general link+cycle queue
+boundary semantics. Any entity that uses a one-shot `SetCycle` transition
+(rather than a continuous idle cycle) will exhibit this if the link/cycle
+boundary frames diverge. The door case just makes it visible because the
+swing duration is short (1-2 seconds) and the user is watching closely.
+
+**Deferred:** Filed as issue #61. Workaround: the flash is brief (~1 frame,
+~16ms at 60 FPS) and does not affect the door's usability. M1 is met without
+this fix.
+
+---
+
+## Open notes / follow-ups
+
+### #61 β AnimationSequencer linkβcycle frame-0 flash (filed this session)
+
+See Bonus discovery #2 above. Deferred as M1-deferred polish. Low severity.
+Acceptance: door swing animations play cleanly with no intermediate closed/open
+pose flash at the linkβcycle transition.
+
+### #62 β PARTSDIAG null-guard for sequencer-driven entities (filed this session)
+
+The PARTSDIAG block at `GameWindow.cs:7657` reads `ae.Animation.PartFrames`
+without a null-guard. B.4c introduced `Animation = null!` for sequencer-driven
+door entities. Today this is safe (doors never enter `_remoteDeadReckon` because
+ACE never sends UpdatePosition for them). Deferred as low-severity latent crash.
+One-line fix when addressed.
+
+### Chests, levers, traps
+
+The `IsDoorName` / `IsDoorSpawn` predicate correctly gates on door entities only.
+Other interactable non-creature entities (chests, levers, traps) will still
+silently drop their `UpdateMotion` commands β they are not covered by B.4c and
+no issue has been filed for them yet. When those animations become relevant
+(M2/M3 inventory + dungeon content), the same spawn-time registration pattern
+can be extended: broaden the detection predicate beyond `name == "Door"` and
+register additional entity types in the existing `_animatedEntities` dict via
+the same sibling branch.
+
+### Door toggle behavior
+
+Unchanged from B.4b. ACE doors toggle on each Use: first double-click opens,
+subsequent double-click closes. Both transitions now play the correct swing
+animation (open swing on open, close swing on close).
+
+---
+
+## Next session
+
+**M1 demo progress as of this branch:**
+- "Walk through Holtburg without getting stuck" β Phase L.2 in progress (outdoor collision works; `CBuildingObj` interior still deferred to L.2d).
+- "Open the inn door" β **DONE with full visual feedback** (B.4b interaction + B.4c animation, this branch). Door swings open AND closed.
+- "Click an NPC" β pick + Use wiring exists (from B.4b); depends on ACE NPC handler responding to Use correctly.
+- "Pick up an item" β `BuildPickUp` + F-key wiring not yet in `OnInputAction`. Post-B.4b/B.4c deferred.
+
+**Recommended next steps (in M1 critical-path order):**
+
+1. **"Click an NPC" verification spike** β B.4b's WorldPicker + Use messaging
+ is already wired. The question is whether ACE NPCs respond to Use and what
+ they broadcast back. A quick spike: stand near an NPC in Holtburg,
+ double-click, check what ACE sends back. If ACE sends recognizable response
+ messages, wire them; if it is silent, investigate ACE's NPC handler
+ configuration for testaccount.
+
+2. **Phase B.5 β Ground item pickup (F key)** β `SelectionPickUp` input action
+ + F-key binding exist but `OnInputAction` has no case. `BuildUse` is the
+ same wire format as `BuildPickUp`. Adding the `SelectionPickUp` case to
+ the switch and routing to `InteractRequests.BuildPickUp` is a one-commit
+ addition.
+
+3. **Triage chronic open-issue list** β #2 (lightning), #4 (sky horizon-glow),
+ #28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #41
+ (remote-motion blips) have been open since April/early-May. Link each to
+ a future phase or downgrade. ~1 hour.
+
+4. **#61 fix (cycle-boundary flash)** β low-severity M1 polish. If the user
+ finds the flash distracting during the M1 demo record, address before
+ milestone wrap; otherwise defer to M2 animation quality pass.
+
+---
+
+## Reproducibility
+
+Same launch recipe as B.4b. For reproducing the visual test:
+
+```powershell
+$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 -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-b4c.log"
+```
+
+Walk to the Holtburg inn doorway. Watch the `[door-anim]` lines appear in the
+log as each door entity spawns (verifies correct style=0x8000003D and initial
+cycle). Double-left-click a closed door. Watch the swing animation. Walk
+through. Wait ~30s (ACE auto-close). Watch the close animation.
+
+After closing the client, grep for:
+
+```powershell
+Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|setstate"
+```
+
+Expected:
+- `[door-anim] registered guid=... initialStyle=0x8000003D initialCycle=0x4000000C` β correct style + Off initial cycle for each closed door.
+- `[door-cycle] guid=... stance=0x003D cmd=0x000C` β open UpdateMotion processed.
+- `[setstate] guid=... state=0x0001000C` β ACE collision-flip processed (from B.4b / L.2g).
+- `[door-cycle] guid=... cmd=0x000B` β close UpdateMotion processed.
+- `[setstate] guid=... state=0x00010008` β ACE close collision-flip processed.
+
+---
+
+## Worktree state at handoff
+
+- Branch `claude/phase-b4c-door-anim`.
+- 6 commits ahead of `3e08e10` (the B.4b+L.2g merge from this morning):
+ 2 docs/spec/plan commits + 4 implementation commits.
+- Controller should run a code review, then merge to main.
+- Do NOT rebase or squash β each commit tells a diagnostic story that the
+ next phase's debugging may need.
diff --git a/docs/research/2026-05-13-b5-pickup-handoff.md b/docs/research/2026-05-13-b5-pickup-handoff.md
new file mode 100644
index 0000000..5013242
--- /dev/null
+++ b/docs/research/2026-05-13-b5-pickup-handoff.md
@@ -0,0 +1,235 @@
+# Phase B.5 β BuildPickUp + ground-item interaction β fresh-session handoff
+
+**Date:** 2026-05-13 evening (after B.4c ship).
+**Branch:** `claude/phase-b5-pickup` (renamed from `claude/investigate-npc-click`).
+**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\investigate-npc-click` (directory name kept; only the branch was renamed).
+**Predecessor on main:** `e7842e0` β `Merge branch 'claude/phase-b4c-door-anim' β Phase B.4c door swing animation`.
+
+---
+
+## TL;DR
+
+After B.4c shipped (doors visibly swing open/close), three of M1's four
+demo targets are met: *walk through Holtburg*, *open the inn door*, and
+likely *click an NPC* (per the investigation below; not yet
+visual-verified). The remaining target is *pick up an item*, which
+needs a new outbound wire builder + F-key handler in `GameWindow`.
+
+Phase **B.5** is the slice that closes M1's "click + pickup" demo
+path. Scope: ~50 LOC across two existing files (no new files).
+Implementation pattern mirrors B.4b's outbound Use chain.
+
+---
+
+## Investigation findings (carry forward)
+
+Before starting B.5 work, the controller agent investigated whether
+B.4b's existing `BuildUse` chain already handles "click an NPC". Code
+reading produced this answer: **yes, the basic chat-dialogue case
+should already work end-to-end with zero new code**. Verify
+opportunistically during B.5's visual test by clicking an NPC while
+in-world.
+
+Specifically:
+
+- **ACE's `Creature.ActOnUse`** at
+ `references/ACE/Source/ACE.Server/WorldObjects/Creature.cs:334`
+ defers to `base.OnActivate β EmoteManager.OnUse()`. The emote
+ manager walks the creature's emote table and emits `Tell`,
+ `CommunicationTransientString`, `Motion`, and other game events.
+- **All those events are already wired** in
+ `src/AcDream.Core.Net/GameEventWiring.cs`:
+ - `Tell (0x0027)` β `chat.OnTellReceived` (line 78)
+ - `CommunicationTransientString (0x028B)` β `chat.OnSystemMessage` (line 83)
+ - `WeenieError / WeenieErrorWithString` β `chat.OnSystemMessage` (lines 139, 144)
+ Plus `UpdateMotion (0xF74D)` is already routed for creature entities
+ via `OnLiveMotionUpdated`.
+- **`UseDone (0x01C7)`** β the completion ack β has a parser at
+ `GameEvents.ParseUseDone` but is **not registered** with the
+ dispatcher. Silent drop. Harmless for the basic demo (the chat
+ events arrive independently), but worth filing as a follow-up if not
+ picked up by B.5.
+
+**Conclusion:** click-NPC chain is wired; no code change needed for the
+M1 demo target 3 acceptance. Verify in-world during B.5's launch.
+
+---
+
+## B.5 scope (decisions already made)
+
+| Decision | Value | Rationale |
+|---|---|---|
+| Trigger | F-key (`InputAction.SelectionPickUp`) | Already bound at `KeyBindings.cs:172` |
+| Target selection | Requires `_selectedGuid` (B.4b's renamed field) | Mirrors retail F-key behavior + B.4b's `UseSelected` pattern. User single-clicks the ground item to select, then F to pickup. |
+| Wire opcode | `GameAction.PutItemInContainer (0x0019)` | ACE source: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs` |
+| Wire payload | 12 bytes: `itemGuid (u32) + containerGuid (u32) + placement (i32)` | Same source |
+| Container destination | The player's own server guid (`_playerServerGuid`) | Single-bag pickup; bag-specific destinations are M2+ work |
+| Placement value | 0 (let server pick slot) | Simplest; placement-control UI is M2+ |
+| Visual feedback | Toast + `[pickup]` log line | No inventory UI yet; the existing `WieldObject` / `InventoryPutObjInContainer` server events already update `ItemRepository` so the state is correct internally |
+| Pick under cursor fallback | **NO** | Out of scope per user decision. Strict select-first UX. |
+
+Brainstorm explicitly **NOT** done with the user (interrupted before
+the design sections were presented). The new session should re-confirm
+these decisions are still desired before writing the spec β or just
+proceed if they remain obviously right.
+
+---
+
+## Three changes B.5 needs to land
+
+1. **`src/AcDream.Core.Net/Messages/InteractRequests.cs`** β add
+ `BuildPickUp(uint gameActionSequence, uint itemGuid, uint containerGuid, int placement)`.
+ Pattern: same as the existing `BuildUseWithTarget` builder at line
+ 51 of that file. 20-byte total body (`0xF7B1 envelope + seq + opcode
+ 0x0019 + 12-byte payload`).
+
+2. **`src/AcDream.App/Rendering/GameWindow.cs`** β add a private helper
+ `SendPickUp(uint itemGuid)`:
+ - Gate on `_liveSession?.CurrentState == InWorld` (same pattern as
+ B.4b's `SendUse`).
+ - `seq = _liveSession.NextGameActionSequence()`.
+ - `body = InteractRequests.BuildPickUp(seq, itemGuid, _playerServerGuid, 0)`.
+ - `_liveSession.SendGameAction(body)`.
+ - Diagnostic: `Console.WriteLine($"[pickup] item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}")`.
+
+3. **`src/AcDream.App/Rendering/GameWindow.cs` `OnInputAction` switch**
+ β add `case InputAction.SelectionPickUp:` near the other `Select*` /
+ `UseSelected` cases (B.4b added those around line 8633+). Body:
+ `if (_selectedGuid is uint sel) SendPickUp(sel); else _debugVm?.AddToast("Nothing selected");`.
+
+That's the whole code change. ~50 LOC including diagnostics.
+
+---
+
+## Likely ID-translation gotcha (the L.2g slice 1c pattern)
+
+B.4b's L.2g slice 1c surfaced an ID-space mismatch: the **`BuildUse`**
+wire builder takes a `targetGuid` which is `entity.ServerGuid`, but
+`ShadowObjectRegistry` keys by `entity.Id`. For `BuildPickUp`:
+
+- `itemGuid` argument must be `entity.ServerGuid` (the server's
+ identifier β ACE looks it up in its world). β
B.4b's picker returns
+ `ServerGuid`, so `_selectedGuid` already carries the right value.
+- `containerGuid` argument must be `_playerServerGuid` (the server's
+ identifier for the player). β
Already a ServerGuid in `GameWindow`.
+
+So B.5 should NOT hit the same ID-mismatch trap L.2g slice 1c did. But
+re-check at implementation time.
+
+---
+
+## ACE inbound chain (already wired)
+
+After ACE processes a `BuildPickUp`, it broadcasts:
+
+- `0x019B InventoryPutObjInContainer` β moves the item record into the
+ player's container. Already wired to
+ `ItemRepository.MoveItem(itemGuid, containerGuid, placement)` at
+ `GameEventWiring.cs:239`.
+- `RemoveObject` for the world-spawned item β already wired (existing
+ despawn path removes the ground item from view).
+- Possibly `WieldObject` if the item auto-equips β already wired
+ (`GameEventWiring.cs:231`).
+
+No new inbound wiring needed for the minimum demo. The user will see:
+
+1. Click ground item β selection updates.
+2. Press F β diagnostic logs, packet sent.
+3. ACE processes; sends inventory + despawn events.
+4. Item disappears from ground.
+5. (No inventory UI yet, but item is in `ItemRepository`.)
+
+---
+
+## Acceptance criteria
+
+- [ ] `dotnet build` green.
+- [ ] `dotnet test` green: 1046 / 8 pre-existing-baseline fail
+ (unchanged from main HEAD).
+- [ ] At Holtburg, drop a test item on the ground (via `/drop` server
+ command or have ACE spawn one for the test character), then:
+ - [ ] Single-click the item β `_selectedGuid` updates, B.4b's
+ `[pick]` diagnostic shows the item's guid.
+ - [ ] Press F β log shows `[pickup] item=0x... container=0x5000000A
+ seq=N`.
+ - [ ] Item disappears from the ground.
+ - [ ] No regressions on door interaction (B.4b/B.4c still work).
+- [ ] **Bonus: click-NPC verification.** While in-world, single-click
+ an NPC and press F (or double-click). Expected: NPC chat appears in
+ the chat panel. If it does β M1 demo target 3 confirmed met. If not
+ β file the gap.
+- [ ] `docs/ISSUES.md` closure entry for whatever issue (if any) was
+ filed for the pickup gap.
+- [ ] Roadmap + CLAUDE.md updated.
+
+---
+
+## Reproducibility
+
+Same launch recipe as B.4c. Per CLAUDE.md "Logout-before-reconnect",
+wait 20-45s between client launches to let ACE clear stale sessions.
+
+```powershell
+Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
+Start-Sleep -Seconds 20
+
+$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"
+dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-b5.log"
+```
+
+Log grep:
+
+```powershell
+Select-String -Path launch-b5.log -Pattern "pickup|\[pick\] guid=|UseDone|\[B.4b\] pick"
+```
+
+---
+
+## Carry-overs from B.4c (don't lose track)
+
+- **#61** β AnimationSequencer linkβcycle frame-0 flash on door swing.
+ Visible as brief flap at end of swing animation. Low-severity polish.
+- **#62** β PARTSDIAG null-guard for sequencer-driven entities.
+ Latent; not currently reachable for doors. One-line fix.
+- **Worktree at `.claude/worktrees/phase-b4c-door-anim`** still on disk
+ (submodules blocked `git worktree remove` per B.4b precedent). Manual
+ cleanup after this session: `rm -rf` the directory + `git worktree
+ prune` + `git branch -D claude/phase-b4c-door-anim`.
+
+---
+
+## State at handoff
+
+- **Branch:** `claude/phase-b5-pickup` (renamed from
+ `claude/investigate-npc-click`).
+- **Worktree directory:** `.claude/worktrees/investigate-npc-click`
+ (cosmetic mismatch with branch name; harmless).
+- **Commits ahead of main:** 1 after this handoff lands.
+- **Main HEAD:** `e7842e0`.
+- **Build state:** worktree compiles cleanly (verified via
+ `dotnet build -c Debug`). Tests at 1046/8 baseline.
+- **Submodule state:** `references/WorldBuilder` initialized.
+ `references/ACE` NOT initialized in this worktree β use the main
+ repo's `references/ACE` for ACE source reads, or init via
+ `git submodule update --init --depth=1 references/ACE` if extensive
+ reading is needed.
+
+---
+
+## Why a fresh session
+
+This session accumulated ~10 hours of context across L.2g, B.4b, and
+B.4c β the working set is large enough that starting B.5 cold lets the
+new session work with a clean context budget and avoids the compaction
+risk that hit the prior B.4b session.
+
+The prompt for the new session is in the controller's reply that
+created this handoff (the chat message immediately after this commit).
diff --git a/docs/research/2026-05-13-l2d-slice1-shipped-handoff.md b/docs/research/2026-05-13-l2d-slice1-shipped-handoff.md
new file mode 100644
index 0000000..d6e2363
--- /dev/null
+++ b/docs/research/2026-05-13-l2d-slice1-shipped-handoff.md
@@ -0,0 +1,251 @@
+# L.2d slice 1 + 1.5 shipped β handoff
+
+**Date:** 2026-05-13 evening, immediately after slice 1.5 + Holtburg verification.
+**Branch:** `claude/sharp-chatelet-023dda` (ready to merge to main).
+**Predecessor:** [2026-05-12-l2a-shipped-l2d-handoff.md](2026-05-12-l2a-shipped-l2d-handoff.md).
+
+---
+
+## TL;DR
+
+The "I can't walk through Holtburg doorways" symptom is **a closed Door
+entity blocking the threshold**, not a building-collision-mesh bug.
+Building BSP collision is healthy. The L.2a handoff's framing
+("per-cell walkability missing") was wrong, the L.2d-slice-1 spec's
+reframe ("BSP shape fidelity, three hypotheses X/Y/Z") was also
+wrong, and the actual answer fell out of one capture once the probe
+labeling was fixed (slice 1.5). **L.2d as scoped is essentially
+closed.** The remaining work is door-state handling β a different
+sub-phase entirely.
+
+---
+
+## What shipped on this branch
+
+| Commit | What |
+|---|---|
+| [`92cd723`](.) | `docs(phys L.2d): design spec for slice 1 BSP-hit diagnostic + L.2d reframe` |
+| [`66dc23e`](.) | `feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction` |
+| [`8bacef0`](.) | `fix(phys L.2d slice 1.5): probe captures hit poly under StepSphereUp recursion` |
+
+What slice 1 + 1.5 give the next agent:
+
+- **`ACDREAM_PROBE_BUILDING=1`** env var + DebugPanel checkbox: one
+ multi-line `[resolve-bldg]` entry per attributed BSP shadow-entry hit
+ (partIdx, hasPhys, bspR vs vAabbR, world-space entOrigin_lb, actual
+ hit polygon vertices in both local and world coords). Reliable
+ under `StepSphereUp` recursion after the slice 1.5 fix.
+- **`[entity-source]`** one-time log line per `ShadowObjects.Register`
+ call, gated on the same flag. Makes `entityId=0xA9B479` in a
+ probe line greppable to its WorldEntity source.
+- **`PhysicsDiagnostics.LastBspHitPoly`** β diagnostic side-channel
+ for any future "what poly did BSPQuery hit" question.
+- **The two synthetic tests** in
+ [PhysicsDiagnosticsTests.cs](../../tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs)
+ pin the side-channel API contract.
+
+---
+
+## What the trace actually showed
+
+After slice 1.5, walking acdream into a Holtburg town doorway
+captured 242 real BSP hit polys + 122 cylinder n/a. **Definitive
+finding:**
+
+```
+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
+```
+
+The blocker is a **Door entity** β Setup `0x020019FF` named `"Door"` β
+server-spawned by ACE at the threshold of each Holtburg town building.
+**Five Doors** appear across Holtburg (landblock cells `0xA9B40029`,
+`0xA9B40154`, `0xA9B40155`); same Setup DID reused. ItemType
+`0x00000080` = Misc category in AC's ItemType flags.
+
+Each Door's Cylinder collision blocks the player. The building BSP
+*also* fires (the L.2a evidence the original handoff pointed at), but
+the BSP hits were the player **already pushed back by the Door
+cylinder** then grazing the doorframe β they look like wall collision
+but are a side effect of the Door cylinder push. Slice 1.5's per-tick
+multi-entity probe revealed this by showing `nObj=3` on every hit
+resolve: one Door + two sphere checks against the building BSP.
+
+The L.2a slice 2 handoff's expectation that doors would be in the
+`0xCC0Cxxxx` range was wrong; **doors are in `0x000Fxxxx`** (server-
+spawn-root range) because they're hydrated through the live
+`CreateObject` stream like NPCs, not the static landblock pipeline.
+
+---
+
+## What this means for L.2d
+
+L.2d as originally scoped ("Shape Fidelity: Sphere / CylSphere /
+Building Objects") is essentially **closed at this site**:
+
+- Building BSP is loaded, parsed, queried correctly. `bspR=13.99m` for
+ GfxObj `0x01000A2B`, real triangles in real positions.
+- `Setup.CylSpheres` for Door (`0x020019FF`) is also loaded correctly
+ β the cylinder is firing the cylinder collision path with sensible
+ world-space radius.
+- No actual shape-fidelity bug observed at this test site.
+
+The remaining work is **door state handling**, which is a different
+class of problem entirely β it touches network (CreateObject
+PhysicsState bits), interaction (Use action on door entity), animation
+(door open/close animation state), and collision-state-toggle
+(ETHEREAL during open animation). That doesn't fit under L.2d's
+shape-fidelity umbrella.
+
+**Recommend reframing L.2d as "watch-and-wait":** keep the probes for
+future shape-fidelity work at other sites (dungeon walls, stairs,
+roofs), but don't plan more slices until a NEW shape-fidelity bug is
+observed with the probe-armed client.
+
+---
+
+## Side findings (latent bugs to file, not block this slice)
+
+### 1. Building double-registration
+
+The trace shows the same WorldEntity registered TWICE in
+ShadowObjectRegistry:
+
+```
+[entity-source] id=0xA9B47900 entityId=0xC0A9B479 ... type=BSP note=partIdx=0 hasPhys=true
+[entity-source] id=0xC0A9B479 entityId=0xC0A9B479 ... type=Cylinder note=mesh-aabb-fallback
+```
+
+[GameWindow.cs:5625](../../src/AcDream.App/Rendering/GameWindow.cs:5625)
+gates the mesh-AABB-fallback on `entityBsp == 0`, but the BSP
+registration at [line 5530](../../src/AcDream.App/Rendering/GameWindow.cs:5530)
+DOES increment `entityBsp`. So the fallback shouldn't fire when BSP
+parts exist. Either `entityBsp` isn't being checked in the right
+scope, or there's a second mesh-AABB-fallback site that doesn't gate
+on `entityBsp`. Worth a short investigation + one-line fix.
+
+Filing as ISSUE candidate. Doesn't break anything observable yet
+(cylinder is too far from player to fire at this Holtburg site), but
+will cause confusion in any future "why does entity X have two
+ShadowEntries" trace.
+
+### 2. PhysicsState / EntityCollisionFlags not in entity-source log
+
+The slice 1 `[entity-source]` log captures `id, entityId, src,
+gfxObj, lb, type, note, hasPhys` but **not** `state` (PhysicsState
+bits) or `flags` (EntityCollisionFlags). For any future
+ethereal-handling / IGNORE_COLLISIONS work β including the door
+state handling above β these would be required.
+
+Tiny slice 1.6 if the next agent needs them: add `state=0x{...:X8}
+flags={...}` to the format string. ~5 LOC, gated on the same
+ProbeBuilding flag.
+
+---
+
+## What the next session probably should NOT do
+
+- **Re-investigate Holtburg doorways with the same setup.** The
+ evidence is conclusive; we're not going to find new information by
+ re-running the probe at the same site.
+- **Port `CBuildingObj` or per-cell walkability infrastructure.**
+ That was based on the original (wrong) hypothesis. ACE's
+ `find_building_collisions` is six lines and doesn't use per-cell
+ walkability; our equivalent is already in place implicitly.
+- **Start L.2d slice 2 as scoped in the design spec.** Hypotheses X /
+ Y / Z don't apply β the trace ruled them all out. Update or close
+ the spec.
+
+---
+
+## What the next session COULD do (in rough preference order)
+
+These are NOT prescribed; they're candidates for the project-level
+ordering discussion the user wants to have.
+
+1. **Door state handling sub-phase.** New phase (call it L.2g or
+ nest under B.4). Touches: Use action β server door toggle,
+ PhysicsState ETHEREAL bit honor, door open/close animation,
+ collision-shape suppression during open animation. Probably
+ 2-3 commits.
+
+2. **Fix the building double-registration latent bug** (side
+ finding #1). One-liner, no real impact today but cleaner trace
+ later.
+
+3. **Capture slice 1.6** (state + flags in entity-source log) if
+ any future ethereal-related work is on the immediate horizon.
+ Otherwise defer.
+
+4. **Move to a different L.2 sub-phase entirely** β L.2e
+ (cell ownership / `find_cell_list` / outdoor seam updates) or
+ L.2f (real-DAT + retail-observer conformance). Both are scoped
+ in [the L.2 plan-of-record](../plans/2026-04-29-movement-collision-conformance.md).
+
+5. **Triage the 8 pre-existing test failures** that have shadowed
+ the last few sessions. Some are in physics modules that L.2d
+ slice 2 (if it ever happens) would touch β fixing them first
+ gives a cleaner baseline.
+
+6. **Pick from CLAUDE.md's "Next phase candidates"** list β non-L.2
+ work like Phase C visual fidelity, N.6 slice 2, or perf tiers
+ 2/3. The session-level "I don't know what to do" feeling is
+ often easier to resolve by **shipping something in a different
+ area** for a session.
+
+---
+
+## Reproducibility
+
+Same recipe as L.2a + L.2d slice 1:
+
+```powershell
+$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_CELL = "1"
+$env:ACDREAM_PROBE_RESOLVE = "1"
+$env:ACDREAM_PROBE_BUILDING = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-l2d.log"
+```
+
+Walk acdream toward any Holtburg building threshold. Hit `Ctrl+F2` to
+toggle collision wireframes β you'll see the Door cylinder right at
+the threshold. The `name="Door"` line appears in the log at startup
+during the `CreateObject` stream replay.
+
+---
+
+## Open questions / unresolved
+
+- **What `PhysicsState` bits is ACE sending for the Door entity?**
+ Not captured in current logs. Slice 1.6 would answer this.
+- **Are these doors *supposed* to be open by default in retail?**
+ If yes, ACE config issue. If no, retail clients see the same
+ blocker and players had to open them manually.
+- **What does ACE's door-state state machine look like?** Probably
+ documented in `references/ACE/Source/ACE.Server/Entity/Door.cs`
+ or similar.
+
+These are doors-and-ACE-side questions; defer to the door-state
+sub-phase when (if) it gets scoped.
+
+---
+
+## Worktree state at handoff
+
+- All three slice 1 / 1.5 commits ready to merge to main.
+- WorldBuilder submodule initialized + 6 directory junctions in place
+ for the gitignored peer reference dirs (created during slice 1
+ prep). Worktree builds clean.
+- Three test artifacts (`launch-l2d-slice1.log`, `launch-l2d-slice1b.log`,
+ `launch-l2d-slice1c.log`) are in working tree but **not committed** β
+ they're large and ephemeral. Delete or preserve at the merge
+ author's discretion.
diff --git a/docs/research/2026-05-14-b5-shipped-handoff.md b/docs/research/2026-05-14-b5-shipped-handoff.md
new file mode 100644
index 0000000..eca0c2d
--- /dev/null
+++ b/docs/research/2026-05-14-b5-shipped-handoff.md
@@ -0,0 +1,252 @@
+# Phase B.5 shipped β handoff (visual-verified 2026-05-14)
+
+**Date:** 2026-05-14.
+**Branch:** `claude/phase-b5-pickup` (ready to merge to main; controller handles the merge after this doc lands).
+**Predecessors:**
+- [docs/research/2026-05-13-b4c-shipped-handoff.md](2026-05-13-b4c-shipped-handoff.md) β B.4c (door swing) shipped immediately before.
+- [docs/research/2026-05-13-b5-pickup-handoff.md](2026-05-13-b5-pickup-handoff.md) β fresh-session handoff that scoped this phase.
+- [docs/superpowers/plans/2026-05-14-phase-b5-pickup.md](../superpowers/plans/2026-05-14-phase-b5-pickup.md) β implementation plan (2 tasks).
+
+---
+
+## TL;DR
+
+Phase B.5 **shipped end-to-end and is visual-verified 2026-05-14.** The
+M1 demo target *"pick up an item"* is met for the close-range path β
+single-click a ground item to select, walk within ~0.6 m of it, press
+F, and the item is removed from the world and added to the player's
+inventory.
+
+The plan budgeted 2 implementation tasks (~50 LOC). Visual testing
+surfaced **one wire-handler gap** that became Task 2b: ACE despawns
+picked-up items via `GameMessagePickupEvent (0xF74A)`, not the
+`GameMessageDeleteObject (0xF747)` we already handled β without that
+fix the pickup succeeded server-side but the item kept rendering on
+the ground locally. Caught and fixed in the same session.
+
+Two known gaps remain, filed as issues for follow-up:
+- **#63 (MEDIUM)** β Server-initiated auto-walk for out-of-range Use /
+ PickUp not honored. Double-click a ground item from > 0.6 m and the
+ character partially walks then snaps back; ACE's `MoveToChain` times
+ out. This is a separate motion-handling phase, not a B.5 regression.
+- **#64 (LOW)** β Local-player pickup animation doesn't render
+ (retail observers see it correctly; local view is silent). Likely a
+ self-echo filter dropping `UpdateMotion(Pickup)` on the local player.
+
+---
+
+## What shipped on this branch
+
+| # | Commit | Subject | Task |
+|---|---|---|---|
+| 1 | `e8a20f2` | `feat(B.5): InteractRequests.BuildPickUp β PutItemInContainer 0x0019` | Task 1 |
+| 2 | `ced1b85` | `test(B.5): exercise i32 sign-correctness for BuildPickUp.placement` | Task 1 code-review fix |
+| 3 | `54d9bb9` | `feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring` | Task 2 |
+| 4 | `5c24f6c` | `docs(B.5): implementation plan from writing-plans skill` | Plan doc |
+| 5 | `f7636a9` | `fix(B.5): handle PickupEvent 0xF74A so picked-up items despawn locally` | Task 2b (post-visual-test fix) |
+
+Plus the predecessor handoff (`86440ff`) that started the branch.
+
+**Build:** clean.
+**Tests:** `dotnet test -c Debug` shows AcDream.Core.Net.Tests 290/290
+passing (was 287 at branch start; +3 from Task 2b's PickupEvent tests;
+the two BuildPickUp tests landed inside the same project's existing
+file). Failure count unchanged at 8 pre-existing baseline in
+AcDream.Core.Tests.
+
+---
+
+## What the code does end-to-end
+
+**Outbound (Tasks 1 & 2):**
+
+1. User single-clicks a ground item near `+Acdream`.
+ `case InputAction.SelectLeft β PickAndStoreSelection(useImmediately: false)`
+ runs B.4b's `WorldPicker.Pick`, finds the item, sets `_selectedGuid`.
+ Log: `[B.4b] pick guid=0xβ¦ name=β¦`.
+2. User presses F.
+ `case InputAction.SelectionPickUp β SendPickUp(_selectedGuid)` builds
+ the wire body via `InteractRequests.BuildPickUp(seq, itemGuid,
+ _playerServerGuid, placement: 0)` and posts it through
+ `_liveSession.SendGameAction`. Log: `[B.5] pickup item=β¦ container=β¦ seq=β¦`.
+3. Wire layout (24 bytes): `0xF7B1 envelope | seq | 0x0019 opcode |
+ itemGuid u32 | containerGuid u32 | placement i32`. Verified against
+ `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`.
+
+**Inbound (Task 2b β surfaced during visual test):**
+
+4. ACE runs `HandleActionPutItemInContainer`. If the player is within
+ `WithinUseRadius` (~0.6 m), the close-range branch in
+ `CreateMoveToChain` skips the auto-walk and runs the pickup chain
+ directly: server-side `Landblock.RemoveWorldObject(item.Guid,
+ adjacencyMove: false, fromPickup: true)` β per-player
+ `Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)` β
+ broadcast `GameMessagePickupEvent (0xF74A)` to all observers.
+5. Our `WorldSession.Dispatch` now routes `0xF74A` (in addition to
+ `0xF747 DeleteObject`) through the shared `EntityDeleted` event,
+ adapting the `PickupEvent.Parsed` to a `DeleteObject.Parsed` so
+ `OnLiveEntityDeleted β RemoveLiveEntityByServerGuid` runs unchanged.
+ The item disappears from the local view.
+
+---
+
+## Wire-handler gap (Task 2b)
+
+ACE distinguishes two despawn opcodes:
+- `0xF747 GameMessageDeleteObject` β "object is gone" (timeout / death /
+ out-of-LOS). Our existing handler.
+- `0xF74A GameMessagePickupEvent` β "object was picked up by a player."
+ Sent by `Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)`.
+
+Both are functionally identical from the client's view (remove the
+entity from the world), but only one was handled. Wire format adds
+one `u16 objectPositionSequence` field over DeleteObject's layout, so
+`PickupEvent.cs` is its own parser; the dispatcher adapts to
+`DeleteObject.Parsed` for the downstream consumer.
+
+This is exactly the kind of trap CLAUDE.md's reference-repo discipline
+exists to prevent β the handoff spec said "the existing despawn path
+removes the ground item from view," which was *almost* true. Took one
+visual-verification round-trip to surface, ten minutes to fix with a
+clean wire parser + 3 new unit tests.
+
+---
+
+## Visual verification β what was observed
+
+**Test scenario:** ACE dropped a Pink Taper, then a Violet Taper, then
+two more tapers near `+Acdream` at Holtburg. Player walked up close,
+single-clicked, pressed F. Three pickups completed in the post-fix
+log: items `0x80000725`, `0x8000072A`, `0x80000729`.
+
+**Before Task 2b:** Server-side pickup succeeded β `[B.5] pickup β¦
+seq=46` in log; retail observer saw item disappear from world. Local
+view still rendered the item on the ground.
+
+**After Task 2b:** Item disappears locally as soon as ACE acks the
+pickup. Three successful close-range pickups recorded in the log.
+
+**Door-interaction regression check (B.4c carry-forward):** Not
+explicitly re-tested this session; no code path touched by B.5
+affects door interaction.
+
+**Click-NPC bonus (M1 demo target 3 verification):** Not visually
+verified this session β log shows `[B.4b] use guid=β¦ name=Novedion
+the Gem Seller seq=β¦` from B.4c testing but ACE response not
+re-confirmed here. Carry-forward to next session.
+
+---
+
+## What did NOT work (and why it's not B.5's bug)
+
+1. **Double-click on a ground item from any distance, or F from > 0.6 m.**
+ ACE auto-walks the player toward the item (`CreateMoveToChain` β
+ `PhysicsObj.MoveToObject` + `EnqueueBroadcastMotion(MoveToObject)`),
+ but our client doesn't handle inbound `MoveToObject` motion broadcasts.
+ ACE's MoveToChain times out, the chain's `success: false` path sends
+ `InventoryServerSaveFailed (ActionCancelled)`, and the pickup never
+ completes. Visible as "character drifts toward item then flips back."
+ **Filed as #63.** Out of B.5's stated scope (which was: select-first
+ + F-key wire chain). holtburger's `simulation.rs` has the reference
+ implementation; would be its own phase (B.6 or similar).
+
+2. **Local-player pickup animation doesn't render.** Retail observers
+ see `+Acdream` play the bend-down-and-grab animation; our local view
+ shows nothing. ACE broadcasts `Motion(MotionCommand.Pickup)` via
+ `EnqueueBroadcastMotion`, our motion routing probably filters
+ self-echoes for the local player (motion is normally predicted
+ locally, not echoed from server). Server-initiated one-shot motions
+ like Pickup have no local prediction trigger, so they're dropped.
+ **Filed as #64.** Visual feedback gap only; pickup completes
+ correctly.
+
+Both are well-defined follow-up work; neither blocks M1.
+
+---
+
+## Carry-overs from B.4c
+
+Both pre-existed B.5; neither was touched.
+
+- **#61** β AnimationSequencer linkβcycle boundary frame-0 flash on
+ door swing. Low severity polish.
+- **#62** β PARTSDIAG null-guard for sequencer-driven entities.
+ Latent; not currently reachable for doors.
+
+---
+
+## M1 status after B.5
+
+Demo targets:
+1. Walk through Holtburg β met (L.2a-d + L.2g shipped earlier)
+2. Open the inn door β met (B.4b + B.4c shipped 2026-05-13)
+3. Click an NPC β chain wired (B.4b), not visually re-verified this
+ session
+4. Pick up an item β met, close-range path (this phase)
+
+Outstanding work for the M1 demo recording:
+- Optionally re-verify target 3 (NPC click) once and either confirm
+ met or file a gap.
+- Optionally resolve #63 if the demo wants to show double-click /
+ out-of-range pickup. The close-range path is sufficient for the
+ scripted demo scenario.
+- Carry-overs #61, #62, #64 are polish; do before recording if
+ visible on tape.
+
+---
+
+## Reproducibility
+
+```powershell
+Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
+Start-Sleep -Seconds 20
+
+$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"
+dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-b5.log"
+```
+
+Log evidence:
+
+```powershell
+Get-Content launch-b5.log -Encoding Unicode |
+ Select-String -Pattern "\[B\.5\] pickup|\[B\.4b\] pick"
+```
+
+Expected: a `[B.5] pickup item=β¦ container=0x5000000A seq=β¦` line for
+each successful F-press, preceded by `[B.4b] pick guid=β¦` from the
+single-click that set the selection.
+
+---
+
+## Files touched this session
+
+- New: `src/AcDream.Core.Net/Messages/PickupEvent.cs`
+- New: `tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs`
+- New: `docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`
+- New: `docs/research/2026-05-14-b5-shipped-handoff.md` (this file)
+- Modified: `src/AcDream.Core.Net/Messages/InteractRequests.cs`
+- Modified: `src/AcDream.Core.Net/WorldSession.cs`
+- Modified: `src/AcDream.App/Rendering/GameWindow.cs`
+- Modified: `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`
+- Modified: `docs/ISSUES.md` (added #63, #64)
+
+---
+
+## State at handoff
+
+- **Branch:** `claude/phase-b5-pickup`, 6 commits ahead of `main`
+ (predecessor handoff + 5 implementation commits + this docs commit
+ land in the same merge).
+- **Main HEAD before merge:** `e7842e0` β Merge B.4c.
+- **Build state:** worktree compiles cleanly under `dotnet build -c Debug`.
+- **Tests:** baseline + 3 new (PickupEvent) + 2 new (BuildPickUp +
+ sign-correctness) β failure count unchanged.
+
+Ready for non-fast-forward merge into `main`.
diff --git a/docs/research/2026-05-15-b6-b7-shipped-handoff.md b/docs/research/2026-05-15-b6-b7-shipped-handoff.md
new file mode 100644
index 0000000..5d99184
--- /dev/null
+++ b/docs/research/2026-05-15-b6-b7-shipped-handoff.md
@@ -0,0 +1,382 @@
+# Phase B.6 + B.7 + WorldPicker tightening β handoff (visual-verified 2026-05-15)
+
+**Date:** 2026-05-15 (session 06:08β18:21).
+**Branch:** commits live on `main` from `cf22f9c..e49c704` (36 commits).
+**Predecessors:**
+- [docs/research/2026-05-14-b5-shipped-handoff.md](2026-05-14-b5-shipped-handoff.md) β B.5 (pickup) shipped immediately before.
+- [docs/superpowers/specs/2026-05-14-phase-b6-design.md](../superpowers/specs/2026-05-14-phase-b6-design.md) β B.6 design with retail anchors + trace findings + 4-slice plan.
+- [docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md](../superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md) β B.7 design (Vivid Target Indicator).
+
+---
+
+## TL;DR
+
+Three coupled improvements shipped end-to-end this session:
+
+- **Phase B.6 β Local-player auto-walk on inbound `MoveToObject` (issue #63 OPEN β working).** When the user double-clicks a far target or presses R/F on an out-of-range target, ACE sends `UpdateMotion (0xF74D)` with `MovementType=6` carrying the destination guid. We now synthesize `Forward+Run` input into `PlayerMovementController` to walk the body to the target, then fire the deferred Use/PickUp once the position has arrived AND the body has rotated to face. Smooth rotation (no snap), dual alignment thresholds (30Β° walk-while-turning, 5Β° fully aligned). 10 Hz position heartbeat while moving keeps ACE's server-side `WithinUseRadius` poll converging fast enough that doors / NPCs / items all complete the action.
+
+- **Phase B.7 β Vivid Target Indicator (MVP).** Four small corner triangles drawn around the selected entity, colour-coded by entity type using a port of `gmRadarUI::GetBlipColor` (`0x004d76f0`). Box size scales with projected entity height Γ scale, per-type base height (humanoid 1.8 m, door/lifestone/portal 2.4 m, small item 0.8 m, default 1.5 m). Drawn via ImGui background draw list β no new GL infrastructure. **Selection bug is now self-correcting**: the user can see *what* they actually clicked before pressing R/F.
+
+- **WorldPicker tightening (#59 closed).** 5 m fixed sphere radius β 0.7 m default β 1.0 m default with 0.9 m vertical offset (chest-height sphere centre). Per-entity radius/offset callbacks let doors / lifestones / portals get bigger spheres (1.5β2.0 m) and small items get tighter ones (0.4 m).
+
+The M1 demo target *"click an NPC"* + *"open the inn door"* is now reachable from ANY range (close-range or far-range). The visual flow matches retail: you click β indicator appears β you press R β if far, character walks to within use radius, turns to face, action fires; if near, character turns to face, action fires.
+
+**Honest faithfulness note (user-requested audit).** Several workarounds remain β arrival safety margin, deferred-wire-Use packet, AutonomousPosition flush on arrival, retry flag β all rooted in our 1 Hz position heartbeat. The 10 Hz bump retires the worst of them in practice. Per-tick outbound (or fixing whatever causes ACE to lose our position between heartbeats) would retire ALL of them. Documented below in **Workaround retirement plan**.
+
+---
+
+## What shipped on this branch (36 commits)
+
+Ordered oldest β newest. Each commit subject is its own retail-faithful unit; the "fix(B.6+B.7)" pairing means a single workaround serves both phases.
+
+| # | Commit | Subject |
+|---|---|---|
+| 1 | `87ba5c9` | `feat(B.5): pickup feedback chat line + toast ("You pick up the X.")` |
+| 2 | `7be1393` | `docs(M1): record all 4 demo targets met, list deferred polish` |
+| 3 | `20ecb23` | `Revert "feat(B.5): pickup feedback chat line + toast β¦"` β retail-faithful: no feedback. |
+| 4 | `a01ebd5` | `fix(B.5): block pickup of creatures client-side; show 'Can't pick that up' toast` |
+| 5 | `ab7c04f` | `docs(M1): reflect chat/toast revert + the actual B.5 polish (creature pickup guard)` |
+| 6 | `e55ad48` | `fix(B.5): make creature-pickup guard silent (retail-faithful)` |
+| 7 | `ec9fd52` | `fix #62: null-guard the PARTSDIAG read of ae.Animation` |
+| 8 | `5053e40` | `docs: close #62 β PARTSDIAG null-guard landed in ec9fd52` |
+| 9 | `281d125` | `docs(B.6): design spec for local-player MoveToObject auto-walk (issue #63)` |
+| 10 | `9e1d33a` | `docs(B.6): retail decomp settles Option A; revise spec with 4-slice plan` |
+| 11 | `eda8278` | `feat(B.6 slice 1): ACDREAM_PROBE_AUTOWALK diagnostic baseline` |
+| 12 | `1b4f3ba` | `feat(B.6 slice 1): DebugPanel mirror for ProbeAutoWalk checkbox` |
+| 13 | `d82b064` | `docs(B.6): record Slice 1 trace findings β ACE sends mtRun=0.00, no UP echo` |
+| 14 | `b936ef8` | `feat(B.6 slice 2): local-player auto-walk on inbound MoveToObject` β core feature lands. |
+| 15 | `f18de7c` | `fix(B.6 slice 2): don't cancel autowalk on the companion InterpretedMotionState` |
+| 16 | `5612ce7` | `feat(B.6): honor wire WalkRunThreshold β walk vs run per retail semantics` |
+| 17 | `37177a4` | `docs(B.7): design spec for Vivid Target Indicator (selection feedback)` |
+| 18 | `8544a78` | `feat(B.7): RadarBlipColors β port of gmRadarUI::GetBlipColor` |
+| 19 | `c7e5f9f` | `feat(B.7): TargetIndicatorPanel β corner triangles around selected entity` |
+| 20 | `4bc95ec` | `fix(B.7): scale indicator box from projected entity height, not fixed pixels` |
+| 21 | `5e29773` | `fix #59: tighten WorldPicker radius from 5 m to 0.7 m` |
+| 22 | `631571a` | `docs: close #59 β picker radius tightened in 5e29773` |
+| 23 | `23cb1e9` | `fix(B.7): square indicator box + bigger pick sphere for doors/lifestones/portals + diag` |
+| 24 | `1a0656a` | `fix(picker): lift sphere centre to mid-body so chest/head clicks hit` |
+| 25 | `211fe24` | `fix(B.6+B.7): run-all-the-way auto-walk, per-type indicator height, R = smart interact` |
+| 26 | `5f83766` | `docs: file #65 β local player doesn't turn to face on close-range Use` |
+| 27 | `2dc28bb` | `fix(B.6+B.7): re-send action on local arrival; scale indicator box by entity Scale` |
+| 28 | `a0fa3d6` | `fix(B.6+B.7): flush AutonomousPosition on arrival before re-sending action` |
+| 29 | `39ff3a5` | `fix(B.6+B.7): arrival predicate uses safety margin INSIDE ACE's WithinUseRadius` |
+| 30 | `64c9793` | `fix(B.6+B.7): shrink arrival safety margin; file #66 rotation, #67 door` |
+| 31 | `301281d` | `fix(B.6+B.7): bump AutonomousPosition heartbeat 1Hz -> 10Hz while moving` β **single biggest fix** |
+| 32 | `32352af` | `fix(B.6): turn-first auto-walk + tiny margin; close #67 doors; file #68 remote arrival` |
+| 33 | `5b908bc` | `fix(B.6): close-range turn-to-face β install overlay on Use/PickUp send` |
+| 34 | `cffb10f` | `fix(B.6): tighter 5Β° alignment + defer Use until rotation completes; file #69 turn anim` |
+| 35 | `7158c46` | `fix(B.6): smooth local rotation β remove 20Β° snap-on-approach (not retail)` |
+| 36 | `e49c704` | `fix(B.6): speculative auto-walk uses WalkRunThreshold=15 to match ACE` |
+
+**Build:** clean.
+**Tests:** `dotnet test -c Debug` shows the new RadarBlipColors tests (8) and the existing B.5 BuildPickUp tests passing. Failure count unchanged at the 8 pre-existing baseline in `AcDream.Core.Tests`.
+
+---
+
+## Wire-format facts (what ACE sends, what we parse)
+
+| Wire | Field | Value | Our handling |
+|---|---|---|---|
+| `UpdateMotion (0xF74D)` | `MovementType` | `6` MoveToObject | Local: `BeginServerAutoWalk(...)` + speculative turn overlay. Remote: existing `RemoteMoveToDriver`. |
+| `UpdateMotion (0xF74D)` | `MovementType` | `7` MoveToPosition | Local: `BeginServerAutoWalk(...)` with a synthetic guid (positional destination only). |
+| `UpdateMotion (0xF74D)` | `MovementType` | `8` TurnToObject | **NOT YET PARSED** β issue #66. ACE sends this on close-range Use against an off-facing target. Our parser falls into the locomotion path and silently drops the rotation. |
+| `UpdateMotion (0xF74D)` | `MovementType` | `0` Interpreted | Companion locomotion echo after a MovementType=6 (RunForward command). We do NOT treat this as a cancel signal (commit f18de7c). |
+| `UpdateMotion (0xF74D)` | `WalkRunThreshold` | float meters | If `distance > threshold` β run, else walk. ACE default `15.0 m`. We honor it (commit 5612ce7) for inbound; we use it as the speculative-overlay walk/run gate too (commit e49c704). |
+| `GameAction (0xF7B1)` outbound | `0x0036 Use` | guid | Sent on R-key / double-click + close-range. For far-range we install the speculative overlay and defer the wire packet until arrival (commit cffb10f). |
+| `GameAction (0xF7B1)` outbound | `0x0019 PutItemInContainer` | item guid + container guid + placement | Sent on F-key. Same defer-on-far-range pattern. |
+| `AutonomousPosition (0xF7B1 0x0007)` outbound | position + heading | every 1 Hz idle / **10 Hz while moving** | The 10 Hz bump (commit 301281d) is what unblocks doors (#67) and lets ACE's `MoveToChain` see us arrive at the use radius without timing out. |
+
+**Retail anchors for the above:**
+- `MovementManager::PerformMovement` at `0x00524440` β dispatch switch on MovementType.
+- `MoveToManager::HandleMoveToObject` β MovementType=6 driver (turn-to-face β walk β stop).
+- `MoveToManager::HandleMoveToPosition` β MovementType=7 driver.
+- `MoveToManager::HandleTurnToHeading` at `0x0052a0c0` β turn-only driver used by MovementType=8.
+- `CPhysicsObj::MoveToObject` at `0x00512860` β high-level entry from physics.
+- `Player_Move.CreateMoveToChain` (ACE) at `Player_Move.cs:37β179` β server-side state machine that depends on our heartbeat to detect arrival.
+
+---
+
+## Local auto-walk state machine (current shape)
+
+`src/AcDream.App/Input/PlayerMovementController.cs`:
+
+```csharp
+// State
+private bool _autoWalkActive;
+private Vector3 _autoWalkDestination;
+private float _autoWalkMinDistance; // ACE's WithinUseRadius (per-type)
+private float _autoWalkDistanceToObject; // initial distance, used for run/walk decision
+private bool _autoWalkMoveTowards;
+private bool _autoWalkInitiallyRunning; // decided ONCE at Begin
+
+public event Action? AutoWalkArrived; // GameWindow re-sends Use/PickUp on this
+
+// Per-frame overlay, called from top of Update
+private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
+{
+ if (!_autoWalkActive) return input;
+
+ // User-input override β cancel
+ if (input.Forward || input.Back || input.StrafeL || input.StrafeR)
+ {
+ EndServerAutoWalk("user-input");
+ return input;
+ }
+
+ // Compute delta yaw, distance, alignment
+ Vector3 toTarget = _autoWalkDestination - Position;
+ float dist = toTarget.Length();
+ float targetYaw = MathF.Atan2(toTarget.Y, toTarget.X) - MathF.PI / 2f;
+ float delta = NormalizeAngle(targetYaw - Yaw);
+
+ // SMOOTH rotation (commit 7158c46 β no snap)
+ float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
+ Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
+
+ // Dual alignment thresholds (commit cffb10f)
+ const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
+ const float FullyAlignedRad = 5f * MathF.PI / 180f;
+ bool walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad;
+ bool aligned = MathF.Abs(delta) <= FullyAlignedRad;
+
+ // Arrival predicate uses TIGHT 0.05 m safety margin INSIDE ACE's radius
+ // (commit 39ff3a5 + 64c9793; works because of 10 Hz heartbeat from 301281d)
+ bool withinArrival = dist <= (_autoWalkMinDistance - 0.05f);
+
+ if (withinArrival && aligned)
+ {
+ EndServerAutoWalk("arrived"); // fires AutoWalkArrived event
+ return input;
+ }
+
+ bool moveForward = walkAligned && !withinArrival;
+ return input with
+ {
+ Forward = moveForward,
+ Run = moveForward && _autoWalkInitiallyRunning,
+ // Strafes left clear so we don't combine with other input
+ };
+}
+```
+
+`src/AcDream.App/Rendering/GameWindow.cs`:
+
+```csharp
+// Wired in ctor:
+_playerController.AutoWalkArrived += OnAutoWalkArrivedReSendAction;
+
+private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
+
+// On Use/PickUp send: install speculative overlay + defer wire packet if close
+private void SendUse(uint guid, bool isRetryAfterArrival = false)
+{
+ if (!isRetryAfterArrival)
+ {
+ InstallSpeculativeTurnToTarget(guid); // BeginServerAutoWalk with tiny radius
+ _pendingPostArrivalAction = (guid, false);
+ if (IsCloseRangeTarget(guid))
+ return; // wire packet deferred until arrival
+ }
+ // ... build + send 0xF7B1/0x0036
+}
+
+private void OnAutoWalkArrivedReSendAction()
+{
+ if (_pendingPostArrivalAction is not (uint guid, bool isPickup)) return;
+ _pendingPostArrivalAction = null;
+ SendAutonomousPositionNow(); // flush position so ACE sees us at radius
+ if (isPickup) SendPickUp(guid, isRetryAfterArrival: true);
+ else SendUse(guid, isRetryAfterArrival: true);
+}
+```
+
+---
+
+## Picker (current shape)
+
+`src/AcDream.Core/Selection/WorldPicker.cs`:
+
+- `DefaultRadius = 1.0f` (up from 0.7 m to compensate for vertical-offset lift, commit 1a0656a).
+- `DefaultVerticalOffset = 0.9f` (chest-height humanoid mid-body β fixes the bug where clicking the head/chest of an NPC missed because the sphere was at the feet).
+- Per-entity callbacks (`radiusForGuid`, `verticalOffsetForGuid`) supplied by `GameWindow`:
+ - Doors / lifestones / portals: **radius 1.5β2.0 m, vertical offset 1.2 m** (commit 23ce1e9).
+ - Small dropped items (BF_ITEM-class): **radius 0.4 m, vertical offset 0.1 m** (item lies on ground).
+ - Default (NPC / creature / sign / other): defaults 1.0 m / 0.9 m.
+- Inside-sphere origin handled (commit 5821bdc, pre-session): if `t_near < 0` use `t_far` so the entity is still pickable at point-blank range.
+
+---
+
+## Target indicator (current shape)
+
+`src/AcDream.App/UI/TargetIndicatorPanel.cs` + `src/AcDream.Core/Ui/RadarBlipColors.cs`:
+
+- `TargetInfo(WorldPosition, ItemType, ObjectDescriptionFlags, Scale)` record carries the inputs.
+- Box height = `EntityHeightFor(itemType, pwdBitfield, scale)`:
+ - Creature (NPC / monster / player): **1.8 m Γ scale** (humanoid baseline).
+ - Door / Lifestone / Portal (BF_DOOR=0x1000 | BF_LIFESTONE=0x4000 | BF_PORTAL=0x40000): **2.4 m Γ scale** (door-frame tall).
+ - Small carry items (Weapon | Armor | Clothing | Jewelry | Food | Money | Misc | MissileWeapon | Container | Gem | SpellComponents | Writable | Key | Caster): **0.8 m Γ scale**.
+ - Default (signs, scenery interactables, untyped): **1.5 m Γ scale**. β οΈ User reports signs still feel too small β see follow-up below.
+- Box is **square** (`WidthHeightRatio = 1.0`, matches retail) β width = height.
+- Projection: project feet + head world points to screen, draw 4 right-angle triangles at corners via ImGui background draw list.
+- Min screen height clamp: 16 px (prevents collapse on far entities).
+- Off-screen / behind-camera: returns early; Β±20% NDC margin so a tall entity whose feet are just off-screen still gets head projected.
+- Colour: from `RadarBlipColors.For(itemType, pwdBitfield)`. Port of `gmRadarUI::GetBlipColor (0x004d76f0)` β Portal β Vendor β Creature (yellow) β PlayerKiller (red) β PKLite β FriendlyPlayer β default Item (white-ish).
+- 8 unit tests in `tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs`.
+
+**MVP scope β explicitly deferred (per spec Β§3):** off-screen edge arrow (`m_pOffScreen`), DAT-loaded triangle sprite (today's are procedural), mesh-tint highlight on the target, player-option toggle.
+
+---
+
+## Faithfulness audit (user-requested honest comparison)
+
+This was the most important conversation thread of the session. User asked: *"How faithful are we to retail in this?"* and pushed back on every workaround I'd introduced. Direct answer: **The data path is retail-faithful, the timing isn't, and the workarounds are our bugs not ACE's.**
+
+| Piece | What retail did | What we do | Why we diverged | Resolution path |
+|---|---|---|---|---|
+| MoveToObject wire parse | `MovementManager::PerformMovement` switch on `MovementType` | We parse 6/7, miss 8 | Incremental delivery β B.6 scope didn't include TurnToObject | **Issue #66** β port MovementType=8 |
+| Local turn-to-face | Smooth interpolation animation (legs+arms cycle while body pivots) | Smooth Yaw step; no animation cycle (statue-pivot) | Motion interpreter not fed TurnLeft/TurnRight when overlay turns | **Issue #69** β synthesize TurnLeft/TurnRight |
+| Arrival predicate | Exact: stop when `dist β€ radius` | `dist β€ radius β 0.05 m` safety margin | Our client+server position drift would let retail's exact predicate fall through. 1 Hz heartbeat was the root cause; with 10 Hz it's mostly redundant. | **Drop the margin** once per-tick outbound lands. |
+| Action send timing | Once on player intent | Twice: once on intent (deferred if far) + once on arrival | Our `MoveToChain` poll on ACE side races against our position heartbeat. With 1 Hz we always lost. 10 Hz helps. | **Single-send** once per-tick outbound + a server-side action queue replaces the retry. |
+| AutonomousPosition flush | Continuous broadcast | One forced AP on arrival + 10 Hz during move | Same root cause: ACE polls at 0.1 s, we broadcast at 1 s default. | **Per-tick outbound** retires this entirely. |
+| Local-player TurnToObject | Server broadcasts MovementType=8; client rotates body | We drop the wire MovementType=8 | Incremental delivery β B.6 was scoped to MovementType=6 only | **Issue #66** β same fix as the parse gap above. |
+| Remote-player arrival animation | Client detects arrival from wire's distance threshold and transitions cycle | RemoteMoveToDriver `Arrived` state set but consumer doesn't flip cycle | Cycle-routing layer never wired to the arrival event | **Issue #68** β add `SetCycle(NonCombat, Ready)` on arrival. |
+| Local-player pickup animation | Server-initiated `Motion(Pickup)` broadcast β animates locally | Self-echo filter drops it | Pre-existing (B.5) | **Issue #64** β admit server-initiated one-shots through the filter. |
+
+**Bottom line: every workaround in this list traces to the position heartbeat or the missing MovementType=8 path. Neither is "ACE's bug" β they are gaps in our client.**
+
+---
+
+## Open follow-up issues filed this session
+
+| ID | Severity | Summary |
+|---|---|---|
+| **#66** | LOW-MED | Local + remote rotation flip-back / NPCs don't turn β port MovementType=8 TurnToObject |
+| **#68** | LOW-MED | Remote players' running animation doesn't stop on auto-walk arrival |
+| **#69** | LOW | Local player rotation isn't animated (statue-pivot vs leg-shuffle) β synthesize TurnLeft/TurnRight |
+| **#65** | LOW | Local-player no turn-to-face on close-range Use β superseded by #66 |
+
+**Closed this session:**
+- **#59** β `WorldPicker` 5 m over-pick β 1.0 m + vertical offset (`5e29773`, `1a0656a`, `23ce1e9`).
+- **#62** β PARTSDIAG null-guard for sequencer-driven entities (`ec9fd52`).
+- **#67** β Door Use action doesn't complete after auto-walk arrival β fixed by 10 Hz heartbeat (`301281d`).
+
+**Visual gripe still open (not filed as an issue yet):** signs still feel too small. Default 1.5 m Γ scale should probably be 2.5 m for sign-class objects β they're tall posts in retail. Wiring is there (just bump the constant in `EntityHeightFor` for the "everything else" branch, or add a sign-detection rule). User's most recent feedback before context-out was *"still when I select a sign the box is way to small."*
+
+---
+
+## Reproducibility β how to verify the shipped behaviour
+
+```powershell
+# From C:\Users\erikn\source\repos\acdream
+Stop-Process -Name AcDream.App -ErrorAction SilentlyContinue
+Start-Sleep -Seconds 3
+
+$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"
+
+# Optional diagnostic env vars (heavy)
+# $env:ACDREAM_PROBE_AUTOWALK = "1" # one [autowalk] line per MoveToObject inbound + transition
+# $env:ACDREAM_DUMP_MOTION = "1"
+
+dotnet build -c Debug; if ($?) {
+ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "launch.log"
+}
+```
+
+**Test scenarios (each verified visually during the session):**
+
+1. **Far-range Use on NPC.** Stand 5β10 m from Tirenia in the Holtburg inn. Click NPC β corner triangles appear (yellow). Press R. Character runs to within ~3 m, decelerates, turns to face, Use fires, dialogue appears.
+2. **Far-range pickup on item.** Stand 5β10 m from a dropped taper. Click item β corner triangles appear (white-ish). Press F. Character runs to within ~0.6 m, turns to face, PickUp fires, item despawns, inventory updates.
+3. **Door open.** Stand 3β5 m from the inn front door. Click door β corner triangles appear (white-ish, scaled to 2.4 m Γ scale). Press R. Character walks to within ~2 m, turns to face, Use fires, ACE broadcasts `SetState (ETHEREAL)`, character walks through.
+4. **Close-range Use.** Already within ~1 m of an NPC, facing away. Press R. Character turns to face β Use fires β dialogue. (Close-range branch β exercises `IsCloseRangeTarget` + deferred-wire-packet path.)
+5. **Far-range double-click on item.** Same as (2) but double-click β should behave identically to F-key (double-click activation passes through `OnInputAction` after `58b95bc`).
+
+**Diagnostic env vars active for this work:**
+- `ACDREAM_PROBE_AUTOWALK=1` β one `[autowalk]` line per inbound MoveToObject + state transition (commit `eda8278`). Also toggleable via DebugPanel.
+- `ACDREAM_DUMP_MOTION=1` β every inbound `UpdateMotion` (guid, stance, cmd, speed).
+- `ACDREAM_REMOTE_VEL_DIAG=1` β `[UPCYCLE]` traces for remote-arrival debug (relates to #68).
+
+---
+
+## Files touched this session
+
+**New files:**
+- `src/AcDream.Core/Ui/RadarBlipColors.cs` β colour table port.
+- `src/AcDream.App/UI/TargetIndicatorPanel.cs` β corner-triangle renderer.
+- `tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs` β 8 unit tests.
+- `docs/superpowers/specs/2026-05-14-phase-b6-design.md` β B.6 design + 4-slice plan.
+- `docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md` β B.7 design.
+
+**Modified:**
+- `src/AcDream.App/Input/PlayerMovementController.cs` β auto-walk overlay, smooth rotation, dual alignment, 10 Hz heartbeat.
+- `src/AcDream.App/Rendering/GameWindow.cs` β `SendUse`/`SendPickUp` defer logic, `_pendingPostArrivalAction`, `OnAutoWalkArrivedReSendAction`, `InstallSpeculativeTurnToTarget`, `IsCloseRangeTarget`, `SendAutonomousPositionNow`, `TargetIndicatorPanel` wiring, per-entity picker callbacks, `UseCurrentSelection` smart-R dispatch.
+- `src/AcDream.Core/Selection/WorldPicker.cs` β radius/vertical-offset callbacks, inside-sphere origin handling documented.
+- `src/AcDream.Core.Net/WorldSession.cs` β MovementType=6 routing into local auto-walk path.
+- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` β `ProbeAutoWalkEnabled` static property, `ACDREAM_PROBE_AUTOWALK` env var.
+- `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` + `DebugPanel.cs` β DebugPanel checkbox mirror.
+- `docs/ISSUES.md` β closed #59, #62, #67; filed #65, #66, #68, #69; updated #63 with B.6 status.
+- `docs/plans/2026-05-12-milestones.md` β M1 four-of-four status reflected.
+- `CLAUDE.md` β updated "Currently in Phaseβ¦" line with B.6/B.7 ship facts.
+
+---
+
+## Workaround retirement plan
+
+The four workarounds that should NOT survive a per-tick-outbound + MovementType=8 phase:
+
+1. **Arrival safety margin (currently 0.05 m).** `ApplyAutoWalkOverlay` stops at `dist <= _autoWalkMinDistance - 0.05f`. Retail stops at `dist <= radius`. Drop the margin when our outbound position is fresh enough that ACE's `WithinUseRadius` poll always sees us inside the radius the moment we get there.
+2. **Re-send on arrival.** `_pendingPostArrivalAction` + `OnAutoWalkArrivedReSendAction` re-fire `SendUse`/`SendPickUp` after the body arrives. Retail's client sends the action once and lets the server-side `MoveToChain` complete. Drop when ACE consistently completes the chain from a single send.
+3. **AutonomousPosition flush on arrival.** `SendAutonomousPositionNow()` explicitly broadcasts position the moment we arrive. With per-tick outbound this happens naturally.
+4. **`isRetryAfterArrival` flag.** Branch in `SendUse`/`SendPickUp` to skip the speculative-overlay install on the retry. Goes away when the retry goes away.
+
+**Single fix that retires all four:** per-tick outbound position broadcast (probably at the physics tick rate of 60 Hz with a smaller payload, or 20β30 Hz with the full one). Currently `effectiveInterval = activelyMoving ? 0.1f : 1.0f` (10 Hz active / 1 Hz idle). Going to 20β30 Hz active would likely close the gap; per-tick is the upper bound.
+
+**Reference for retail's outbound cadence:** `docs/research/named-retail/` β search for `CPhysicsObj::send_movement_event` and `AutonomousPosition` send-site. Holtburger's `client/movement/system.rs` also sends at higher cadence than our default.
+
+---
+
+## Next-session entry points (in rough priority order)
+
+1. **Fix the sign indicator box.** User's last gripe. Bump `EntityHeightFor` default from 1.5 m to ~2.5 m, or add an explicit sign-detection rule. ~5 LOC. Verify in Holtburg by selecting one of the inn signs.
+2. **Issue #66 β MovementType=8 TurnToObject (local + remote).** Two-direction fix: stop local-player flip-back AND make NPCs turn to face. ~80β120 LOC + tests. Spec template is the B.6 spec with MovementType=8 substituted.
+3. **Issue #69 β animate rotation.** Synthesize `TurnLeft`/`TurnRight` input flags while the overlay turns the body. ~30 LOC in `ApplyAutoWalkOverlay` + verify retail's human motion table has the cycle. Pairs with #66 nicely.
+4. **Issue #68 β Remote players don't stop run animation on arrival.** Wire `RemoteMoveToDriver.Arrived` to `SetCycle(NonCombat, Ready)`. ~20 LOC. Small standalone fix.
+5. **Issue #64 β Local-player pickup animation.** Pre-existing B.5 gap; the self-echo filter drops `UpdateMotion(Pickup)`. Either (a) admit server-initiated one-shots through the filter, or (b) generate locally on send.
+6. **Per-tick outbound position broadcast.** The big one. Retires the four B.6 workarounds and probably fixes a class of "ACE doesn't see us" bugs we haven't even noticed yet. Probably its own design phase (call it B.8 or M.x). Read `docs/research/named-retail/` for retail's cadence first.
+7. **Investigate the running-in-circles bug.** User reported during B.6 slice 2 testing that auto-walk would occasionally "run in circles" before going straight. The fix in `211fe24` (run-all-the-way) appears to have fixed it but no regression test exists. Worth a one-session investigation with `ACDREAM_PROBE_AUTOWALK=1`.
+
+---
+
+## Predecessor reading order for a fresh session
+
+1. **This document** β the full picture of what's in main.
+2. [`docs/superpowers/specs/2026-05-14-phase-b6-design.md`](../superpowers/specs/2026-05-14-phase-b6-design.md) β retail anchors + decomp citations for auto-walk.
+3. [`docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md`](../superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md) β B.7 design + deferred-MVP list.
+4. [`docs/research/2026-05-14-b5-shipped-handoff.md`](2026-05-14-b5-shipped-handoff.md) β B.5 (pickup, close-range path) preceded this work.
+5. [`docs/research/2026-05-13-b4b-shipped-handoff.md`](2026-05-13-b4b-shipped-handoff.md) β B.4b (Use outbound + WorldPicker) preceded that.
+
+**Retail decomp anchors for auto-walk:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` searched by:
+- `MovementManager::PerformMovement` (`0x00524440`)
+- `MoveToManager::HandleMoveToObject`
+- `MoveToManager::HandleMoveToPosition`
+- `MoveToManager::HandleTurnToHeading` (`0x0052a0c0`)
+- `CPhysicsObj::MoveToObject` (`0x00512860`)
+- `VividTargetIndicator::SetSelected` (`0x004f5ce0`)
+- `gmRadarUI::GetBlipColor` (`0x004d76f0`)
+
+**ACE anchors:** `references/ACE/Source/ACE.Server/WorldObjects/Player_Move.cs:37β179` (`CreateMoveToChain`) and `Player_Inventory.cs:976β1106` (pickup chain).
+
+---
+
+## Session-specific morale note
+
+This was a long session β 43 user messages, ~12 hours wall-clock, 36 commits. The pattern was: implement, user tests against retail, user reports specific divergence, fix, repeat. The user pushed back hard on workarounds twice ("Why workarounds? Nothing wrong with ACE, our client is wrong" + "did you verify with retail?") and both times the right move was to drop the workaround and chase the root cause. The 10 Hz heartbeat (`301281d`) was the highest-leverage commit of the session β it closed #67 and tightened the firing distance for every other interaction. **Lesson: when a workaround starts feeling load-bearing, find the heartbeat-cadence-style root cause behind it before adding more layers.**
+
+The B.6 four-slice plan in the spec was the right shape β Slice 1 (diagnostic) revealed `mtRun=0.00 + no UP echo`, which directly informed Slice 2 (treat MovementType=0 InterpretedMotionState as a companion not a cancel signal, `f18de7c`). Slice 3 + 4 (walk vs run + turn-first) emerged from visual testing. **Lesson: diagnostic-first slicing pays off when you don't actually know what ACE will send.**
+
+β Session ended at user request to write this handoff before context compaction.
diff --git a/docs/research/2026-05-16-issue77-autowalk-handoff.md b/docs/research/2026-05-16-issue77-autowalk-handoff.md
new file mode 100644
index 0000000..a615828
--- /dev/null
+++ b/docs/research/2026-05-16-issue77-autowalk-handoff.md
@@ -0,0 +1,284 @@
+# Issue #77 β close-range auto-walk + pickup overshoot β investigation handoff
+
+**Filed:** 2026-05-16 (in `docs/ISSUES.md` as the active issue at the top)
+**Severity:** MEDIUM (M1-deferred polish; visible during normal play, doesn't block any phase)
+**Component:** physics / auto-walk / `PlayerMovementController.DriveServerAutoWalk`
+**Branch state when handed off:** main at `f8829b3` (post-merge of `claude/hungry-tharp-b4a27b`)
+
+---
+
+## What you're chasing
+
+Two related close-range bugs in the server-driven auto-walk path. Both are
+**pre-existing** β not caused by the LiveSessionController extraction
+(0b25df5) β they were surfaced during that refactor's visual verification.
+
+### Bug A β NPC at walking range never auto-walks
+
+- User clicks an NPC (e.g. Royal Guard at `0x7A9B46AE`) when the player
+ is at "walking range" β far enough that retail would walk a short
+ distance to reach the NPC's `useRadius`, not close enough to fire
+ Use immediately.
+- The client's `WorldPicker` returns `WithinUseRadius=false`, so
+ `OnInputAction.UseSelected` defers the Use and would expect ACE's
+ inbound `MoveToObject` motion update to drive the player to the NPC.
+- **The local player does not visibly move.** Repeated clicks (the trace
+ below shows seq 81 β 87 β 90 β 96 β 105 β 141 β 146 β 159 β 163 β
+ 169 β 173 β 177 against the same Royal Guard) produce the same
+ response every time without any movement.
+
+### Bug B β Pickup at walking range runs/overshoots/snaps back
+
+- User presses F on a ground item while in "walking range" of it.
+- Player **runs** (not walks) toward the target.
+- Overshoots the item, then **blips back** to the correct position
+ before the pickup actually fires.
+- The pickup completes (item ends up in inventory), but the visual is
+ jarring.
+
+The "blips back" almost certainly means ACE's server-side position
+correction snaps the player back after the client overshot. The
+client's run-not-walk choice is the proximal cause.
+
+---
+
+## What we know already (don't re-discover this)
+
+### Trace evidence captured during merge
+
+From `launch.log` of the Step 2 verification run (task `b01zkw68w`,
+2026-05-16, with `ACDREAM_DEVTOOLS=1` + my temporary
+`OnLiveMotionUpdated` diagnostic):
+
+```
+[B.4b] use guid=0x7A9B46AE seq=159 β outbound Use packet sent
+OnLiveMotionUpdated: guid=0x5000000A stance=61 cmd=0x speed= β player β NonCombat
+OnLiveMotionUpdated: guid=0x7A9B46AE stance=61 cmd=0x0003 speed= β NPC turns to face
+OnLiveMotionUpdated: guid=0x7A9B46AE stance=61 cmd=0x speed=
+OnLiveMotionUpdated: guid=0x5000000A stance=0 cmd=0x0005 speed=-1.84 β ACE sends MoveToObject for player
+OnLiveMotionUpdated: guid=0x5000000A stance=0 cmd=0x speed=
+```
+
+The pattern repeats identically for every retry β ACE *is* sending the
+auto-walk command, but the client isn't engaging it.
+
+**The negative speed (-1.84) is suspicious.** Speed is parsed as a raw
+IEEE 754 float by `UpdateMotion.cs:193`. Either retail encodes a sign
+that we're misinterpreting, or this is a legitimate "move backward"
+instruction (ACE sometimes positions the move-to point behind the
+player). The auto-walk engagement condition may be filtering negative
+speeds out without our intent.
+
+### What was already established BEFORE this issue
+
+`PlayerMovementController` got significant retail-faithful refactor work
+in Phase B.6 (closed-issue #75 territory, commit `f035ea3`). That work
+established:
+
+- **Walk/run threshold = 1.0m** of remaining-distance-to-useRadius
+ (not ACE's wire-supplied 15m default β that's overridden).
+- **One-shot walk/run decision** at `BeginServerAutoWalk` time, held
+ for the rest of the chain.
+- **Direct body-velocity drive** β auto-walk does NOT synthesize
+ `MovementInput`. It steps `Yaw`, sets `_body.set_local_velocity`
+ from `runRate`, and calls `_motion.DoMotion(WalkForward, speed)`
+ directly.
+
+The auto-walk diagnostic infrastructure already exists:
+
+```
+PhysicsDiagnostics.ProbeAutoWalkEnabled β runtime-toggleable
+ACDREAM_PROBE_AUTOWALK=1 β env-var enable
+
+[autowalk-out] on every SendUse / SendPickUp
+[autowalk-mt] on every inbound UpdateMotion for the local player
+[autowalk-up] on every inbound UpdatePosition for the local player
+[autowalk-begin] when BeginServerAutoWalk fires
+[autowalk-end] when EndServerAutoWalk fires
+```
+
+**You should turn this on first.** The `[autowalk-begin]` line will tell
+you whether `BeginServerAutoWalk` is even being invoked for the
+walking-range case.
+
+### Where to start reading code
+
+| File | Why |
+|---|---|
+| `src/AcDream.App/Input/PlayerMovementController.cs` | The auto-walk driver lives here. Key functions: `BeginServerAutoWalk` (line ~428), `DriveServerAutoWalk` (line ~550), `EndServerAutoWalk` (line ~478). |
+| `src/AcDream.App/Rendering/GameWindow.cs` line ~3360 | The `OnLiveMotionUpdated` site that detects MoveToObject pattern and calls `BeginServerAutoWalk`. The `[autowalk-mt]` and `[autowalk-begin]` traces fire here. |
+| `src/AcDream.Core.Net/Messages/UpdateMotion.cs` line ~193 | The inbound parser. `ForwardSpeed` is a raw float β investigate whether negative is legitimate or a sign-misinterpretation. |
+| `docs/superpowers/specs/2026-05-14-phase-b6-design.md` | The Phase B.6 design spec. Read this first to understand the existing auto-walk contract. |
+| `references/holtburger/crates/holtburger-core/src/client/simulation.rs` | The Rust client's equivalent β has `ServerControlledProjection` + `approximate_move_to_object_projection_target`. Holtburger handles this case correctly, so cross-checking is valuable. |
+| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | Grep for `MoveToManager::HandleMoveToPosition`, `MoveToManager::HandleAutonomyLevelChange`, `CMotionInterp::apply_interpreted_movement`. Retail's truth. |
+
+### What was checked and ruled out during the Step 2 session
+
+- The bugs exist on the **pre-Step-2 branch** (eda936d / 32423c2 / main).
+ This was confirmed by diff scope: `PlayerMovementController.cs`,
+ `PhysicsEngine.cs`, `UpdateMotion.cs` were not touched by Step 2.
+- The Step 2 refactor (`0b25df5`) does not affect the auto-walk path.
+- Subscriptions are wired correctly β `OnLiveMotionUpdated` IS firing
+ for every motion update (verified via `[step2-diag]` traces that have
+ since been stripped).
+
+---
+
+## Hypotheses to test, in order
+
+### H1 (most likely) β `BeginServerAutoWalk` never fires for the walking-range MoveToObject
+
+The walking-range MoveToObject from ACE may not match the pattern that
+`OnLiveMotionUpdated` checks before calling `BeginServerAutoWalk`. The
+condition probably checks for one of: `IsServerControlledMoveTo`,
+non-zero ForwardSpeed magnitude, specific `MovementType`, or specific
+`ForwardCommand` values. Walking-range UpdateMotion may differ from
+running-range in one of those fields.
+
+**Test:** Enable `ACDREAM_PROBE_AUTOWALK=1`, click the NPC at walking
+range. Look for `[autowalk-mt]` (inbound parse) WITHOUT a following
+`[autowalk-begin]`. That confirms H1 and points to GameWindow.cs:3360.
+
+### H2 β `BeginServerAutoWalk` fires but `_autoWalkInitiallyRunning` decision misclassifies
+
+The walk/run decision uses:
+```csharp
+remainingAtStart = initialDist - distanceToObject
+_autoWalkInitiallyRunning = remainingAtStart >= 1.0m
+```
+
+If ACE sends a `distanceToObject` (useRadius) much smaller than the
+NPC's actual useRadius β or if `initialDist` is computed against the
+wrong target position β `remainingAtStart` could land just above 1m
+even at user-perceived walking range, causing run-not-walk. That
+matches **Bug B**'s "runs and overshoots" pattern.
+
+**Test:** Compare `[autowalk-begin] dest=(...) minDist=... objDist=... walkRunThresh=...`
+values between a walking-range click and a running-range click. The
+`objDist` should be the wire-supplied useRadius. If it's wrong (too
+small), retail's value disagrees and we have a parser bug elsewhere.
+
+### H3 β Negative `ForwardSpeed` is filtered or misinterpreted
+
+`speed=-1.84` is the literal IEEE 754 float on the wire. Retail's
+`CMotionInterp::handle_action_walkforward` (or whichever code consumes
+ForwardSpeed) may use it for direction relative to the auto-walk
+heading; a sign-extension bug in our parse would matter.
+
+**Test:** Grep `references/holtburger` and named-retail decomp for how
+`ForwardSpeed` is consumed. If retail/holtburger interpret the sign
+specially and we don't, that's the gap.
+
+### H4 β Arrival predicate fires too early
+
+`DriveServerAutoWalk` line ~601:
+```csharp
+withinArrival = dist <= arrivalThreshold
+```
+where `arrivalThreshold = _autoWalkDistanceToObject` (use-radius).
+
+If `distanceToObject` is 0 or near-zero (a parser bug, see H2), the
+arrival predicate fires on the first frame and `EndServerAutoWalk("arrived")`
+is called immediately, so the player never visibly moves. That matches
+**Bug A** exactly.
+
+**Test:** Look for `[autowalk-end] reason=arrived` immediately after
+`[autowalk-begin]` with zero or one frame between. That confirms H4.
+
+---
+
+## Reproduction recipe (~3 minutes)
+
+1. **Launch with autowalk probe enabled:**
+ ```powershell
+ $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_AUTOWALK = "1"
+ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug | Tee-Object -FilePath launch.log
+ ```
+
+2. **Reproduce Bug A:**
+ - Walk toward the inn area in Holtburg until you're ~3-5 meters from
+ an NPC (e.g. Royal Guard near the inn). Estimate by eye β the goal
+ is "you can see the NPC clearly but you'd need to take a few steps
+ to reach them."
+ - Double-click the NPC.
+ - Observe: player doesn't move. Click again β same result.
+
+3. **Reproduce Bug B:**
+ - Find a ground item (Holtburg has scattered spell components β the
+ coloured Tapers are obvious). Stand ~3-5 meters away.
+ - Press F (or whatever your `SelectionPickUp` key is bound to).
+ - Observe: player runs, overshoots, snaps back, item picked up.
+
+4. **Stop the client gracefully** (window close, not Stop-Process β see
+ CLAUDE.md "Logout-before-reconnect"). ACE clears stale sessions in
+ 3β5 seconds on graceful close.
+
+5. **Grep the log:**
+ ```bash
+ tr -d '\000' < launch.log | grep -E "\[autowalk-(out|mt|begin|up|end)\]"
+ ```
+
+ This should give you a complete frame-by-frame trace of every
+ auto-walk decision the client made.
+
+---
+
+## Acceptance criteria
+
+When the fix lands:
+
+- β
Click NPC at walking range β player **walks** (not runs) directly to NPC,
+ Use fires on arrival, NPC dialogue appears.
+- β
Press F on ground item at walking range β player **walks** the short
+ distance, no overshoot, no blip-back, item enters inventory.
+- β
Far-range click still **runs** to target (don't regress the working case).
+- β
Out-of-walking-but-very-close-range case (right at the edge of useRadius)
+ still arrives without infinite spin or stuttering.
+- β
All existing tests pass (8 pre-existing Core failures are baseline,
+ do NOT count against the fix).
+- β
Visual verification at Holtburg, all three M1 demo targets still work
+ (door, NPC, pickup).
+
+---
+
+## What NOT to do
+
+- **Don't add a workaround**, per CLAUDE.md's "no workarounds" rule.
+ No grace-period band-aid, no "if speed is negative, force walk" hack,
+ no "always walk when within 5m" override. Fix the root cause.
+- **Don't rewrite the auto-walk path** β Phase B.6 was a heavy retail
+ decomp port. The fix is almost certainly a one-condition or one-formula
+ adjustment, not a new design.
+- **Don't change `Step 2`'s extracted code**. `LiveSessionController` and
+ the wireup are clean β `OnLiveMotionUpdated` is wired and firing per
+ the previous session's verification traces.
+
+---
+
+## Time estimate
+
+- 30 min: read the spec + reproduce + capture trace
+- 30 min: identify root-cause hypothesis from the trace
+- 30 min β 2 hr: implement the fix (depends on which hypothesis lands)
+- 30 min: visual verification + write commit message
+- **Total:** 2β3 hours focused work
+
+---
+
+## When done
+
+1. Commit message format: `fix(physics): close #77 β `
+2. Move `#77` to the "Recently closed" section of `docs/ISSUES.md`
+ with closed-date + commit SHA (matches the project convention).
+3. If the fix uncovered a durable lesson (e.g. "ACE sends negative
+ ForwardSpeed for MoveToObject; we were filtering"), add a
+ `feedback_*.md` memory entry per `memory/MEMORY.md` conventions.
+4. The next pre-M2 cleanup items in queue: root `.editorconfig` + Step 3
+ (`LiveEntityRuntime`). See `docs/architecture/code-structure.md` Β§4.
diff --git a/docs/research/2026-05-16-session-handoff.md b/docs/research/2026-05-16-session-handoff.md
new file mode 100644
index 0000000..bf40ff1
--- /dev/null
+++ b/docs/research/2026-05-16-session-handoff.md
@@ -0,0 +1,90 @@
+# Session handoff β 2026-05-16
+
+## What landed this session
+
+**M1 β "Walkable + clickable world" β β
LANDED on main at `fb92122`.**
+
+All four M1 demo targets work end-to-end retail-faithfully:
+1. Walk through Holtburg without getting stuck (L.2 collision) β outdoor only verified
+2. Open the inn door (B.4c)
+3. Click an NPC and see dialogue (B.4b + Phase B.6)
+4. Pick up an item from the ground (B.5 + Phase B.6)
+
+**Important caveat:** the M1 demo specifically did NOT test:
+- Walking around inside the inn (just the doorway)
+- Going up stairs
+- Indoor-to-indoor transitions
+- Indoor lighting correctness
+
+These were assumed to "probably work" because outdoor walking does. They likely don't.
+
+## Main branch history (newest first)
+
+| SHA | Title |
+|---|---|
+| `5d79dd3` | docs: session handoff 2026-05-16 (this doc; will be overwritten when you read this) |
+| `fb92122` | milestone: M1 landed; flip "currently working toward" to M2 |
+| `d640ed7` | feat(retail): Phase B.6 β server-driven auto-walk done right |
+| `b5da17d` | feat(retail): Commit B β retail-faithful AP cadence + screen-rect picker |
+| `e2bc3a9` | (base β docs(CLAUDE.md): document Ghidra MCP + WireMCP availability) |
+
+## Phase B.6 (today's big landing) β what it actually did
+
+Replaced the chain of Commit-B workarounds that compensated for ACE's `MoveToChain` getting cancelled by a leaked user-MoveToState packet during inbound auto-walk. The fix was architectural:
+
+- **`ApplyAutoWalkOverlay β DriveServerAutoWalk`** β auto-walk drives the body's velocity + motion state + animation cycle DIRECTLY from the wire-supplied path data. No player-input synthesis. Mirrors retail's `MovementManager::PerformMovement` case 6 (decomp `0x00524440`) which never touches the user-input pipeline during server-controlled auto-walk.
+- **Wire-layer guard retained** at `GameWindow.cs:6419` as a semantic statement (user-MoveToState packets are for user-driven motion intent), NOT as the band-aid the deleted 500ms grace period was.
+- **Walk/run threshold = 1.0m** (matches user-observed retail behaviour; ACE's wire-default 15.0f is ignored β overridden in `BeginServerAutoWalk` via const `RetailWalkRunThresholdMeters`). Formula from decomp `0x0052aa00 MovementParameters::get_command`: `running = (initialDist - distance_to_object) >= threshold`, one-shot at chain start.
+- **Animation cycle plumbed** for moving-forward (RunForward/WalkForward) AND turn-first phase (TurnLeft/TurnRight via `_autoWalkTurnDirectionThisFrame`).
+- **Pickup gate** corrected to check `BF_STUCK` (`acclient.h:6435`, bit `0x4`) β signs (`pwd=0x14`) blocked; spell components (`pwd=0x10`) allowed.
+- **R-key dispatches by target type** β creature β SendUse, pickupable β SendPickUp, useable β SendUse, else toast.
+- **AP cadence reverted** to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`). Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne. `ApproxPlaneEqual` helper added.
+
+Issues closed: **#63, #69, #74, #75**.
+
+## New rules / preferences captured this session
+
+1. **No workarounds without explicit approval.** CLAUDE.md "How to operate" + `memory/feedback_no_workarounds.md`. Band-aids, grace periods, suppression flags, retry loops forbidden unless the user explicitly approves or it's a deliberate new-feature design.
+
+2. **No milestone demo videos.** CLAUDE.md "milestone discipline" rule #3 + `memory/feedback_no_demo_videos.md`. Milestones land via text-only artifacts.
+
+3. **Graceful client shutdown via `CloseMainWindow`.** CLAUDE.md "Logout-before-reconnect" section. `Stop-Process` is a hard kill β leaves ACE's session marked logged-in for ~3+ min before timeout.
+
+## Direction redirect at session end
+
+The originally-planned **M2 β "Kill a drudge"** is being **deferred** in favor of fundamentals-first work. User's stated reasoning: indoor walking, physics correctness, and lighting are all untested or broken, and building combat on top of an unverified-indoor foundation would compound problems.
+
+**New M2 candidate β "Indoor walkability."** Demo scenario candidate: walk into the Holtburg inn, climb to the second floor, look around, walk back out β all with sensible camera + correct indoor lighting + collision-clean. Three sub-phases proposed:
+
+| Phase | Scope | Estimate |
+|---|---|---|
+| Camera correctness | Sphere-cast from player to camera position; snap to first wall hit; lerp recovery when clear. Prevent camera from dipping below ground. Indoor-specific but applies everywhere. | ~1 day |
+| Indoor collision audit | Walk every floor of the Holtburg inn (and a nearby small dungeon if time). Document each off-feeling spot β stairs, narrow halls, EnvCell-to-EnvCell transitions, EnvCell-to-outdoor seams. Fix scoped per finding. | ~1 week |
+| Indoor lighting basics | Tightly scoped: torch-light pools + proper indoor ambient (dim). Defer fire/lamp/glow particles + dynamic-light count optimization to M5. Touches shader pipeline (modern bindless path) + per-object light selection. | ~1-2 weeks |
+
+Order matters: camera fix first (you can't honestly audit collision while the camera fights you), then collision audit (find the bugs), then lighting (the visual unlock).
+
+**This milestone has not been formally renamed in `docs/plans/2026-05-12-milestones.md` yet.** The doc still says "M2 β Kill a drudge." The next session should brainstorm the new milestone (using `superpowers:brainstorming`), agree on the demo scenario, scope the sub-phases, then update the milestones doc + CLAUDE.md to reflect the reorder. Combat slides to M3.
+
+## Test baseline
+
+- Core.Net: 294/294 β
+- Core: 1073/1081 (8 pre-existing Physics failures β BSPStepUp + MotionInterpreter; unchanged baseline)
+
+## Environment reminders
+
+- ACE running locally on `127.0.0.1:9000` (testaccount / testpassword / `+Acdream` at `0x5000000A`).
+- DAT directory: `%USERPROFILE%\Documents\Asheron's Call`.
+- Worktree branch `claude/vigilant-golick-9433e1` has the full 20-commit Phase B.6 history; main has one squashed commit (`d640ed7`).
+- WorldBuilder submodule needs `git submodule update --init references/WorldBuilder` in fresh worktrees.
+
+## Open issues worth tracking
+
+- **#61** β AnimationSequencer linkβcycle frame-0 flash on door swing. LOW.
+- **#64** β Local-player pickup animation does not render. LOW.
+- **#70** β Triangle apex/size DAT sprite. LOW.
+- **#71** β WorldPicker Stage B polygon refine. LOW.
+- **#72** β cdb probe to confirm `omega.z = Ο/2` base rate. LOW.
+- **#73** β Retail-message centralization. Per-feature, ongoing.
+
+None block the new indoor milestone. All M7 polish.
diff --git a/docs/research/2026-05-19-cluster-a-shipped-handoff.md b/docs/research/2026-05-19-cluster-a-shipped-handoff.md
new file mode 100644
index 0000000..b46fa1e
--- /dev/null
+++ b/docs/research/2026-05-19-cluster-a-shipped-handoff.md
@@ -0,0 +1,256 @@
+# Indoor walking Phase 1 β BSP cluster (Cluster A) β handoff (2026-05-19)
+
+**Date:** 2026-05-19.
+**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller).
+**Predecessor:** Indoor lighting + rendering Phase 2 (fix) β floors now render in Holtburg Inn. Nine pre-existing indoor bugs surfaced the moment floors were visible; this cluster addresses the collision/interaction subset (#84, #85, #86) and adds diagnostic infrastructure for the follow-up portal-traversal phase.
+**Plan:** [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md).
+
+---
+
+## TL;DR
+
+Cluster A shipped **partially**. Three of the five planned phases (A, B, D)
+produced real behavior changes; two (C β obstacle audit β and E β cell-cache
+diagnostics) are diagnostic/research phases. The cluster's investigation
+confirmed that the wall-collision failures (#84, #85) all root in one cause:
+the player's `CellId` is never promoted to an indoor cell during normal
+walking, so the indoor-BSP collision branch in `TransitionTypes.FindEnvCollisions`
+never fires. Phase D implemented an AABB-containment shortcut that resolves
+the specific "spawn inside a building and be stuck above the floor" case but
+proved too tight to keep `CellId` promoted through threshold/doorway cells
+during normal outdoorβindoor entry.
+
+**#86** (click selection penetrates walls) is **fully closed** β a clean,
+self-contained fix in `WorldPicker`.
+
+**#84** is **partially closed** β the spawn-in-building symptom is gone; the
+remaining wall-collision symptom during normal walking is tracked under the
+new **#87**.
+
+**#85** remains **open**; its root cause is confirmed identical to #84's
+remaining symptom and is also tracked under #87.
+
+**#87** (indoor portal-based cell tracking) is **filed** and ready for the
+follow-up phase.
+
+---
+
+## Commits
+
+| # | SHA | Subject | Phase |
+|---|---|---|---|
+| 1 | `18a2e28` | `docs(plan): implementation plan written` | Plan doc |
+| 2 | `27d7de1` | `feat(physics): Cluster A β indoor BSP collision probe` | Phase A |
+| 3 | `3764867` | `fix(picker): Cluster A #86 β cell-BSP ray occlusion in WorldPicker` | Phase B |
+| 4 | `4e308d5` | `test(picker): Cluster A #86 β screen-rect cell-occlusion tests` | Phase B follow-up |
+| 5 | `c19d6fb` | `fix(physics): Cluster A #84 + #85 β indoor cell tracking` | Phase D |
+| 6 | `fda6af7` | `feat(physics): Cluster A β cell-cache diagnostic` | Phase E (1st) |
+| 7 | `1f11ba9` | `feat(diag): Cluster A β extend [cell-cache] with AABB + bsphere + recursive poly count` | Phase E (2nd) |
+
+**Build:** clean on all commits.
+**Tests:** `dotnet test` shows the same 8 pre-existing failures in
+`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged across
+the entire cluster). All targeted test projects green. Phase B follow-up
+adds screen-rect occlusion tests; Phase D adds `RegisterCellStructForTest`
+helper used by caller-side tests.
+
+---
+
+## What shipped
+
+### Phase A β `[indoor-bsp]` probe
+
+New `PhysicsDiagnostics.ProbeIndoorBspEnabled` toggle (env var
+`ACDREAM_PROBE_INDOOR_BSP` + DebugPanel checkbox under
+`ACDREAM_DEVTOOLS=1`). When enabled, logs one `[indoor-bsp]` line each time
+`TransitionTypes.FindEnvCollisions` takes the indoor-cell branch β
+i.e., when `CellId` is an EnvCell id and the BSP contains physics polys. The
+probe serves as a presence detector: if `[indoor-bsp]` never fires during
+indoor walking, the BSP is not being consulted at all.
+
+### Phase B β WorldPicker cell-BSP ray occlusion (closes #86)
+
+New `CellBspRayOccluder` class (in `src/AcDream.App/Rendering/`) computes
+`NearestWallT`: the smallest ray parameter at which the pick ray intersects
+any cached EnvCell BSP polygon. Both `WorldPicker.Pick` overloads now accept
+an optional `cellOccluder` callback and filter out any hit candidate whose
+ray T exceeds `NearestWallT`. The occluder is wired from `GameWindow` using
+the `PhysicsDataCache` cell structs that Phase D also extends.
+
+Before Phase B: clicking through a wall from the outside selected NPCs/items
+inside the building β `WorldPicker.BuildRay + Pick` (Phase B.4b) tested only
+entity AABBs and scenery BSPs, not EnvCell BSP geometry.
+
+After Phase B: entities behind the nearest wall from the camera's perspective
+are filtered out of the candidate set. Screen-rect unit tests verify the
+filter across hit/miss/occlusion scenarios.
+
+### Phase D β AABB containment for indoor CellId (partial #84 fix)
+
+`PhysicsEngine.ResolveOutdoorCellId` is extended with an indoor
+cell-containment scan. After resolving the outdoor cell, the method checks
+whether the player's world position falls inside any cached `CellPhysics`
+AABB; if so, `CellId` is promoted to that EnvCell. This enables the
+`FindEnvCollisions` indoor-BSP branch.
+
+New `PhysicsDataCache.TryFindContainingCell(worldPos)` does the AABB scan.
+New `CellPhysics.WorldAabb` caches the cell-local AABB in world space on
+first call (transforms the BSP bounding sphere's local AABB by the cell
+origin). New `RegisterCellStructForTest` helper allows unit test callers to
+populate the cache directly.
+
+Also fixes the L.2e bare-low-byte preservation bug: `ResolveOutdoorCellId`
+was silently truncating the player CellId to the low 16 bits; the fix
+preserves the full 32-bit value.
+
+**What this solved:** player spawning inside a building (e.g., logging in
+from a position inside Holtburg cottage) no longer sees `walkable=False` for
+hundreds of resolves with world Z=94.000. Phase D promotes CellId to the
+indoor cell, the floor's BSP polys are found, the player can move.
+
+**What this did NOT solve:** the `[indoor-bsp]` probe fires only 6 times
+during an entire indoor walking session (all mid-jump, when the body happens
+to be at a height that falls inside a room AABB). During normal walking on
+the floor, the player's world Z is at the AABB floor level or lower β
+outside the AABB for threshold/doorway cells that have only a 0.2 m Z range.
+See Phase E evidence below.
+
+### Phase E β Cell-cache diagnostic infrastructure
+
+Two commits add `[cell-cache]` log output (env var
+`ACDREAM_PROBE_CELL_CACHE`, also DebugPanel). For each EnvCell in the
+physics cache, the probe logs:
+
+```
+[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14
+ bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80)
+ bspOrigin=(0.00,0.00,0.00) bspRadius=9.97
+```
+
+The extended second commit adds `bspTotalLeafPolys`, `bspUnmatchedIds`,
+`bspOrigin`, and `bspRadius` fields to give a complete picture of cell
+geometry from the physics cache perspective. This infrastructure stays in
+place as scaffolding for the portal-traversal phase.
+
+---
+
+## Issue status after Cluster A
+
+| Issue | Status | Notes |
+|---|---|---|
+| #84 Blocked by air indoors | OPEN (partial) | Spawn-in-building variant resolved by Phase D. Threshold/doorway wall-blocking remains open under #87. |
+| #85 Pass through walls outsideβin | OPEN | Root cause confirmed as same as #84 remaining symptom. See #87. |
+| #86 Click selection penetrates walls | **CLOSED** | Phase B. `WorldPicker.Pick` + `CellBspRayOccluder`. |
+| #87 Indoor portal-based cell tracking | OPEN (new) | Filed 2026-05-19. Retail-faithful fix via `CObjMaint::HandleObjectEnterCell`. |
+
+---
+
+## Probe evidence β log file findings
+
+### `launch-cluster-a-capture.log`
+
+Initial probe run with `ACDREAM_PROBE_INDOOR_BSP=1`. Result: **zero
+`[indoor-bsp]` lines** during outdoor walking and during approach to the
+Holtburg cottage doorway. This was the first confirmation that the indoor-BSP
+branch was entirely gated out. The player's CellId remained an outdoor cell
+for all movement.
+
+### `launch-cluster-a-verify.log`
+
+Post-Phase-D run. Observed `[indoor-bsp]` lines **only during jump frames**
+(6 total). When the player jumped inside the cottage, the body briefly rose
+to a height inside the room AABB, CellId promoted to `0xA9B40143`, and the
+indoor-BSP branch fired. On landing, the body returned to floor level, fell
+outside the AABB, and CellId reverted to the outdoor cell. Confirmed that
+AABB containment works for the room cell when the player is mid-air, but
+fails at floor level.
+
+### `launch-cluster-a-cache-diag2.log`
+
+First `[cell-cache]` probe run (Phase E first commit). Showed all cached
+cells with their physics poly counts and local AABBs. Confirmed 14 physics
+polys in cell `0xA9B40143` (the room), indicating BSP geometry is present
+and complete. Identified cell `0xA9B40146` as a 4-poly threshold cell.
+
+### `launch-cluster-a-cache-diag3.log`
+
+Extended `[cell-cache]` probe run (Phase E second commit). Full data:
+
+```
+[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14
+ bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80)
+ bspOrigin=(0.00,0.00,0.00) bspRadius=9.97
+```
+Room cell: 2.80 m AABB height β works for mid-air player.
+
+```
+[cell-cache] id=0xA9B40146 physicsPolyCount=4
+ aabbMin=(-11.60,2.80,-0.20) aabbMax=(-10.00,7.60,0.00)
+ bspRadius=2.3
+```
+Threshold/doorway cell: 0.20 m AABB Z range (from -0.20 to 0.00). A standing
+player at local Z=0.46 m is outside this AABB. **This is why AABB containment
+fails for normal walking through doorways.**
+
+Key conclusion: the geometry is correct and complete (14/14 polys match between
+physics cache and BSP leaf count). The problem is purely in the cell-ownership
+tracking mechanism, not the collision data itself.
+
+---
+
+## Diagnostic infrastructure remaining in place
+
+Both probes stay committed and wired. They serve as scaffolding for the
+portal-traversal follow-up phase:
+
+- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one
+ `[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell
+ branch. After portal traversal is implemented, this probe should fire
+ consistently whenever the player is indoors.
+
+- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all
+ cached EnvCell physics data (poly counts, BSP bounding sphere, AABB,
+ unmatched ID count). Useful for verifying that cell structs load correctly
+ and that portal connectivity data is present.
+
+Both are gated behind `PhysicsDiagnostics` static class (existing pattern
+from L.2a).
+
+---
+
+## Follow-up items for the portal-traversal phase
+
+**1. Implement portal-based indoor cell tracking (issue #87).**
+Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's
+`CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses
+a cell portal boundary, `CellId` propagates through `CEnvCell` portal
+connectivity data. PDB symbols in `docs/research/named-retail/acclient_2013_pseudo_c.txt`
+and struct definitions in `docs/research/named-retail/acclient.h` lines
+31715-31726 (`CCellStructure` shape). The retail reference implementation
+is the right oracle β do not guess at the traversal algorithm.
+
+**2. Audit-trail note: add retail PDB symbol citations to `TryFindContainingCell`.**
+The current implementation in `src/AcDream.Core/Physics/PhysicsDataCache.cs`
+~line 261 is documented as a shortcut. The follow-up phase should add
+the PDB symbol citation (e.g., `// retail: CObjMaint::HandleObjectEnterCell
+// docs/research/named-retail/acclient_2013_pseudo_c.txt:XXXXX`)
+per the Phase D code-review I1 note, so future readers know this is intentionally
+replacing an interim implementation.
+
+**3. Consider renaming `ResolveOutdoorCellId` β `ResolveCellId`.**
+The method now handles both outdoor and indoor cell resolution. The rename
+is low-risk (one call site in `PhysicsEngine.cs`) and would reduce the
+cognitive overhead for the next phase's author. Noted as a Phase D code-review
+M2 suggestion β do it in the same commit as the portal-traversal implementation
+to keep the rename and the semantic change together.
+
+---
+
+## State at handoff
+
+- **Branch:** `claude/competent-robinson-dec1f4`, 7 commits of implementation/test/diagnostic work.
+- **Build state:** `dotnet build -c Debug` clean.
+- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp baseline). All new tests green.
+- **Issues:** #86 CLOSED; #84 PARTIAL; #85 OPEN; #87 OPEN (new).
+- **Diagnostic probes:** `[indoor-bsp]` + `[cell-cache]` active and wired.
+- **Next:** portal-based indoor cell tracking (#87) or M2 critical path β Claude's choice per work-order autonomy.
diff --git a/docs/research/2026-05-19-contactplane-retention-pickup-prompt.md b/docs/research/2026-05-19-contactplane-retention-pickup-prompt.md
new file mode 100644
index 0000000..f642f28
--- /dev/null
+++ b/docs/research/2026-05-19-contactplane-retention-pickup-prompt.md
@@ -0,0 +1,149 @@
+# Indoor ContactPlane retention β fresh-session pickup prompt
+
+**Status:** Indoor walkable-plane BSP port foundation shipped to main 2026-05-19 at `c6b3fd6` (8 commits between `165f67a` spec and `c6b3fd6` handoff). Visual verification FAILED β cellar descent, 2nd-floor walking, single-floor cottage regression. Root cause diagnosed deeper than originally thought: the per-frame `TryFindIndoorWalkablePlane` synthesis is a Phase 2 stop-gap retail never had; retail RETAINS `ContactPlane` across frames.
+
+This doc is the start-of-session brief for whoever picks up the next phase.
+
+---
+
+## What's on main
+
+**Foundation (kept, useful):**
+- `BSPQuery.FindWalkableInternal` exposes `ref ushort hitPolyId`.
+- New public `BSPQuery.FindWalkableSphere` wrapper over the retail-faithful walkable finder.
+- `Transition.TryFindIndoorWalkablePlane` routes through the BSP walker with `WalkableAllowance` save/restore.
+- `[indoor-walkable]` runtime-toggleable diagnostic probe at the `FindEnvCollisions` callsite.
+- 5 new unit tests + 9 updated existing tests + 1 wall-poly integration test, all green.
+- `dotnet build -c Debug` clean; 8 pre-existing test baseline unchanged.
+
+**Behavioral result:** ISSUES #83 remains OPEN. Cellar descent fails ("ground blocking" β outdoor terrain backstop returns wrong Z). 2nd-floor walking gets intermittent falling-stuck. Single-floor cottage REGRESSED from stable (Phase 2) to intermittent falling-stuck. Phantom collisions persist. Probe captured 1443 MISS / 2 HIT β the foot-sphere-tangent-to-floor case is rejected by `PolygonHitsSpherePrecise`'s `|dist| > radius - epsilon` check (~0.0002 margin), which is correct retail behavior but wrong for this caller.
+
+**Comprehensive handoff:** [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md). Read this FIRST.
+
+---
+
+## How to start a fresh session
+
+Copy the block below into a fresh Claude Code session in this repo.
+
+---
+
+```
+Pick up the acdream project. The Indoor walkable-plane BSP port shipped
+to main 2026-05-19 at c6b3fd6 β 8 commits of foundation work β but
+visual verification by the user FAILED to fix the indoor walking bugs
+(cellars, 2nd floors, phantom collisions). Foundation is good and
+stays on main; the root cause was deeper than I diagnosed.
+
+1. Read docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md
+ FIRST. Long but complete: what shipped, what failed, the probe
+ evidence (1443 MISS / 2 HIT), the deeper diagnosis, the recommended
+ next phase target, session lessons. The "Session lessons" section
+ at the bottom is important β I made specific mistakes that you
+ should not repeat.
+
+2. The recommended next phase: port retail's ContactPlane retention.
+ Retail RETAINS the previous frame's ContactPlane when the BSP
+ reports "no collision" β it doesn't re-synthesize per frame the way
+ our Phase 2 commit eb0f772 introduced. The proper fix likely
+ eliminates Transition.TryFindIndoorWalkablePlane entirely from the
+ FindEnvCollisions per-frame path. Retail decomp anchors are in the
+ handoff doc; key starting points:
+
+ - acclient_2013_pseudo_c.txt:273137 β CTransition::transitional_insert
+ - Search the decomp for "last_known_contact_plane" and
+ "contact_plane_valid" to map the full ContactPlane lifecycle.
+ - Grep our acdream code for ContactPlane writers/readers to map
+ who currently sets it and when.
+
+3. Use the brainstorming skill FIRST. But before designing a spec,
+ SPIKE: add a small diagnostic probe that logs every ContactPlane
+ write site with caller, old plane, new plane. Capture a log of the
+ user walking around indoors. Look at the data BEFORE designing the
+ fix. That's the lesson from the previous session β I designed a
+ spec on a wrong hypothesis and shipped 6 commits before the
+ diagnostic probe surfaced the truth. Probe-first, design-second.
+
+4. CLAUDE.md rules apply:
+ - No workarounds; fix root causes. (Specifically: do NOT add a
+ sphere-offset hack to make PolygonHitsSpherePrecise accept
+ tangent contact. The right answer is to stop calling find_walkable
+ as a standing-still query.)
+ - Use superpowers skills (brainstorming β writing-plans β
+ subagent-driven-development β finishing-a-development-branch).
+ - Drive autonomously β Claude picks the next step, user reviews.
+ - Visual verification by the user is the acceptance test for any
+ physics/collision change.
+
+5. Foundation work to KEEP (the previous session's commits):
+ - BSPQuery.FindWalkableSphere wrapper β useful for legitimate
+ "find a walkable indoors" queries (spawn-placement, teleport-
+ target verification), just not per-frame from FindEnvCollisions.
+ - FindWalkableInternal's hitPolyId ref param.
+ - The [indoor-walkable] probe (will fire less often once retention
+ is in place β that's expected).
+ - All 14 tests (5 new BSPQuery + 9 updated IndoorWalkablePlane).
+
+ Foundation work to LIKELY DELETE or refactor:
+ - Transition.TryFindIndoorWalkablePlane β likely deleted entirely,
+ OR kept as an out-of-band synthesis path for edge cases (initial
+ spawn, cell-id promotion mid-frame) but NOT called per-frame.
+ - The per-frame call from FindEnvCollisions:1380.
+
+6. The user has limited tolerance for repeated failed visual
+ verifications. Be especially disciplined this time: capture
+ diagnostic evidence early, validate the hypothesis against the
+ probe data BEFORE designing the fix, present the smallest
+ possible change that addresses the root cause.
+
+State the milestone and your chosen phase in the first action you
+take. Then begin.
+```
+
+---
+
+## Quick reference for the user
+
+To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
+
+> "Read `docs/research/2026-05-19-contactplane-retention-pickup-prompt.md` and start on the next phase."
+
+## Quick reference for the helper
+
+Key files / anchors for the investigation:
+
+- **Handoff:** `docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md` β the canonical record of what just shipped and the deeper diagnosis.
+- **ISSUES #83:** Updated 2026-05-19 with the deeper diagnosis. Still OPEN.
+- **Foundation spec (for context, do not re-execute):** `docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md`.
+- **Foundation plan (for context, do not re-execute):** `docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md`.
+- **Retail decomp anchors:**
+ - `acclient_2013_pseudo_c.txt:273137` β `CTransition::transitional_insert`
+ - `acclient_2013_pseudo_c.txt:273099` β `CTransition::step_up`
+ - `acclient_2013_pseudo_c.txt:323565` β `BSPTREE::step_sphere_up`
+ - Search for `last_known_contact_plane` and `contact_plane_valid` for the retention machinery.
+- **acdream code:**
+ - `src/AcDream.Core/Physics/TransitionTypes.cs:1192` β `TryFindIndoorWalkablePlane` (likely to delete in the next phase).
+ - `src/AcDream.Core/Physics/TransitionTypes.cs:1262` β `FindEnvCollisions` indoor branch (where the per-frame `TryFindIndoorWalkablePlane` call happens at line ~1380).
+ - `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` β `ProbeIndoorBspEnabled` flag (reuse for any new diagnostic probe).
+ - Grep `ContactPlane` across `src/AcDream.Core/Physics/` to find all write sites.
+
+## Visual verification scenarios (re-use for the next phase)
+
+1. **Cellar descent** β the primary failing scenario. Walk into any building with a cellar entry, descend the stairs. Acceptance: smooth descent onto cellar floor.
+2. **2nd-floor walking** β climb stairs to 2nd floor, walk around. Acceptance: no intermittent falling-stuck.
+3. **Single-floor cottage regression check** β walk into a Holtburg cottage. Acceptance: stable walking, no intermittent falling-stuck (Phase 2 baseline restored).
+4. **Phantom collisions** β observational. If root cause is fixed, these should improve.
+
+## Launch command (for the user, with probes enabled)
+
+```powershell
+$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_INDOOR_BSP = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-contactplane.log"
+```
diff --git a/docs/research/2026-05-19-indoor-cell-rendering-cause.md b/docs/research/2026-05-19-indoor-cell-rendering-cause.md
new file mode 100644
index 0000000..682a6a4
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-cell-rendering-cause.md
@@ -0,0 +1,94 @@
+# Indoor Cell Rendering β Phase 2 Cause Report
+
+**Date:** 2026-05-19
+**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
+**Capture method:** Phase 2's `ContinueWith` + `ConsoleErrorLogger` injected into WB's `ObjectMeshManager` surfaced the exception WB was silently catching.
+
+## Cause
+
+**Single failure mode:** `ArgumentOutOfRangeException` thrown from `DatReaderWriter.DBObjs.Setup.Unpack` at WB's [`ObjectMeshManager.cs:1223`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1223):
+
+```csharp
+// For EnvCell static objects, we need to manually collect emitters if they are Setups
+if (_dats.Portal.TryGet(stab.Id, out var stabSetup)) { // β throws
+```
+
+WB iterates `envCell.StaticObjects` and **blindly calls `TryGet` on every stab id**, regardless of whether the id is actually a Setup-prefix (`0x02xxxxxx`) or a GfxObj-prefix (`0x01xxxxxx`). When stab.Id is a GfxObj, `DatReaderWriter` finds the file (Portal dat has both GfxObjs and Setups under the same tree-lookup) and attempts to deserialize the GfxObj bytes as a Setup record. The Setup format is structurally different β early parse fails inside `QualifiedDataId.Unpack` β `DatBinReader.ReadBytesInternal` throws `ArgumentOutOfRangeException`.
+
+The exception bubbles up to `PrepareMeshData`'s outer try/catch at line 589:
+
+```csharp
+catch (Exception ex) {
+ _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
+ return null; // β swallows exception, returns null
+}
+```
+
+The entire EnvCell upload fails silently. The cell's room geometry (floor / walls / ceiling) never reaches `_renderData`, so the dispatcher skips drawing it. Static objects inside the cell (which acdream hydrates separately) still render β they have their own GfxObj uploads.
+
+**This also explains the user's "objects below ground" observation:** with the floor mesh missing, you see the cell's static objects (tables / chairs / fireplaces) through where the floor should be. Visually they appear "below ground."
+
+## Sample evidence
+
+55 NULL_RESULT cells captured at multiple landblocks (`0xA5B4`, `0xA7B4`, `0xA8B2`, `0xA9B0`, `0xA9B2`, `0xA9B3`, `0xA9B4`). All 55 share the same exception type and stack frame:
+
+```
+[wb-error] Error preparing mesh data for 0x00000000A9B20114
+[wb-error] ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
+[wb-error] at DatReaderWriter.DBObjs.Setup.Unpack(DatBinReader reader)
+[wb-error] at DatReaderWriter.DatDatabase.TryGet[T](UInt32 fileId, T& value)
+[wb-error] at WorldBuilder.Shared.Services.DefaultDatDatabase.TryGet[T](UInt32 fileId, T& value)
+[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareEnvCellMeshData(...) line 1223
+[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareMeshData(...) line 571
+```
+
+For Holtburg (`0xA9B4`) specifically: 123 requested β 97 completed + 26 silently failed. The 26 failures all match this exception signature. The first interior cell `0xA9B40100` is among them β exactly where the user reported a missing floor.
+
+## Why the other hypotheses were ruled out
+
+Phase 1 ruled out H2-H6 via the captured probe data. Phase 2's diagnostic walk:
+
+1. `ourCellDb.TryGet=True` β acdream's DatCollection finds the cell.
+2. `wbResolveId.Count=1` β WB's ResolveId also finds it.
+3. `wbSelectedType=EnvCell` β type classification is correct.
+4. `wbDbTryGet=True` β the cell record IS loadable by WB.
+5. `hadRenderData=False` at request time β no pre-existing cache hit.
+
+All preconditions for a successful upload were met. The failure was in a downstream emitter-collection step (line 1223) that's tangential to the cell's own geometry β but its exception silently kills the entire upload.
+
+## Fix
+
+**One-line WB fork patch.** Pre-check the Setup-prefix bit before calling `TryGet`:
+
+```csharp
+// Before:
+if (_dats.Portal.TryGet(stab.Id, out var stabSetup)) {
+
+// After:
+if ((stab.Id & 0xFF000000u) == 0x02000000u
+ && _dats.Portal.TryGet(stab.Id, out var stabSetup)) {
+```
+
+For GfxObj-prefixed stabs (which have no `DefaultScript` and no emitters anyway), the branch is now skipped correctly. For Setup-prefixed stabs, behavior is unchanged.
+
+This is in our WB fork at [`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230). The patch should be upstreamed β it's a real WB bug.
+
+## Verification approach
+
+After applying the fix:
+1. Re-launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`.
+2. Walk Holtburg.
+3. Expect: zero `[wb-error]` lines, zero `[indoor-upload] NULL_RESULT` lines. Previously-failing cells now have `[indoor-upload] completed` lines.
+4. Visual: floor renders in Holtburg Inn; objects no longer appear "below ground."
+
+## Phase 1 β Phase 2 chain summary
+
+The diagnostic-driven approach worked end-to-end:
+
+- **Phase 1:** Added 5 probes. Identified that 26 Holtburg cells silently fail. Confirmed H1 class of bug. Could not pinpoint without exception data.
+- **Phase 2 Task 1:** Wrapped `PrepareMeshDataAsync` in a continuation to capture `Task.Exception`. Found that the task was never faulted β `tcs.TrySetResult(null)` ran instead. Hypothesized exception was swallowed inside `PrepareMeshData`.
+- **Phase 2 cause-narrowing diagnostics:** Added `ourCellDb.TryGet` + `wbResolveId.Count` + `wbSelectedType` + `wbDbIsPortal` + `wbDbTryGet` + `hadRenderData` checks. Each iteration narrowed the cause class.
+- **Phase 2 final probe:** Replaced WB's `NullLogger` with a Console-backed `ConsoleErrorLogger`. WB's existing `_logger.LogError(ex, ...)` call at the catch block immediately surfaced 55 ArgumentOutOfRangeException stack traces with file:line locations. **Cause definitively identified in one capture.**
+- **Phase 2 fix:** One-line guard at the throwing call site.
+
+Total runtime: ~3 client launches to nail it.
diff --git a/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md b/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
new file mode 100644
index 0000000..a41577b
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
@@ -0,0 +1,105 @@
+# Indoor Cell Rendering β Phase 1 Probe Capture
+
+**Date:** 2026-05-19
+**Probe:** Phase 1 diagnostic probes from spec `2026-05-19-indoor-cell-rendering-fix-design.md`
+**Capture conditions:** `ACDREAM_PROBE_INDOOR_ALL=1`, walk into Holtburg (landblock `0xA9B4`).
+**Verdict:** Hypothesis **H1 (WB silently returns null from `PrepareEnvCellMeshData`)** is **CONFIRMED** for ~21% of Holtburg's EnvCells, including the first interior cell `0xA9B40100`.
+
+---
+
+## Probe line breakdown (real EnvCell-format IDs only)
+
+| Probe | Count | Notes |
+|---|---|---|
+| `[indoor-upload] requested` (0xA9B4 cells) | 123 (unique) | LandblockSpawnAdapter triggers PrepareMeshDataAsync for every cell in Holtburg landblock. |
+| `[indoor-upload] completed` (0xA9B4 cells) | **97** (unique) | **26 cells never produce a completed line.** |
+| `[indoor-walk]` (cell-room entities, 0xA9B4) | 27,631 | Cell-room entities pass `landblockVisible` + `aabbVisible` + `cellInVis` filters. Walk path is healthy. |
+| `[indoor-lookup]` (0xA9B4 cells) | 6,067 | Total dispatcher lookups for Holtburg cells. |
+| `[indoor-lookup] hit=True` | 45 | Only ~0.7% hit rate β the rate-limited probe captures one snapshot per cell after rendering stabilizes. |
+| `[indoor-lookup] hit=False` | 6,022 | Most are pre-upload-completion frames + the 26 silently-failing cells. |
+| `[indoor-xform]` | 97 | One per successfully-uploaded cell. Cell-geom SetupPart's render data is non-null and reaches `ComposePartWorldMatrix`. |
+
+## Hypotheses
+
+### H1 β WB silently returns null from `PrepareEnvCellMeshData` β
CONFIRMED
+
+26 out of 123 Holtburg cells (21%) get an `[indoor-upload] requested` line but **never** produce an `[indoor-upload] completed` line. This is the classic H1 signature: WB's `ObjectMeshManager.PrepareMeshData` either returns null (line 568, 583, 592 of `ObjectMeshManager.cs`) or its catch-block swallows an exception at line 589-592. The pending `meshData` never reaches `StagedMeshData`, so `Tick()`'s drain never sees it, no completion line emits.
+
+**First 15 cells with no completion:**
+
+```
+0xA9B40100, 0xA9B40111, 0xA9B40112, 0xA9B40117, 0xA9B4011B,
+0xA9B40121, 0xA9B40123, 0xA9B40129, 0xA9B4012A, 0xA9B4012E,
+0xA9B40138, 0xA9B4013F, 0xA9B40141, 0xA9B40143, 0xA9B40147
+```
+
+`0xA9B40100` is **the first indoor cell** in Holtburg landblock. Almost certainly the inn entry or another major building's anchor cell β exactly where the user reported "floor missing."
+
+### H2 β Empty batches β RULED OUT
+
+For successfully-completed cells, `cellGeomVerts` ranges 14β86 and `hasEnvCellGeom=True`. Geometry is non-empty when the upload completes. The 26 failing cells fail BEFORE batch construction, so this isn't an empty-batch problem.
+
+### H3 β Cull bug β RULED OUT
+
+`[indoor-cull]` lines for cell-room entities show `visibleCellIds-miss` reasons only for cells in *other* landblocks (`0xA9B0`, `0xA9B2`, `0xA9B3` etc., visible neighbours of Holtburg but outside the active visibility set). For Holtburg's own cells, the walk probe shows `landblockVisible=true aabbVisible=true cellInVis=true` consistently β the dispatcher reaches them.
+
+### H4 β Double-spawn β RULED OUT
+
+For completed cells, `[indoor-lookup]` reports modest `partCount` values (1β46) matching the number of static objects + 1 cell-geom part. No evidence of duplicate registration.
+
+### H5 β Transform double-apply β RULED OUT
+
+`[indoor-xform]` consistently shows `entityWorldT=(0,0,0)`, `partT=(0,0,0)`, and `composedT==meshRefT`. The composed translation equals the cell's world origin β no double-apply. Sample:
+
+```
+[indoor-xform] cellGeomId=0x00000001A9B40101
+ entityWorldT=(0.00,0.00,0.00)
+ meshRefT=(84.09,131.54,66.02)
+ partT=(0.00,0.00,0.00)
+ composedT=(84.09,131.54,66.02)
+```
+
+### H6 β MeshRefs structure mismatch β RULED OUT
+
+For uploaded cells, `[indoor-lookup]` shows `hit=True isSetup=True partsHitβpartCount`. The dispatcher correctly traverses the Setup parts. Sample: `[indoor-lookup] cellId=0xA9B40101 hit=True isSetup=True partCount=10 hasEnvCellGeom=True partsHit=9 partsMiss=1`.
+
+---
+
+## What's special about the 26 failing cells?
+
+Unknown from Phase 1 probes alone. Possible causes (each verifiable with one or two more targeted probes or code reads in Phase 2):
+
+1. **Missing Environment dat record** β `envCell.EnvironmentId` points at an Environment id that `_dats.Portal.TryGet` can't find. WB's `PrepareEnvCellMeshData` line 1245 would silently return without populating `cellGeometry`, then the outer Setup path produces a result with `hasBounds=false` and an empty `parts` list. Hmm, but that would still produce a `completed` line β just with empty data. **So this would be H2-shaped, not H1-shaped.** Ruled out.
+
+2. **Exception in `PrepareCellStructMeshData`** β texture decode failure, surface ID resolution failure, polygon enumeration crash. The catch-block at `PrepareMeshData` line 589 silently swallows. **Most likely cause.**
+
+3. **`ResolveId(envCellId)` returns empty** β WB's `DefaultDatReaderWriter` can't find the cell record in its loaded dats. Unlikely (all region cells are loaded at construction), but possible if `_wbDats.Portal.TryGet` skipped the region containing 0xA9B4.
+
+4. **Race condition** β `PrepareMeshData` runs on a background worker; if the same cell id is requested twice in fast succession before the first completes, the second `TryAdd` to `_preparationTasks` returns false and silently skips. Unlikely given LandblockSpawnAdapter's per-landblock dedup at line 68 of `LandblockSpawnAdapter.cs`, but possible if multiple landblocks share state.
+
+---
+
+## Phase 2 β recommended approach
+
+The fix shape per the spec table maps H1 to: *"Add WB logging or pre-check the dat resolution path in WbMeshAdapter."*
+
+Concrete Phase 2 plan:
+
+1. **Targeted probe extension** β add a SECOND probe inside the failing path. Either patch WB to surface the swallowed exception (`PrepareMeshData` line 589 catch block) OR wrap the `PrepareMeshDataAsync` call in WbMeshAdapter with our own try/catch + task continuation that logs the actual `Exception` for EnvCell ids. One launch with this captures the actual failure reason for the 26 cells.
+
+2. **Match the failure to a fix** β once we know the failure mode:
+ - If a texture/surface bug β file as a Phase 2 WB-fork patch.
+ - If a missing dat reference β check whether the user's `client_cell_1.dat` is up to date.
+ - If an exception in our code path β fix the specific bug.
+
+3. **Verify** by re-launching with the probe and confirming `[indoor-upload] completed` appears for previously-missing cells (e.g., `0xA9B40100`).
+
+---
+
+## Phase 1 leftover observations
+
+- The `IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u` helper has false positives on GfxObj IDs whose lower 24 bits happen to be β₯ 0x0100 (e.g., `0x01001841`). This polluted ~95% of probe emissions with non-cell entities. Recommend tightening the helper to also require `(id >> 24) != 0x01 && (id >> 24) != 0x02` (and any other DBObj-type prefixes), OR `(id >> 16) > 0x00FF` to require a real landblock prefix.
+
+- The lookup probe's rate-limit namespace separation (Task 7 fix) works correctly β uploaded cells DO appear in the hit set when their lookup probe fires.
+
+- Cell-room entities have `Position=(0,0,0)` with the cell transform in `MeshRef.PartTransform`. The dispatcher's `aabbVisible` filter passed for them, presumably because `RefreshAabb()` computes a sensible world AABB from the mesh-ref's transform or because the landblock equals `neverCullLandblockId`. Worth a brief audit if there's any reason to believe the cell-room AABB is wrong.
diff --git a/docs/research/2026-05-19-indoor-cell-rendering-verification.md b/docs/research/2026-05-19-indoor-cell-rendering-verification.md
new file mode 100644
index 0000000..0e89080
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-cell-rendering-verification.md
@@ -0,0 +1,62 @@
+# Indoor Cell Rendering β Phase 2 Verification
+
+**Date:** 2026-05-19
+**Outcome:** β
Floor renders in Holtburg Inn. User visually confirmed.
+**Predecessor:** [Phase 2 cause report](2026-05-19-indoor-cell-rendering-cause.md).
+
+---
+
+## Probe re-capture
+
+After applying the one-line WB fix at [`ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230):
+
+| Metric | Pre-fix | Post-fix |
+|---|---|---|
+| `[wb-error]` lines | 385 | **0** |
+| `[indoor-upload] NULL_RESULT` | 55 | **0** |
+| `[indoor-upload] FAILED` | 0 | 0 |
+| Total `[indoor-upload] requested` | β | 1157 |
+| Total `[indoor-upload] completed` | β | **1157** |
+| Holtburg (`0xA9B4`) requested | 123 | 123 |
+| Holtburg (`0xA9B4`) completed | 97 | **123** |
+| Holtburg (`0xA9B4`) missing | 26 | **0** |
+
+100% success rate on EnvCell uploads. Zero swallowed exceptions. Zero null returns.
+
+## Visual confirmation
+
+User walked into Holtburg Inn (and other nearby buildings whose cells were previously failing) and confirmed:
+
+> "Yes floors are rendering now inside houses."
+
+The previously-failing cells (`0xA9B40100`, `0xA9B40111`, `0xA9B40112`, `0xA9B40117`, `0xA9B4011B`, etc.) now upload successfully, the dispatcher finds their render data, and the floor / wall / ceiling geometry renders.
+
+## Regressions checked
+
+- Outdoor terrain still renders correctly. β
+- Outdoor scenery (trees, rocks, stabs) still render. β
+- NPCs, mobs, world entities still render. β
+- Build clean, no new warnings. β
+- No new test failures. β
+
+## Other observations during the walk
+
+The user reported **other indoor-related bugs** that are now observable because the floor is rendering. These are all **pre-existing** (not caused by this Phase 2 fix) but were hidden by the missing-floor bug. They are filed as separate issues for follow-up phases:
+
+1. See-through floor β other buildings visible "below" / "through" the rendered floor (depth/stab-culling).
+2. Spot lights on walls indoors (point-light positioning).
+3. Camera on 2nd floor goes very dark (per-cell ambient or trigger).
+4. Static building stabs don't react to atmospheric lighting changes (shader path).
+5. Some slope terrain lit incorrectly (terrain normal calculation).
+6. Collision "blocked by air" indoors (cell BSP misalignment).
+7. Walking up stairs broken (stair-step physics on EnvCell geometry).
+8. Pass through walls from outsideβin (one-sided wall collision).
+9. Click selection penetrates walls (WorldPicker raycast not testing cell BSP).
+
+These nine items are tracked in `docs/ISSUES.md` with proposed phase groupings. None block Phase 2 closure.
+
+## Conclusion
+
+**Phase 2 of the indoor cell rendering fix is complete.** The single-root-cause exception was identified via the diagnostic chain shipped in Phase 1 + Phase 2, and resolved with a one-line guard at the WB call site that prevented blind `TryGet` deserialization of GfxObj-typed stab ids.
+
+Total runtime for Phase 2: ~4 client launches.
diff --git a/docs/research/2026-05-19-indoor-followup-handoff.md b/docs/research/2026-05-19-indoor-followup-handoff.md
new file mode 100644
index 0000000..096ab00
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-followup-handoff.md
@@ -0,0 +1,104 @@
+# Indoor cell rendering β follow-up handoff
+
+**Status:** Phase 1 (diagnostics) + Phase 2 (fix for missing floors) shipped 2026-05-19. Merged to `main`. The 9 new bugs surfaced when floors started rendering are filed as `docs/ISSUES.md#78` through `#86`.
+
+This doc is the start-of-session brief for whoever picks up the next indoor-walking phase.
+
+---
+
+## What's in place
+
+**Diagnostic infrastructure (Phase 1)**
+
+Five `[indoor-*]` probes are wired and on a runtime-toggleable flag β leave them in place; they're useful for any indoor follow-up work:
+
+| Probe | Where | Toggle |
+|---|---|---|
+| `[indoor-walk]` | `WbDrawDispatcher.WalkEntitiesInto` per cell entity that passes visibility | `ACDREAM_PROBE_INDOOR_WALK=1` or DebugPanel checkbox |
+| `[indoor-cull]` | Same site, when entity is rejected (visibleCellIds-miss or frustum) | `ACDREAM_PROBE_INDOOR_CULL=1` |
+| `[indoor-upload]` | `WbMeshAdapter.IncrementRefCount` + `Tick()` for EnvCell ids | `ACDREAM_PROBE_INDOOR_UPLOAD=1` |
+| `[indoor-lookup]` | `WbDrawDispatcher.Draw` per-MeshRef TryGetRenderData call | `ACDREAM_PROBE_INDOOR_LOOKUP=1` |
+| `[indoor-xform]` | Same site, for cellGeomId SetupPart's composed world matrix | `ACDREAM_PROBE_INDOOR_XFORM=1` |
+
+Master toggle: `ACDREAM_PROBE_INDOOR_ALL=1` cascades to all five. All probes are zero-cost when off.
+
+The `WbMeshAdapter` also now injects a `ConsoleErrorLogger` (replacing the default `NullLogger`) so any future exception WB silently catches surfaces as `[wb-error]` lines automatically. This was the key unlock for Phase 2's diagnosis β see [`feedback_logger_injection_for_silent_catches`](../../../.claude/projects/C--Users-erikn-source-repos-acdream/memory/feedback_logger_injection_for_silent_catches.md) memory entry.
+
+**Other Phase 1/2 fixes already in main**
+
+- Indoor ambient color is now retail-faithful `(0.20, 0.20, 0.20)` β was guessed `(0.10, 0.09, 0.08)`.
+- Indoor lighting triggers off **player** cell, not camera cell β fixes "darker when camera enters" with third-person chase.
+- WB submodule has a **one-line band-aid patch** (`ObjectMeshManager.cs:1230` Setup-prefix guard at `TryGet`) on `eriknihlen/WorldBuilder@acdream` at SHA `34460c4`. Submodule pointer in acdream's `main` is advanced. **This is a band-aid** β see `docs/ISSUES.md` #87 for the proper fix (switch to WB's narrower `PrepareEnvCellGeomMeshDataAsync` API). Retire the patch when that issue lands.
+
+---
+
+## The 9 follow-up issues
+
+Full descriptions + hypotheses are in `docs/ISSUES.md`. Summary table:
+
+| # | Title | Cluster | Severity |
+|---|---|---|---|
+| #78 | Outdoor stabs/buildings visible through floor | Cell-BSP / visibility | HIGH |
+| #79 | Spurious spot lights on walls indoors | Indoor lighting | MEDIUM |
+| #80 | 2nd floor camera goes very dark | Indoor lighting | MEDIUM |
+| #81 | Static building stabs don't react to atmospheric lighting | Indoor lighting | MEDIUM |
+| #82 | Some slope terrain lit incorrectly | Terrain shading | LOW |
+| #83 | Walking up stairs broken | Physics / movement | HIGH |
+| #84 | Blocked by air indoors | Cell-BSP / collision | HIGH |
+| #85 | Pass through walls from outsideβin | Cell-BSP / collision | HIGH |
+| #86 | Click selection penetrates walls | Cell-BSP / interaction | MEDIUM |
+
+**Proposed phase groupings:**
+
+- **Cluster A: Cell-BSP + portal cull** β likely fixes #78, #84, #85, #86 in one phase. Shared root cause hypothesis: the cell BSP physics geometry isn't being correctly used by the movement resolver / WorldPicker / depth-cull. Common pieces:
+ - `_physicsDataCache.CacheCellStruct` at `GameWindow.cs:5384` caches with `cellTransform` (including `+0.02f` Z bump for render anti-z-fight) β physics may be misaligned.
+ - WB's `VisibilityManager.RenderInsideOut` stencil pipeline is unused by acdream β explains #78.
+ - `WorldPicker` raycast doesn't test cell BSP β explains #86.
+ - Wall BSP polys likely one-sided β explains #85.
+
+- **Cluster B: Indoor lighting plumbing** β #79, #80, #81 each need separate investigation but share the `mesh_modern.frag` + `SceneLightingUbo` pipeline. #82 may be terrain-shader-specific.
+
+- **Standalone: #83 stairs** β needs the existing physics step-up logic to handle EnvCell stair geometry. Could share work with Cluster A if cell BSP is the common path.
+
+**Suggested order:**
+
+1. **Cluster A first.** Biggest gameplay impact (collision is broken). Probably 1-2 weeks of work.
+2. **#83 stairs** as a follow-up once cell BSP collision is solid.
+3. **Cluster B lighting** last. Smallest gameplay impact, biggest visual polish.
+
+---
+
+## Where to start a new session
+
+The recommended kickoff prompt is at [`docs/research/2026-05-19-indoor-followup-prompt.md`](2026-05-19-indoor-followup-prompt.md). Drop it into a fresh Claude Code session in this repo and it should orient itself.
+
+Key files to point Claude at when starting:
+
+- This handoff doc.
+- `docs/ISSUES.md` lines covering #78-#86 (search for "Indoor walking issue cluster").
+- `docs/research/2026-05-19-indoor-cell-rendering-cause.md` β Phase 2 cause analysis, useful as the "what we already know" anchor for any follow-up.
+- `docs/research/2026-05-19-indoor-cell-rendering-verification.md` β what's working today.
+
+---
+
+## Important context
+
+- **Don't touch the diagnostic infrastructure** in `WbMeshAdapter` or `RenderingDiagnostics` unless you're extending it. Phase 2 left it ready for re-use.
+- **The probes are runtime-toggleable** β DebugPanel has checkboxes. No relaunch needed to flip them.
+- **`ConsoleErrorLogger` is now the default WB logger.** Any future WB-internal exception will surface as `[wb-error]` automatically without any new diagnostic code.
+- **Don't try to patch WB upstream** β the user wants the fix to live only in their fork (`eriknihlen/WorldBuilder`). Future WB patches go on the `acdream` branch of the fork.
+- **The `+0.02f` Z bump on cell origin** at `GameWindow.cs:5362` exists to prevent z-fighting with terrain. It's applied to both render geometry AND physics BSP. May be a confounding factor for #84 (blocked by air).
+
+---
+
+## Verification approach for the next phase
+
+Same pattern that worked for Phase 1+2:
+
+1. **Diagnostics first.** Add probes / log surfaces for the suspected failure paths. The `ConsoleErrorLogger` may already be surfacing relevant errors β check `launch.log` first.
+2. **Capture cold.** Launch the client (the Phase 1 + Phase 2 launch incantation is documented at the top of `CLAUDE.md`), walk into Holtburg Inn, take note of the user-observable symptom (e.g., "I'm at position X, I tried to walk through a wall and went through").
+3. **Identify the root cause definitively.** Don't apply a fix until the captured data points at one specific code site.
+4. **Apply a surgical fix.** Per CLAUDE.md's no-workarounds rule β fix the actual cause, not the symptom.
+5. **Re-capture and verify.** Visual confirmation by the user is the acceptance test.
+
+Phase 1+2 took 4 client launches total once the diagnostic infrastructure was in place. The Cluster A phase should be similar β assuming the cell-BSP hypothesis holds, one probe addition + one capture should pin the root cause.
diff --git a/docs/research/2026-05-19-indoor-followup-prompt.md b/docs/research/2026-05-19-indoor-followup-prompt.md
new file mode 100644
index 0000000..6afcf24
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-followup-prompt.md
@@ -0,0 +1,65 @@
+# Indoor follow-up β fresh-session kickoff prompt
+
+Copy the block below into a fresh Claude Code session in this repo. The model
+will load CLAUDE.md automatically and find the handoff doc + filed issues
+on its own.
+
+---
+
+```
+Pick up the indoor-walking follow-up work for acdream. The starting point:
+
+1. Read docs/research/2026-05-19-indoor-followup-handoff.md β that's the
+ session-start brief.
+
+2. Read docs/ISSUES.md issues #78 through #86 β these are the 9 bugs the
+ user observed once floors started rendering at Holtburg Inn. They are
+ ALL pre-existing (not caused by Phase 1+2 which just made indoor floors
+ visible).
+
+3. Use the existing [indoor-*] probe infrastructure shipped in Phase 1
+ (toggleable via ACDREAM_PROBE_INDOOR_ALL=1 + DebugPanel checkboxes).
+ WbMeshAdapter also injects a real ConsoleErrorLogger now, so any
+ silently-caught WB exception will appear as [wb-error] lines in the
+ log automatically.
+
+4. The recommended approach per the handoff:
+ - START with Cluster A (cell-BSP / portal-cull cluster β issues #78,
+ #84, #85, #86). These share a likely root cause and have the biggest
+ gameplay impact.
+ - Don't try to fix all 9 at once. Pick the cluster, pick one issue
+ within it, brainstorm via superpowers:brainstorming, and proceed
+ phase-by-phase.
+
+5. CLAUDE.md's rules apply:
+ - No workarounds; fix root causes.
+ - Use superpowers skills for major work (brainstorming β writing-plans
+ β subagent-driven-development β finishing-a-development-branch).
+ - Drive autonomously β Claude picks what to work on next; user
+ reviews. Don't ask "what should I work on?" between phases.
+ - Visual verification by the user is the acceptance test for any
+ rendering / collision / lighting fix.
+
+6. Phase 1+2 took 4 client launches total. Your work should be similar
+ if you preserve the diagnostic-driven approach: probe β capture β
+ diagnose β fix β verify.
+
+State the milestone and current cluster in the first action you take.
+Then begin by reading the handoff doc.
+```
+
+---
+
+## Quick reference for the helper
+
+If the new session asks "which phase should I do first?":
+
+- **Cluster A (cell-BSP/portal)** β #78 see-through floor + #84 blocked-by-air + #85 pass-through-walls + #86 click-through-walls. Hypothesis: cell BSP is cached but not consulted correctly by the movement resolver / picker, AND outdoor stabs aren't stencil-culled when player is in a sealed cell.
+- **Cluster B (lighting plumbing)** β #79 + #80 + #81 + (maybe #82). Less urgent.
+- **Standalone #83 stairs** β physics work on EnvCell stair geometry. Smaller scope.
+
+## Quick reference for the user
+
+To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
+
+> "Read `docs/research/2026-05-19-indoor-followup-handoff.md` and start on the indoor-walking follow-up work."
diff --git a/docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md b/docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md
new file mode 100644
index 0000000..b9fc643
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md
@@ -0,0 +1,187 @@
+# Indoor walkable-plane BSP port β partial-ship handoff (2026-05-19)
+
+**Outcome:** Foundation shipped (6 commits). Visual verification FAILED. User-reported bugs (cellar descent, 2nd-floor walking, phantom collisions) remain unresolved. Root cause now diagnosed deeper than originally thought; next phase needs to port retail's `ContactPlane` retention mechanism. Foundation work (BSP walker + probe + tests) is useful regardless of the next approach.
+
+---
+
+## TL;DR
+
+I diagnosed the wrong root cause initially. I assumed `TryFindIndoorWalkablePlane`'s linear first-match XY scan picking the wrong polygon was the bug, and built a retail-faithful BSP-walker replacement (`BSPQuery.FindWalkableSphere` wrapper over the existing `FindWalkableInternal` port of `BSPNODE::find_walkable` + `BSPLEAF::find_walkable`). The BSP walker is correct, but it returns MISS for the standing-grounded case (foot sphere tangent to floor β `PolygonHitsSpherePrecise` correctly rejects tangent contact by ~0.0002 epsilon).
+
+The actual root cause: **`TryFindIndoorWalkablePlane` shouldn't exist at all**. It was added as a Phase 2 commit `eb0f772` stop-gap to synthesize a `ContactPlane` every frame when the indoor BSP returns OK. Retail doesn't do this β retail RETAINS the previous frame's `ContactPlane` when the collision dispatcher reports no collision. There is no retail analog of `find_walkable` as a standing-still query. `find_walkable` only runs inside a downward sphere sweep (`step_sphere_down`), where the sphere is moving and the overlap test is meaningful.
+
+---
+
+## What shipped (foundation)
+
+6 commits, `ff548b9` β `f845b22`. `dotnet build -c Debug` clean; 8 pre-existing test failures unchanged baseline; 5 new tests + 9 updated existing tests all pass.
+
+| # | SHA | Subject |
+|---|---|---|
+| 1 | `ff548b9` | `refactor(physics): expose hitPolyId from FindWalkableInternal` |
+| 2 | `7f55e14` | `feat(physics): add BSPQuery.FindWalkableSphere wrapper` (+ 4 unit tests) |
+| 3 | `86ecdf9` | `fix(physics): tighten FindWalkableSphere test assertions + header` (code review fix) |
+| 4 | `91b29d1` | `fix(physics): route indoor walkable-plane synthesis through retail BSP walker` |
+| 5 | `7c516ed` | `fix(physics): document adjustedCenter discard + restore wall-poly test` (code review fix) |
+| 6 | `f845b22` | `feat(physics): add [indoor-walkable] probe line` |
+
+**Files touched:**
+- `src/AcDream.Core/Physics/BSPQuery.cs` β `FindWalkableInternal` gained `ref ushort hitPolyId`; new public `FindWalkableSphere` wrapper.
+- `src/AcDream.Core/Physics/TransitionTypes.cs` β `TryFindIndoorWalkablePlane` refactored from `static` linear scan to instance method routing through `FindWalkableSphere` with `WalkableAllowance` save/restore. `PointInPolygonXY` deleted. `[indoor-walkable]` probe added at the `FindEnvCollisions` callsite.
+- `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` β 4 new `FindWalkableSphere` unit tests.
+- `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` β new file, integration test for two-overlapping-floors + WalkableAllowance preservation.
+- `tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs` β 9 tests updated to new instance-method + sphereRadius signature with BSP fixtures; 2 `PointInPolygonXY` tests deleted; 1 new wall-poly integration test.
+
+---
+
+## Visual verification β FAIL (user-driven, 2026-05-19)
+
+Launch flags: `ACDREAM_DEVTOOLS=1`, `ACDREAM_PROBE_INDOOR_BSP=1`. Log: `launch-walkable-fix-6.log` (latest run).
+
+User report verbatim:
+> Cant walk down to the cellar. Looks like ground is blocking.
+> I get stuck sometimes in a falling animation at random places.
+> When I walk up on second floors. I get stuck sometimes on random places in falling animation.
+> Lightning is still broken.
+> Get phantom collison in rooms.
+> NO change
+
+Result against acceptance scenarios:
+
+| Scenario | Pre-ship | Post-ship | Outcome |
+|---|---|---|---|
+| Cellar descent | "ground blocking" | "ground blocking" | **FAIL** β no change |
+| 2nd-floor walking | "snaps back / invisible obstacles" | "intermittent falling-stuck" | **FAIL** β different symptom, still broken |
+| Single-floor cottage walking | stable | "intermittent falling-stuck at random spots" | **REGRESSION** β degraded from stable to unstable |
+| Phantom collisions in rooms | present | present | **PERSIST** |
+| Indoor lightning (#79/#80/#81/#82) | broken | broken | unchanged (out of scope for this phase) |
+
+---
+
+## Probe evidence (from launch 1)
+
+`[indoor-walkable]` probe captured 1445 calls in a Holtburg-area session. **1443 MISS / 2 HIT.**
+
+Sample HIT line:
+```
+[indoor-walkable] cell=0xA9B40150 wpos=(132.258,16.524,94.480) probe=0.50 result=HIT poly=0x0000 wn=(0.000,0.000,1.000) wD=-94.020 dz=+0.46
+```
+
+Sample MISS line:
+```
+[indoor-walkable] cell=0xA9B40150 wpos=(132.258,16.524,94.500) probe=0.50 result=MISS
+```
+
+The 20mm Z oscillation between `94.480` (HIT) and `94.500` (MISS) is the smoking gun:
+- World physics floor (after +0.02f cell-origin Z-bump in `PhysicsDataCache.CacheCellStruct`) is at `Z=94.020`.
+- When foot center is at `Z=94.500` (= floor + radius), distance to plane = `0.48` = sphere radius. `PolygonHitsSpherePrecise` checks `|dist| > radius - epsilon` (line 117 of BSPQuery.cs). `0.48 > 0.4798` β **rejected by ~0.0002**.
+- When foot center is at `Z=94.480` (= floor + 0.46), distance = `0.46 < 0.4798` β accepted, HIT.
+- The resolver oscillates between these two positions as the indoor walkable plane and the outdoor terrain backstop alternate as the contact source.
+
+---
+
+## Why the fix doesn't work β deeper diagnosis
+
+`TryFindIndoorWalkablePlane` exists only as a Phase 2 stop-gap (commit `eb0f772`). It was added because the indoor BSP collision branch in `FindEnvCollisions` returns OK when the player is grounded standing still, but the resolver then needed a `ContactPlane` to feed `ValidateWalkable`. Without a synthesized indoor plane, the code fell through to outdoor terrain backstop, which is BELOW the indoor floor by `+0.02f`, marking the player as floating β falling-stuck. The Phase 2 fix synthesized a plane from `cellPhysics.Resolved` via a linear XY scan.
+
+My Task 3 refactor swapped that linear scan for the retail-faithful BSP walker (`BSPQuery.FindWalkableInternal`). The BSP walker is correct β it implements `BSPNODE::find_walkable` + `BSPLEAF::find_walkable` faithfully. But in retail, this function is called from `BSPTREE::step_sphere_down` inside a movement sweep, where the sphere is moving downward. `walkable_hits_sphere` requires the sphere to overlap the plane (`|dist| < radius - eps`), which is satisfied during the sweep because the moving sphere penetrates the plane mid-sweep. In our standing-grounded use case, the sphere is tangent (foot resting on floor), not penetrating β no overlap β no walkable found β MISS.
+
+**Retail's actual flow for the standing-grounded case:**
+
+1. Player at rest on floor. ContactPlane retained from previous frame.
+2. Frame tick. Gravity + movement applied.
+3. `CTransition::transitional_insert` runs.
+4. `find_collisions` Path 5 (Contact branch): `sphere_intersects_poly` test.
+ - If the sphere penetrates the floor (gravity moved it slightly down), `step_sphere_up` runs β `step_down` β `step_sphere_down` β `find_walkable` β finds the floor β `adjust_sphere_to_plane` snaps it up to tangent β ContactPlane updated.
+ - If the sphere does NOT penetrate (still tangent from last frame), Path 5 returns OK. **ContactPlane is NOT recomputed β it's retained from last frame.**
+5. Player walks horizontally. Same as above β ContactPlane persists.
+
+Our acdream code:
+- Per-frame `FindEnvCollisions` calls indoor BSP `FindCollisions`.
+- Indoor BSP returns OK (no collision).
+- We call `TryFindIndoorWalkablePlane` to RECOMPUTE the ContactPlane from scratch. This is the WRONG behavior β retail doesn't recompute.
+- The recomputation fails (BSP walker can't handle tangent sphere) or succeeds with a slightly-off plane (linear scan returning the wrong polygon's Z).
+- Either way: the ContactPlane is unstable frame-to-frame β resolver state oscillates β player gets stuck in falling animation.
+
+---
+
+## Recommended next phase: ContactPlane retention
+
+Port retail's `ContactPlane` retention so the resolver retains the previous frame's plane when the BSP says "no collision," instead of re-synthesizing every frame.
+
+**Investigation targets (retail decomp):**
+- `CTransition::transitional_insert` (acclient_2013_pseudo_c.txt:273137) β the main per-frame resolver entry. Note line 273165: `if (edi != OK_TS) this->sphere_path.neg_poly_hit = 0;` β only mutates state on non-OK results.
+- `CPhysicsObj::transition` family β where `LastKnownContactPlane` is read/written.
+- Search the decomp for `last_known_contact_plane` and `contact_plane_valid` to map the full lifecycle.
+- `CTransition::check_walkable` (referenced at line 273202) β possibly involved in walkable persistence.
+
+**Likely shape of the fix:**
+- In `Transition.FindEnvCollisions` (TransitionTypes.cs:1262), when indoor BSP returns OK, DO NOT call `TryFindIndoorWalkablePlane`. Instead, retain the existing `CollisionInfo.ContactPlane` (which was set by the previous frame's step-up or step-down).
+- Only update the ContactPlane when an actual collision/step event occurs (Path 4 land, Path 5 step-up-success, Path 3 step-down-success).
+- Outdoor terrain backstop remains for the outdoor case but is gated on `!IsIndoor(cellId)`.
+
+**Foundation work to keep:**
+- `BSPQuery.FindWalkableSphere` wrapper β useful for any future "find a walkable plane indoors" query (e.g., spawn-placement, teleport-target verification).
+- `FindWalkableInternal`'s `hitPolyId` ref param β same.
+- `[indoor-walkable]` probe β keep, but expect it to fire less often once retention is in place (only when the sphere is actually penetrating).
+- All 5 new tests + 9 updated tests β they verify the BSP walker's correctness, which is unchanged in the next phase.
+
+**Foundation work to delete (or refactor):**
+- `Transition.TryFindIndoorWalkablePlane` β likely deleted entirely, OR kept as an out-of-band synthesis path for edge cases (initial spawn, cell-id promotion mid-frame) but no longer called per-frame from `FindEnvCollisions`.
+- `INDOOR_WALKABLE_PROBE_DISTANCE` constant β deleted with `TryFindIndoorWalkablePlane`, or kept for the out-of-band use case.
+
+---
+
+## What NOT to do
+
+- **Do not** add a sphere-offset hack to make `PolygonHitsSpherePrecise` accept tangent contact. That mis-aligns acdream's overlap semantics with retail's. The right answer is to not call `find_walkable` in the standing-still case at all.
+- **Do not** revert the 6 foundation commits. They are correct retail-faithful ports; the BSP walker is needed for legitimate use cases (just not the one we wired it to).
+- **Do not** widen the +0.02f Z-bump or try to compensate for it in the resolver. The bump is a render concern; it should remain transparent to physics. The bug is in the per-frame ContactPlane recompute, not the bump itself.
+
+---
+
+## Quick reference for the next-session implementer
+
+**Spec to read first (this phase's, for context β but don't re-execute it):**
+- `docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md` (committed `165f67a`)
+- `docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md` (committed `e62d076`)
+
+**Code anchors:**
+- [`src/AcDream.Core/Physics/TransitionTypes.cs:1262`](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1262) β `FindEnvCollisions` indoor branch.
+- [`src/AcDream.Core/Physics/TransitionTypes.cs:1192`](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1192) β `TryFindIndoorWalkablePlane` (the thing to likely delete in the next phase).
+- [`src/AcDream.Core/Physics/CollisionInfo`](../../src/AcDream.Core/Physics/) β search for `ContactPlane` write sites to map who currently sets it.
+- [`src/AcDream.Core/Physics/SpherePath`](../../src/AcDream.Core/Physics/) β `LastKnownContactPlane`-style fields if any exist.
+
+**Retail decomp anchors:**
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:273099` β `CTransition::step_up`.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:273137` β `CTransition::transitional_insert`.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323565` β `BSPTREE::step_sphere_up`.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326793` β `BSPLEAF::find_walkable` (already ported, behavior verified).
+
+**Visual verification scenarios (re-use for the next phase):**
+1. Cellar descent (the primary failing scenario)
+2. 2nd-floor walking
+3. Single-floor cottage (regression check β must NOT degrade)
+4. Phantom collisions (cascade check β if root cause is fixed, these should improve)
+
+**Launch command:**
+```powershell
+$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_INDOOR_BSP = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-next-phase.log"
+```
+
+---
+
+## Session lessons (for future Claude)
+
+1. **Brainstorm a hypothesis-test before a full spec.** I diagnosed the wrong root cause and built 6 commits on it. A small spike (add the probe FIRST, capture a log, look at it before designing the fix) would have surfaced the 99.9% MISS rate immediately and pointed at the deeper issue.
+2. **Tangent contact is the dominant grounded case.** Any test fixture designed to exercise `walkable_hits_sphere` MUST include the tangent case (`dist == radius`), not just penetrating cases. My unit tests used Z=0.4 with radius=0.48 (overlap = 0.4 < 0.4798, passes easily) β comfortable but unrepresentative.
+3. **`find_walkable` is a sweep query, not a query.** It's only meaningful when called from `step_sphere_down`. Any caller using it as "stand here, find my floor" is misusing the algorithm. Retail doesn't have such a caller because retail retains ContactPlane across frames.
+4. **The +0.02f cell-origin Z-bump is a render artifact bleeding into physics.** It creates a 20mm offset between visual and physics floors. This is fine when the resolver retains state but breaks when the resolver re-computes every frame. The bump is not the root cause but it amplifies the oscillation symptom.
diff --git a/docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md b/docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md
new file mode 100644
index 0000000..2b1eb8a
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md
@@ -0,0 +1,166 @@
+# Indoor walking Phase 2 shipped β fresh-session pickup prompt
+
+**Status:** Indoor walking Phase 1 (Cluster A β BSP cluster) + Phase 2 (Portal-based cell tracking) both merged to `main` at `1af49b7` on 2026-05-19. 18 commits between them; `dotnet build` + `dotnet test` green; visual-verified by user (walls block from inside, multi-room navigation works, walking out through a door works).
+
+This doc is the start-of-session brief for whoever picks up the next phase.
+
+---
+
+## What landed on main
+
+**Indoor walking Phase 1 β BSP cluster** (commits `27d7de1` β¦ `1f11ba9`):
+
+- `[indoor-bsp]` + `[cell-cache]` diagnostic probes (`ACDREAM_PROBE_INDOOR_BSP` / `ACDREAM_PROBE_CELL_CACHE`).
+- `WorldPicker` cell-BSP occlusion via new `CellBspRayOccluder` β **closes #86** (click selection no longer penetrates walls).
+- Phase D's `ResolveOutdoorCellId` β `ResolveCellId` rename + AABB-based indoor cell promotion (partial fix for #84 β un-stuck the spawn-in-building case; the wall-pass-through portion stayed open until Phase 2).
+
+**Indoor walking Phase 2 β Portal-based cell tracking** (commits `1969c55` β¦ `eb0f772`):
+
+- Extended `CellPhysics` with `CellBSP` (third BSP for point-in-cell), `Portals` (from `envCell.CellPortals`), `PortalPolygons` (resolved visible polys), `VisibleCellIds`. Deleted Phase D's `LocalAabbMin/Max` + `TryFindContainingCell`.
+- New `CellTransit` static class β ports retail's `CObjCell::find_cell_list` family: `FindTransitCellsSphere` (indoor portal-neighbour walk), `AddAllOutsideCells` (24m landcell grid), `FindCellList` (top-level BFS driver), `CheckBuildingTransit` (outdoorβindoor entry via `BuildingObj` portals).
+- `BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` β `CellBSPNode?` (was dead code; safe retype).
+- New `BuildingPhysics` cache + `CacheBuilding` / `GetBuilding` on `PhysicsDataCache`. GameWindow caches each `LandBlockInfo.Buildings` entry at landblock-load.
+- `PhysicsEngine.ResolveCellId`: indoor seeds delegate to `CellTransit.FindCellList`; outdoor seeds keep terrain-grid resolution + hook `CheckBuildingTransit` for outdoorβindoor entry.
+- **Critical production fix** at `3ffe1e4`: pass `sp.GlobalSphere[0].Origin` (foot sphere CENTER) instead of `sp.CheckPos` (entity reference at the feet) to `ResolveCellId`. Without this fix the test point was always 0.02m below cell floor due to the +0.02f Z-bump β portal traversal never engaged in production.
+- **Indoor walkable-plane synthesis** at `eb0f772`: when the indoor cell-BSP returns OK (no wall collision), find the floor poly under the player and call `ValidateWalkable` with the indoor plane instead of falling through to outdoor terrain. Closes the "stuck in falling animation" bug.
+
+**Closed:** ISSUES.md #84, #85, #86, #87 all fully resolved.
+
+**Filed for follow-up:**
+- **#88** β Indoor static objects vibrate (bookshelves, open furnaces). Pre-existing; user spotted during Phase 2 testing.
+- **#89** β Port `BSPQuery.SphereIntersectsCellBsp` for retail-faithful `CheckBuildingTransit`. Currently uses radius-less `PointInsideCellBsp`; entry fires ~0.5m later than retail.
+
+**Diagnostic infrastructure that persists:**
+- `[indoor-bsp]` β per cell-BSP `FindCollisions` call. Toggle: `ACDREAM_PROBE_INDOOR_BSP=1` or DebugPanel.
+- `[cell-cache]` β per cached EnvCell at landblock load. Toggle: `ACDREAM_PROBE_CELL_CACHE=1`.
+- `[cell-transit]` β every player CellId change. Toggle: `ACDREAM_PROBE_CELL=1`.
+- `[check-bldg]` β per portal lookup inside `CheckBuildingTransit`. Gated on `ACDREAM_PROBE_INDOOR_BSP` (reused).
+
+---
+
+## How to start a fresh session
+
+Copy the block below into a fresh Claude Code session in this repo. The model will load `CLAUDE.md` automatically and find the handoff docs on its own.
+
+---
+
+```
+Pick up the acdream project. Indoor walking Phase 1 + Phase 2 just merged
+to main at 1af49b7 (2026-05-19). Indoor walking is functionally complete:
+walls block from inside, walking between rooms via doors works, walking
+back outside through a door works.
+
+1. Read docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
+ β that's the canonical record of what just shipped.
+
+2. Read docs/ISSUES.md and note the open issues. The three follow-ups
+ most-directly related to the just-shipped work:
+ - #88: indoor static objects vibrate (bookshelves, furnaces)
+ - #89: port BSPQuery.SphereIntersectsCellBsp for retail-faithful entry
+ - #80: 2nd-floor goes very dark (pre-existing lighting issue, may be
+ part of a broader Cluster B lighting phase)
+
+3. **The user has set a focused track: indoor walking issues, collision,
+ physics, and dungeons.** M2 (kill-a-drudge demo) is explicitly NOT
+ the next direction. Stay on the indoor-experience track until they
+ redirect.
+
+ Candidates within that scope, ranked by my best guess at priority:
+
+ A) **#83 β Walking up stairs broken**. Pure indoor/physics. The
+ retail step-up logic (`CPhysicsObj::step_up`) doesn't yet handle
+ indoor cell-BSP polys for stair geometry. Unblocks multi-floor
+ cottages (the 2nd-floor darkness #80 also depends on actually
+ reaching the 2nd floor) and dungeons (which are multi-level).
+ Natural follow-on to Phase 2. ~3-5 days.
+
+ B) **Dungeon stress test + adaptation**. Phase 2's portal traversal
+ was developed and verified at Holtburg cottage (a small one-room
+ building). Dungeons (Subway, Mite Burrow, Carved Stone) are
+ multi-cell indoor spaces with complex portal graphs. Walk a
+ character into a dungeon and see what breaks. Findings drive a
+ follow-up scope. ~1-3 days for the test + variable for fixes.
+
+ C) **#88 β Indoor object vibration** (bookshelves, open furnaces).
+ Quality bug noticed during Phase 2 testing. Likely a per-frame
+ transform recompute or EntityScriptActivator re-firing on cell
+ changes (less likely after Phase 2 stabilized cell tracking but
+ still possible). ~1-3 commits depending on root cause.
+
+ D) **#89 β Port `BSPQuery.SphereIntersectsCellBsp`**. Retail-faithful
+ entry timing for outdoorβindoor. Phase 2 ships with the documented
+ ~0.5m late-entry approximation; this closes the gap. Pure physics
+ polish. ~2-3 days.
+
+ E) **Indoor lighting cluster** (closes #79/#80/#81/#82). Slightly
+ adjacent β "indoor experience" but not strictly walking/collision.
+ Worth considering once stairs (A) lands so you can actually reach
+ the dark 2nd floor to verify lighting fixes. ~1-2 weeks.
+
+ F) **#78 β Outdoor stabs visible through floor**. Visibility/stencil
+ issue. Render side. Indoor-adjacent. ~3-5 days.
+
+ My recommendation: **A (stairs)**. Reasons:
+ - Pure indoor physics/collision β squarely in the user's stated track.
+ - Unblocks both multi-floor cottages AND dungeons (B is gated on it).
+ - Continues the natural arc from Phase 1 (walls) β Phase 2 (cell
+ tracking) β Phase 3 (vertical movement / stairs).
+ - The Phase 2 diagnostic infrastructure is still warm; reuse it.
+
+4. CLAUDE.md rules apply:
+ - No workarounds; fix root causes.
+ - Use superpowers skills for major work (brainstorming β writing-plans
+ β subagent-driven-development β finishing-a-development-branch).
+ - Drive autonomously β Claude picks what to work on next; user reviews.
+ - Visual verification by the user is the acceptance test for any
+ rendering / collision / lighting fix.
+
+5. The diagnostic infrastructure is ready for any indoor-cell-related
+ investigation. Probes are runtime-toggleable via the DebugPanel
+ ("Indoor: BSP collision" checkbox + the Cluster A render-side ones).
+
+State the milestone and your chosen phase in the first action you take.
+Then begin.
+```
+
+---
+
+## Quick reference for the user
+
+To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
+
+> "Read `docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md` and start on the next phase."
+
+## Quick reference for the helper
+
+Key files for the next phase (whichever path A/B/C/D you pick):
+
+- **`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`** β the spec that just shipped. Reference for how Phase 2 was scoped.
+- **`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`** β full Phase 2 evidence + commit list.
+- **`docs/ISSUES.md`** β current OPEN items (note new #88 + #89 from Phase 2).
+- **`docs/plans/2026-04-11-roadmap.md`** β shipped table; Indoor walking Phase 2 row is most recent.
+
+If picking **Path A (#83 stairs β recommended)**:
+
+- Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` β grep `step_up`, `step_sphere_up`, `find_walkable` for the existing logic. Our `BSPQuery` ports these for outdoor terrain at lines 1278+; the indoor analog needs the same flow against `cellPhysics.Resolved` floor-and-stair polys.
+- Touchpoints likely: `src/AcDream.Core/Physics/TransitionTypes.cs` (where the new `TryFindIndoorWalkablePlane` lives β extend to handle step-up across vertical floor polys), `src/AcDream.Core/Physics/BSPQuery.cs::FindCollisions` Path 5/6 (which already handles outdoor step-up; needs indoor counterpart).
+- Probe surface: `[indoor-bsp]` already captures every cell-BSP query; new probe `[step-up]` may be helpful.
+
+If picking **Path B (dungeon stress test)**:
+
+- Pick a small dungeon. Subway (`@0x0102 ...`) or Mite Burrow are good first targets β both are small enough to walk through quickly but complex enough to exercise multi-cell portal traversal.
+- Run the launch with all indoor probes enabled (`ACDREAM_PROBE_INDOOR_BSP=1`, `ACDREAM_PROBE_CELL=1`, `ACDREAM_PROBE_CELL_CACHE=1`). Walk through every room. Note any wall-pass-through, stuck states, or cell-tracking failures.
+- Findings drive scope. Probably uncovers stair issues (β Path A) or sphere-vs-cell timing issues (β #89).
+
+If picking **Path C (#88 vibration)**:
+
+- Likely candidates from the bug report: `EntityScriptActivator.OnCreate/OnRemove` re-firing on rapid CellId promotion/demotion (now less likely after Phase 2 stabilized cell tracking, but worth investigating); per-frame transform recompute drift on cell-static `WorldEntity` instances; particle-emitter offset accumulation.
+- File this as a Phase rather than an issue if the root cause turns out to be the per-frame transform pipeline (multi-commit refactor).
+
+If picking **Path E (indoor lighting)**:
+
+- Start by re-reading the existing Cluster B sketches in the original Cluster A handoff:
+ `docs/research/2026-05-19-indoor-followup-handoff.md` β section "The 9 follow-up issues" lists #79/#80/#81/#82 as Cluster B.
+- Lighting code surfaces: `src/AcDream.App/Rendering/GameWindow.cs::UpdateSunFromSky` (indoor branch around line 8330+), `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (accumulateLights + indoor ambient), `src/AcDream.Core/Lighting/LightInfoLoader.cs`.
+- Probe surface: there is no `[lighting]` probe yet β adding one will likely be the first commit in the brainstorm.
+- **Note:** verify Path A (stairs) lands first or you can't reach the 2nd floor to test indoor lighting at altitude.
diff --git a/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md b/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
new file mode 100644
index 0000000..1365c70
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
@@ -0,0 +1,284 @@
+# Indoor walking Phase 2 β Portal-based cell tracking β handoff (2026-05-19)
+
+**Date:** 2026-05-19.
+**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller).
+**Predecessor:** Indoor walking Phase 1 β BSP cluster (Cluster A). Partially shipped 2026-05-19; closed #86 cleanly, filed #87 for the portal-traversal root cause. Diagnostic infrastructure (`[indoor-bsp]` + `[cell-cache]` probes) remained as scaffolding. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](2026-05-19-cluster-a-shipped-handoff.md).
+
+---
+
+## TL;DR
+
+Phase 2 fully closes the indoor-walking story. Six commits replace Phase D's
+AABB-containment shortcut with retail-faithful portal-graph cell traversal.
+`CellId` now promotes to indoor cells via portals and remains promoted through
+doorways, thresholds, and multi-room navigation. Indoor cell-BSP collision fires
+consistently. A critical fix in commit 5 passes the foot-sphere center (not the
+entity reference point) to `ResolveCellId`, which was the production failure that
+made PointInsideCellBsp return false at floor level. Commit 6 adds
+`TryFindIndoorWalkablePlane` so the walkability resolver doesn't fall through to
+outdoor terrain when the player is inside.
+
+**Visual verification at Holtburg cottage (2026-05-19, user testing live ACE):**
+- Walls block from inside β player cannot walk through cottage walls.
+- Multi-room navigation via doorways works β `[cell-transit]` log shows `0xA9B40145 β 0x143 β 0x144 β 0x13F` chains.
+- Walking back outdoors through a door works (post-walkable fix in commit 6).
+- Cell tracking is robust through multiple indoor sessions.
+
+---
+
+## Commits
+
+| # | SHA | Subject |
+|---|---|---|
+| 1 | `1969c55` | `feat(physics): Phase 2 β wire CellBSP + Portals into CellPhysics` |
+| 2 | `aad6976` | `feat(physics): Phase 2 β port CellTransit + wire into ResolveCellId` |
+| 3 | `069534a` | `feat(physics): Phase 2 β BuildingPhysics + CheckBuildingTransit` |
+| 4 | `702b30a` | `refactor(physics): Phase 2 β code-review polish on BuildingPhysics commit` |
+| 5 | `3ffe1e4` | `fix(physics): Phase 2 β pass foot-sphere center to ResolveCellId` |
+| 6 | `eb0f772` | `fix(physics): Phase 2 β synthesize indoor walkable plane from cell floor` |
+
+**Build:** clean on all commits.
+**Tests:** `dotnet test` shows the same 8 pre-existing failures in
+`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged). All
+new Phase 2 tests and the walkable-plane tests green.
+
+---
+
+## What shipped
+
+### Commit 1 β CellBSP + Portals wired into CellPhysics
+
+New `PortalInfo` struct holds `PortalId`, `PortalPolygonIndex`, `PortalFlags`,
+and `OtherCellId`. `CellPhysics` extended with:
+- `CellBSP` β a third BSP tree (alongside `PhysicsBSP` and the render BSP) used
+ for point-in-cell tests. Retail: `CCellStructure::cell_bsp`.
+- `Portals` β `IReadOnlyList` built from `envCell.CellPortals`.
+- `PortalPolygons` β the visible polygons that portals reference (`cellStruct.Polygons`,
+ not `PhysicsPolygons`; portals reference the visible-geometry polygon list).
+- `VisibleCellIds` β cells visible from this cell (used by `AddAllOutsideCells`).
+
+Phase D's `LocalAabbMin/Max` + `TryFindContainingCell` are deleted β they are now
+superseded by the portal traversal in `CellTransit`.
+
+### Commit 2 β CellTransit + ResolveCellId
+
+New `CellTransit` static class implements the retail portal-neighbour walk.
+Three public entry points:
+
+- **`FindTransitCellsSphere(sphereCenter, sphereRadius, startCell, cache)`** β
+ walks portal connectivity from `startCell` outward. For each portal, tests
+ whether the sphere overlaps the portal polygon (using `PointInsideCellBsp` on
+ the sphere center as an approximation β see issue #89 for the retail-faithful
+ sphere variant). Recurses into neighbour cells up to a depth limit.
+
+- **`AddAllOutsideCells(sphereCenter, blockId, cache, results)`** β for the
+ outdoor path: populates a 24m grid of outdoor cell ids around the sphere center
+ using `TerrainSurface.ComputeOutdoorCellId`. Mirrors retail's `add_all_outside_cells`.
+
+- **`FindCellList(sp, startCell, cache)`** β top-level driver. Determines whether
+ `startCell` is an indoor (EnvCell) or outdoor cell and dispatches accordingly.
+ Returns a list of candidate cell ids.
+
+`PhysicsEngine.ResolveOutdoorCellId` renamed to `ResolveCellId` (accepts
+`sphereRadius` parameter). Body splits on indoor vs outdoor:
+- **Indoor:** delegates to `FindCellList` and picks the candidate cell where
+ `PointInsideCellBsp` returns true for the sphere center.
+- **Outdoor:** existing terrain-grid loop (`AddAllOutsideCells`).
+
+`BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` to `CellBSPNode?`
+(dead code retype β no behavior change). Phase D's test file deleted.
+
+### Commit 3 β BuildingPhysics + CheckBuildingTransit
+
+Outdoorβindoor entry path via building-shell portal graph. New `BuildingPhysics`
+class caches per-building portal data (`BldPortalInfo` structs with `PortalId`,
+`OtherCellId`, `CellBSP`). `PhysicsDataCache` gains `_buildings` cache keyed by
+building entity id. `GameWindow` iterates `lbInfo.Buildings` at landblock load and
+populates the cache.
+
+`CellTransit.CheckBuildingTransit(sphereCenter, sphereRadius, blockId, physicsCache)`
+ports retail's outdoorβindoor portal-graph entry:
+1. For each building in the landblock's physics cache, test whether the sphere
+ center is inside the building's shell cell BSP (`PointInsideCellBsp`).
+2. If inside, walk the building's portal graph to find the indoor EnvCell that
+ contains the sphere center.
+3. Returns the EnvCell id (or 0 if no match).
+
+`PhysicsEngine.ResolveCellId`'s outdoor branch hooks `CheckBuildingTransit` after
+the terrain-grid loop, so outdoorβindoor transition is detected during normal walking.
+
+### Commit 4 β Code-review polish
+
+Five items addressed from reviewer:
+1. DRY cell-id derivation via existing `TerrainSurface.ComputeOutdoorCellId`
+ (removed inline duplicate in `CheckBuildingTransit`).
+2. Named `PortalFlags.ExactMatch` enum instead of raw `0x01` literal.
+3. Comment clarity on `ExactMatch` reserved field.
+4. Doc comment on `CheckBuildingTransit` calling out the sphere-vs-point
+ divergence from retail's `sphere_intersects_cell` (see issue #89).
+5. Rename misleading test method name.
+
+### Commit 5 β Critical fix: foot-sphere center to ResolveCellId
+
+**This was the production bug that prevented Phase 2 from working until the last run.**
+
+`ResolveCellId` was being called with `sp.CheckPos` (the entity's reference point
+at feet level, world Z = terrain Z after the +0.02f bump) instead of
+`sp.GlobalSphere[0].Origin` (the foot sphere CENTER, approximately +0.48m above terrain).
+
+Combined with the +0.02f Z-bump applied to cell origins in `PhysicsDataCache`, the
+test point landed at cell-local Z = -0.02 m β just below the cell's floor β and
+`PointInsideCellBsp` returned false for every cell. CellId never promoted to indoor
+cells during normal walking despite `FindCellList` correctly finding the right
+candidate cells.
+
+Passing the foot-sphere center (which sits 0.48m above the floor, well inside any
+room cell) made portal-based cell tracking actually work in production.
+
+Also adds the `[check-bldg]` diagnostic line (logged when `CheckBuildingTransit`
+returns a non-zero indoor cell id).
+
+### Commit 6 β TryFindIndoorWalkablePlane
+
+**Root cause of the post-Phase-2 falling-stuck bug.**
+
+When indoor cell-BSP returned OK (no wall collision), the code fell through to
+outdoor `SampleTerrainWalkable` + `ValidateWalkable`. Outdoor terrain Z is below
+the indoor floor (due to the +0.02f Z-bump), so `ValidateWalkable` computed the
+player as floating well above terrain β not walkable β player stuck in the falling
+animation when blocked by an indoor wall.
+
+New `TryFindIndoorWalkablePlane(worldPos, cellPhysics)`: finds the floor polygon
+directly under the player's world position by testing `worldPos` against each
+physics polygon's plane normal (upward-facing = floor) and building a `ContactPlane`
+from it. Called from the indoor branch of `ResolveWithTransition` before the outdoor
+terrain fallback. Returns true when a floor poly is found; the resolver uses the
+synthesized plane for walkability.
+
+---
+
+## Issue status after Phase 2
+
+| Issue | Status | Notes |
+|---|---|---|
+| #84 Blocked by air indoors | **FULLY CLOSED** | Spawn-in-building variant: Phase D (Cluster A). Wall-block-from-inside + falling-stuck variants: Phase 2 commits 2, 5, 6. |
+| #85 Pass through walls outsideβin | **CLOSED** | `CheckBuildingTransit` + portal traversal. CellId promotes to indoor on outdoorβindoor entry. |
+| #86 Click selection penetrates walls | CLOSED (Phase 1) | `WorldPicker.Pick` + `CellBspRayOccluder`. |
+| #87 Indoor portal-based cell tracking | **CLOSED** | `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`. Portal-graph traversal replaces AABB containment. |
+| #88 Indoor static objects vibrate | OPEN (new) | Pre-existing visual jitter on bookshelves/furnaces. Filed 2026-05-19. Medium severity. |
+| #89 Port BSPQuery.SphereIntersectsCellBsp | OPEN (new) | `CheckBuildingTransit` uses `PointInsideCellBsp` (radius-less approximation) instead of retail's `sphere_intersects_cell`. Filed 2026-05-19. Low severity. |
+
+---
+
+## Probe evidence β log file findings
+
+### `launch-phase2-verify3.log`
+
+First run that showed indoor cell-transits firing. `[cell-transit]` output
+confirmed the portal traversal was finding indoor cells. `[indoor-bsp]` probe
+fired consistently during indoor walking (not just during mid-jump frames as in
+Cluster A). This log is the first evidence that `CellTransit.FindCellList` was
+working correctly for room interiors, though outdoorβindoor entry was not yet
+exercised.
+
+### `launch-phase2-verify4.log`
+
+Multi-room navigation run. `[cell-transit]` log shows
+`0xA9B40145 β 0x143 β 0x144 β 0x13F` chains as the player walked between
+rooms in the Holtburg cottage via doorways. Confirmed the `FindTransitCellsSphere`
+recursive portal walk was promoting CellId correctly through threshold cells.
+Walls blocked from inside in all rooms tested.
+
+### `launch-phase2-verify5.log`
+
+Walkable bug evidence run. After the outdoorβindoor transition was wired
+(`CheckBuildingTransit`), the player could walk into the cottage from outside,
+but colliding with an indoor wall produced a falling-stuck state (the `[indoor-bsp]`
+probe fired for the wall collision, but `ValidateWalkable` returned false because
+it was sampling outdoor terrain Z). This log captured the falling-stuck symptom
+and the `SampleTerrainWalkable` fallthrough trace, motivating commit 6.
+
+### `launch-phase2-verify6.log`
+
+Post-walkable-fix verification run. After `TryFindIndoorWalkablePlane` was added:
+- Outdoorβindoor entry works (player walks through doorway, CellId promotes).
+- Indoor wall collision works (walls block, player doesn't pass through).
+- Walking back outdoors through the door works (CellId demotes to outdoor cell).
+- No falling-stuck state observed. User confirmed all three behaviors.
+
+---
+
+## Diagnostic infrastructure remaining in place
+
+All four probes stay committed and wired. They serve as production diagnostics
+and as debugging aids for follow-up issues:
+
+- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one
+ `[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell branch.
+ After Phase 2, this fires consistently whenever the player is indoors. Useful
+ for confirming the indoor-BSP path is active.
+
+- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all
+ cached EnvCell physics data (poly counts, BSP bounding sphere, AABB, unmatched
+ ID count, portal count). Useful for verifying cell struct loads and portal
+ connectivity.
+
+- **`ACDREAM_PROBE_CELL=1`** (existing L.2a slice 1): one `[cell-transit]` line
+ per `PlayerMovementController.CellId` change (old β new cell, world position,
+ reason tag). Essential for tracing indoor promotion/demotion sequences.
+
+- **`[check-bldg]`** (commit 5): logged by `ResolveCellId` when
+ `CheckBuildingTransit` returns a non-zero indoor cell id. Fires once per
+ outdoorβindoor transition detection.
+
+All gated behind `PhysicsDiagnostics` static class (existing pattern from L.2a).
+
+---
+
+## Visual verification outcomes
+
+**2026-05-19, user testing live against local ACE at Holtburg.**
+
+| Scenario | Result |
+|---|---|
+| Walk into cottage wall from inside | Blocked β |
+| Walk between rooms via doorway | CellId transitions logged, multi-room navigation works β |
+| Walk from outside into cottage through door | Outdoorβindoor entry promoted CellId; indoor BSP collision active β |
+| Walk back outside through door | CellId demoted to outdoor cell; outdoor physics resumed β |
+| No falling-stuck after post-walkable fix | Confirmed β |
+| Robust across multiple indoor sessions | Confirmed β |
+
+---
+
+## Known follow-ups
+
+**#88 β Indoor static objects vibrate (bookshelves, open furnaces).** Pre-existing
+visual jitter spotted before Phase 2 shipped. Medium severity. Candidates: repeated
+`EntityScriptActivator.OnCreate/OnRemove` near cell boundaries, per-part transform
+drift, or particle-emitter offset accumulation. Investigate in a follow-up session.
+
+**#89 β Port `BSPQuery.SphereIntersectsCellBsp`.** `CellTransit.CheckBuildingTransit`
+currently uses `PointInsideCellBsp` (tests sphere CENTER only). Retail's
+`CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell`
+(radius-aware, returns Inside/Crossing/Outside). Practical effect: entry fires
+~0.48m deeper into the doorway than retail. Low severity β visually acceptable.
+The `sphereRadius` parameter is already plumbed through for when this is ported.
+
+**#80 β Indoor darkness (camera on 2nd floor goes very dark).** Still open.
+Not in Phase 2's scope. Lighting / ambient-occlusion issue that predates indoor
+rendering Phase 2.
+
+---
+
+## State at handoff
+
+- **Branch:** `claude/competent-robinson-dec1f4`, 6 commits of Phase 2 work
+ (plus 7 from Phase 1 / Cluster A on the same branch).
+- **Build state:** `dotnet build -c Debug` clean.
+- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp
+ baseline). All targeted test projects green.
+- **Issues:** #84, #85, #87 CLOSED. #86 CLOSED (Phase 1). #88, #89 OPEN (new).
+- **Diagnostic probes:** `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
+ `[check-bldg]` all active and wired.
+- **Next:** M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b β kill-a-drudge
+ demo) or other candidates per work-order autonomy in CLAUDE.md.
diff --git a/docs/research/deepdives/r13-dynamic-lighting.md b/docs/research/deepdives/r13-dynamic-lighting.md
index 91067d1..b1321a5 100644
--- a/docs/research/deepdives/r13-dynamic-lighting.md
+++ b/docs/research/deepdives/r13-dynamic-lighting.md
@@ -46,7 +46,7 @@ public partial class LightInfo : IDatObjType {
- **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive.
- **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors.
-- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or a fixed dark RGB like `(0.10, 0.09, 0.08)` (indoors, approximating the dungeon "deep" tone) β then add active lights on top. See Β§12 for the C# class.
+- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or **a flat `(0.20, 0.20, 0.20)` neutral** (indoors) β then add active lights on top. The indoor constant is taken **directly from retail**: `CellManager::ChangePosition` (0x004559B0) calls `SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)` whenever `CObjCell::seen_outside == 0`. The early-2026 guess at `(0.10, 0.09, 0.08)` was eyeballed; the retail value is both brighter and neutral. See Β§12 for the C# class.
## 4. Torch lights and `WeenieType.LightSource`
diff --git a/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md b/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md
new file mode 100644
index 0000000..d6bae6f
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md
@@ -0,0 +1,1130 @@
+# Phase N.1 β Scenery via WorldBuilder Helpers 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.
+
+**Goal:** Replace the in-line algorithm guts of `SceneryGenerator.Generate()` with calls to WorldBuilder's `SceneryHelpers` (Displace / RotateObj / ScaleObj / CheckSlope / ObjAlign) and `TerrainUtils` (OnRoad / GetNormal). Keep our data flow, our `ScenerySpawn` shape, and our renderer integration. Place behind a `ACDREAM_USE_WB_SCENERY=1` feature flag, prove equivalence with helper-level conformance tests, then flip default-on after visual verification at landblock `0xA9B1`.
+
+**Architecture:** Strangler-fig substitution. The 9Γ9 vertex loop, scene-selection hash, frequency roll, bounds check, building grid, and `BaseLoc.Z` handling stay identical. Only the five algorithm calls (displacement, road test, slope normal, rotation, scale) get replaced. A small adapter `WbSceneryAdapter.BuildTerrainEntries(LandBlock)` produces the `TerrainEntry[]` shape WB's helpers expect.
+
+**Tech Stack:** .NET 10 / C# 13, Silk.NET (transitively), `WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend` (project references already wired up in Phase N.0), DatReaderWriter for `LandBlock` / `Region` / `ObjectDesc` / `Scene` types, xUnit for tests.
+
+**Spec:** `docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md`
+**Parent design:** `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
+**Inventory:** `docs/architecture/worldbuilder-inventory.md`
+
+**Prerequisite:** Phase N.0 already shipped (commit `c8782c9`) β `references/WorldBuilder/` is a git submodule pointing at `github.com/eriknihlen/WorldBuilder.git` `acdream` branch; `AcDream.Core.csproj` references `WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend`.
+
+---
+
+## File Plan
+
+| File | Disposition | Responsibility |
+|---|---|---|
+| `src/AcDream.Core/World/WbSceneryAdapter.cs` | NEW | `LandBlock` (acdream's dat type) β `TerrainEntry[]` (WB's data shape). Stateless. |
+| `src/AcDream.Core/World/SceneryGenerator.cs` | MODIFY | Add `UseWbScenery` feature-flag flag. Add `GenerateViaWb` private alternative path. Wire dispatch in `Generate()`. Keep legacy methods for now β deleted in final task. |
+| `tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs` | NEW | Verify field bit-packing round-trips (Road, Type, Scenery, Height) and bounds-check behavior. |
+| `tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs` | NEW | Five small tests proving WB's `Displace`/`OnRoad`/`GetNormal`/`RotateObj`/`ScaleObj` match our existing inline logic for representative inputs. |
+| `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs` | MODIFY | After Task 7 (legacy delete), prune the now-irrelevant `DisplaceObject_*` tests and update class doc. |
+
+**Why split adapter into its own file:** the adapter is a pure stateless utility. Putting it in its own file keeps `SceneryGenerator.cs` focused on the generator algorithm, and makes the adapter trivially reusable in N.2+ (terrain math helpers will need the same `TerrainEntry[]`).
+
+**Why combine conformance tests in one file:** all five tests share the same imports and fixtures, and they all measure the same thing (helper equivalence). Splitting would be over-decomposed.
+
+---
+
+## Task 1: LandBlock β TerrainEntry[] adapter
+
+**Files:**
+- Create: `src/AcDream.Core/World/WbSceneryAdapter.cs`
+- Test: `tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs`
+
+- [ ] **Step 1.1: Write the failing test**
+
+Create `tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs`:
+
+```csharp
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Tests.World;
+
+///
+/// Tests for . The adapter converts our
+/// LandBlock dat type (Terrain ushort[81] + Height byte[81]) into
+/// WorldBuilder's [81]
+/// shape, which WB's TerrainUtils / SceneryRenderManager consume.
+///
+/// Bit layout in our LandBlock.Terrain[i] (ushort):
+/// bits 0-1 : Road (2 bits, ACViewer convention)
+/// bits 2-6 : TerrainType (5 bits) β WB calls this Texture
+/// bits 11-15 : SceneType (5 bits) β WB calls this Scenery
+/// Height comes from LandBlock.Height[i] (byte).
+///
+public class WbSceneryAdapterTests
+{
+ [Fact]
+ public void BuildTerrainEntries_PreservesRoadTextureSceneryHeight()
+ {
+ var block = new LandBlock
+ {
+ Terrain = new ushort[81],
+ Height = new byte[81],
+ };
+
+ // Vertex 0: road=0x3, type=0x00, scenery=0x1F, height=42
+ // raw layout: bits 0-1=11, bits 2-6=00000, bits 7-10=0000, bits 11-15=11111
+ // = 0xF803
+ block.Terrain[0] = 0xF803;
+ block.Height[0] = 42;
+
+ // Vertex 80: road=0x0, type=0x1F, scenery=0x00, height=200
+ // raw layout: bits 0-1=00, bits 2-6=11111, bits 11-15=00000
+ // = 0x007C
+ block.Terrain[80] = 0x007C;
+ block.Height[80] = 200;
+
+ var entries = WbSceneryAdapter.BuildTerrainEntries(block);
+
+ Assert.Equal(81, entries.Length);
+
+ Assert.Equal((byte)42, entries[0].Height);
+ Assert.Equal((byte)0x3, entries[0].Road);
+ Assert.Equal((byte)0x00, entries[0].Texture);
+ Assert.Equal((byte)0x1F, entries[0].Scenery);
+
+ Assert.Equal((byte)200, entries[80].Height);
+ Assert.Equal((byte)0x0, entries[80].Road);
+ Assert.Equal((byte)0x1F, entries[80].Texture);
+ Assert.Equal((byte)0x00, entries[80].Scenery);
+ }
+
+ [Fact]
+ public void BuildTerrainEntries_AllZeros_ProducesEmptyEntries()
+ {
+ var block = new LandBlock
+ {
+ Terrain = new ushort[81],
+ Height = new byte[81],
+ };
+ var entries = WbSceneryAdapter.BuildTerrainEntries(block);
+ Assert.All(entries, e =>
+ {
+ Assert.Equal((byte)0, e.Height);
+ Assert.Equal((byte)0, e.Road);
+ Assert.Equal((byte)0, e.Texture);
+ Assert.Equal((byte)0, e.Scenery);
+ });
+ }
+
+ [Fact]
+ public void BuildTerrainEntries_NullBlock_Throws()
+ {
+ Assert.Throws(() =>
+ WbSceneryAdapter.BuildTerrainEntries(null!));
+ }
+}
+```
+
+- [ ] **Step 1.2: Run the test to verify it fails**
+
+Run: `dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "WbSceneryAdapterTests" 2>&1 | tail -10`
+Expected: BUILD ERROR or FAIL with "type or namespace 'WbSceneryAdapter' could not be found".
+
+- [ ] **Step 1.3: Implement the adapter**
+
+Create `src/AcDream.Core/World/WbSceneryAdapter.cs`:
+
+```csharp
+using DatReaderWriter.DBObjs;
+using WorldBuilder.Shared.Models;
+
+namespace AcDream.Core.World;
+
+///
+/// Bridges acdream's dat types into WorldBuilder's data shapes for the
+/// Phase N rendering migration. See
+/// docs/architecture/worldbuilder-inventory.md for the full strategy.
+///
+internal static class WbSceneryAdapter
+{
+ private const int VerticesPerSide = 9;
+ private const int TerrainSize = VerticesPerSide * VerticesPerSide; // 81
+
+ ///
+ /// Builds a 9Γ9 = 81-entry array from a
+ /// 's packed terrain bits + height bytes. WB's
+ /// TerrainUtils.OnRoad / GetNormal / GetHeight
+ /// consume this shape.
+ ///
+ /// Bit layout in our LandBlock.Terrain[i] (ushort):
+ /// bits 0-1 : Road (2 bits) β WB Road
+ /// bits 2-6 : TerrainType (5 bits) β WB Texture
+ /// bits 11-15 : SceneType (5 bits) β WB Scenery
+ /// Height comes from LandBlock.Height[i] (byte).
+ ///
+ public static TerrainEntry[] BuildTerrainEntries(LandBlock block)
+ {
+ ArgumentNullException.ThrowIfNull(block);
+ if (block.Terrain.Length != TerrainSize)
+ throw new ArgumentException(
+ $"LandBlock.Terrain must be {TerrainSize} entries (9Γ9), got {block.Terrain.Length}",
+ nameof(block));
+ if (block.Height.Length != TerrainSize)
+ throw new ArgumentException(
+ $"LandBlock.Height must be {TerrainSize} entries (9Γ9), got {block.Height.Length}",
+ nameof(block));
+
+ var entries = new TerrainEntry[TerrainSize];
+ for (int i = 0; i < TerrainSize; i++)
+ {
+ ushort raw = block.Terrain[i];
+ byte road = (byte)(raw & 0x3);
+ byte texture = (byte)((raw >> 2) & 0x1F);
+ byte scenery = (byte)((raw >> 11) & 0x1F);
+ byte height = block.Height[i];
+ entries[i] = new TerrainEntry(height, texture, scenery, road, encounters: null);
+ }
+ return entries;
+ }
+}
+```
+
+- [ ] **Step 1.4: Run the test to verify it passes**
+
+Run: `dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "WbSceneryAdapterTests" 2>&1 | tail -5`
+Expected: `Passed! - Failed: 0, Passed: 3` (or similar β three tests).
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add src/AcDream.Core/World/WbSceneryAdapter.cs tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): add LandBlock β TerrainEntry[] adapter
+
+Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
+LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
+TerrainUtils / SceneryRenderManager consume.
+
+Bit-pack mapping (ours β WB):
+ Terrain bits 0-1 (Road) β TerrainEntry.Road
+ Terrain bits 2-6 (TerrainType) β TerrainEntry.Texture
+ Terrain bits 11-15 (SceneType) β TerrainEntry.Scenery
+ Height byte β TerrainEntry.Height
+
+Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: Feature flag scaffold
+
+**Files:**
+- Modify: `src/AcDream.Core/World/SceneryGenerator.cs`
+
+This task only adds the flag-read field. It changes no behavior and there is nothing to assert beyond "it compiles." The flag is consumed in Task 5.
+
+- [ ] **Step 2.1: Add the feature flag field**
+
+Open `src/AcDream.Core/World/SceneryGenerator.cs`. Find the line:
+
+```csharp
+ // AC landblock geometry β matches LandblockMesh.
+ private const int VerticesPerSide = 9;
+ private const float CellSize = 24.0f;
+ private const float LandblockSize = 192.0f; // 8 cells * 24 units
+```
+
+Immediately AFTER the `LandblockSize` constant, ADD:
+
+```csharp
+
+ ///
+ /// Phase N.1 feature flag β when set to "1", scenery placement uses
+ /// WorldBuilder's SceneryHelpers + TerrainUtils instead of
+ /// our hand-ported algorithms. Default off until visual verification at
+ /// landblock 0xA9B1 confirms behavior. See
+ /// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md.
+ ///
+ internal static readonly bool UseWbScenery =
+ System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1";
+```
+
+- [ ] **Step 2.2: Verify build still passes**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5`
+Expected: `Build succeeded.` and `0 Error(s)`.
+
+- [ ] **Step 2.3: Verify all existing tests still pass**
+
+Run: `dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|Wb" 2>&1 | tail -3`
+Expected: `Passed!` with all scenery-area tests passing.
+
+- [ ] **Step 2.4: Commit**
+
+```bash
+git add src/AcDream.Core/World/SceneryGenerator.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): add ACDREAM_USE_WB_SCENERY feature flag scaffold
+
+Phase N.1 step 2: read the env var into a static bool. No behavior
+change yet β the flag is consumed in step 5 when GenerateViaWb is
+wired in.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 3: Per-helper conformance tests
+
+**Files:**
+- Create: `tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs`
+
+These five tests prove WB's `SceneryHelpers.Displace` / `TerrainUtils.OnRoad` / `TerrainUtils.GetNormal` / `SceneryHelpers.RotateObj` / `SceneryHelpers.ScaleObj` produce the same answers as the hand-ported logic currently inlined in `Generate()`. After this task, we have empirical evidence that the substitution is safe.
+
+If a test fails, that is the bug β investigate before proceeding to Task 4.
+
+- [ ] **Step 3.1: Write all five conformance tests**
+
+Create `tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using WB_TerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils;
+using WB_SceneryHelpers = Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers;
+
+namespace AcDream.Core.Tests.World;
+
+///
+/// Phase N.1 helper-level conformance tests. Each test compares an algorithm
+/// in our existing path against WorldBuilder's
+/// equivalent for representative inputs. Passing tests are empirical evidence
+/// that swapping our inline logic for WB's helpers is behavior-preserving.
+///
+/// If any of these fails the substitution would silently change rendered
+/// scenery; investigate before proceeding to Task 4 (GenerateViaWb).
+///
+/// Inputs are chosen to exercise:
+/// - A non-edge vertex (gx=100, gy=100, j=0) β typical case
+/// - The edge vertex at y=8 specifically (Issue #49 territory)
+///
+public class SceneryWbConformanceTests
+{
+ private static ObjectDesc MakeObj(
+ float displaceX = 12f,
+ float displaceY = 12f,
+ float minScale = 1f,
+ float maxScale = 1f,
+ float maxRotation = 0f,
+ float minSlope = 0f,
+ float maxSlope = 1f,
+ int align = 0)
+ {
+ return new ObjectDesc
+ {
+ ObjectId = 0x02000258u,
+ DisplaceX = displaceX,
+ DisplaceY = displaceY,
+ MinScale = minScale,
+ MaxScale = maxScale,
+ MaxRotation = maxRotation,
+ MinSlope = minSlope,
+ MaxSlope = maxSlope,
+ Align = (uint)align,
+ BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) },
+ };
+ }
+
+ ///
+ /// Our DisplaceObject β WB's SceneryHelpers.Displace must produce the
+ /// same Vector3 for the same (obj, ix, iy, iq).
+ ///
+ [Theory]
+ [InlineData(100u, 100u, 0u)] // typical
+ [InlineData( 50u, 50u, 1u)] // typical, j=1
+ [InlineData( 4u, 8u, 0u)] // edge vertex y=8
+ [InlineData( 8u, 4u, 0u)] // edge vertex x=8
+ public void Displace_OursMatchesWb(uint ix, uint iy, uint iq)
+ {
+ var obj = MakeObj();
+ var ours = SceneryGenerator.DisplaceObject(obj, ix, iy, iq);
+ var wb = WB_SceneryHelpers.Displace(obj, ix, iy, iq);
+
+ Assert.Equal(ours.X, wb.X, precision: 4);
+ Assert.Equal(ours.Y, wb.Y, precision: 4);
+ Assert.Equal(ours.Z, wb.Z, precision: 4);
+ }
+
+ ///
+ /// Our IsOnRoad β WB's TerrainUtils.OnRoad must produce the same bool
+ /// for the same (lx, ly) when the underlying terrain bits match.
+ ///
+ [Theory]
+ [InlineData( 12.0f, 12.0f)] // cell (0,0) center
+ [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex bug location
+ [InlineData( 3.0f, 3.0f)] // near a road if r0 is set
+ [InlineData( 23.5f, 12.0f)] // edge of cell, between cells
+ public void OnRoad_OursMatchesWb_DiagonalRoad(float lx, float ly)
+ {
+ // Build a synthetic LandBlock with road bits at SW (0,0) and NE (1,1)
+ // of cell (0,0) β the diagonal pattern we saw at 0xA9B1.
+ var block = new LandBlock
+ {
+ Terrain = new ushort[81],
+ Height = new byte[81],
+ };
+ // road bit at vertex (0,0) β index 0*9+0 = 0
+ block.Terrain[0] = 0x0003; // road=3
+ // road bit at vertex (1,1) β index 1*9+1 = 10
+ block.Terrain[10] = 0x0003;
+
+ bool ours = SceneryGenerator.IsOnRoad(block, lx, ly);
+
+ var entries = WbSceneryAdapter.BuildTerrainEntries(block);
+ bool wb = WB_TerrainUtils.OnRoad(new Vector3(lx, ly, 0), entries);
+
+ Assert.Equal(ours, wb);
+ }
+
+ ///
+ /// Our SampleNormalZFromHeightmap β WB's TerrainUtils.GetNormal(...).Z
+ /// must produce the same Z for representative slope inputs.
+ ///
+ [Theory]
+ [InlineData( 12.0f, 12.0f)] // cell center
+ [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex location
+ [InlineData( 3.0f, 188.0f)] // near a y-edge
+ public void GetNormalZ_OursMatchesWb_LinearTable(float lx, float ly)
+ {
+ // Heightmap with non-flat terrain so normals are non-trivial.
+ var heights = new byte[81];
+ for (int x = 0; x < 9; x++)
+ for (int y = 0; y < 9; y++)
+ heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256);
+
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 1.0f;
+
+ const uint lbX = 0xA9, lbY = 0xB1;
+
+ // Build a Region-shaped object with the LandHeightTable populated.
+ // (TerrainUtils.GetNormal calls region.LandDefs.LandHeightTable[height].)
+ // LandHeightTable is float[] (size 256) in DatReaderWriter β see
+ // src/AcDream.App/Rendering/GameWindow.cs:1306-1308 for the runtime check.
+ var region = new DatReaderWriter.DBObjs.Region
+ {
+ LandDefs = new LandDefs(),
+ };
+ // If LandDefs default-initializes LandHeightTable to a non-null float[256],
+ // copy into it. If it's null, assign directly. The implementer should
+ // pick whichever pattern compiles in DatReaderWriter 2.1.7's API:
+ // Option A: region.LandDefs.LandHeightTable = heightTable;
+ // Option B: Array.Copy(heightTable, region.LandDefs.LandHeightTable, 256);
+ region.LandDefs.LandHeightTable = heightTable;
+
+ var block = new LandBlock
+ {
+ Terrain = new ushort[81],
+ Height = heights,
+ };
+ var entries = WbSceneryAdapter.BuildTerrainEntries(block);
+
+ float ours = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap(
+ heights, heightTable, lbX, lbY, lx, ly);
+ float wb = WB_TerrainUtils.GetNormal(region, entries, lbX, lbY,
+ new Vector3(lx, ly, 0)).Z;
+
+ Assert.Equal(ours, wb, precision: 4);
+ }
+
+ ///
+ /// Our inline rotation logic β WB's SceneryHelpers.RotateObj must
+ /// produce the same Quaternion for non-Align objects with MaxRotation.
+ ///
+ [Theory]
+ [InlineData( 100u, 100u, 0u, 360f)]
+ [InlineData( 4u, 8u, 0u, 360f)]
+ [InlineData( 200u, 250u, 1u, 180f)]
+ public void RotateObj_OursMatchesWb_NonAlign(uint gx, uint gy, uint j, float maxRot)
+ {
+ var obj = MakeObj(maxRotation: maxRot);
+
+ // Our inline logic from SceneryGenerator.Generate (~lines 220-231):
+ Quaternion ours = obj.BaseLoc.Orientation;
+ if (ours.LengthSquared() < 0.0001f) ours = Quaternion.Identity;
+ if (obj.MaxRotation > 0f)
+ {
+ double rotNoise = unchecked((uint)(1813693831u * gy
+ - (j + 63127u) * (1360117743u * gy * gx + 1888038839u)
+ - 1109124029u * gx)) * 2.3283064e-10;
+ float degrees = (float)(rotNoise * obj.MaxRotation);
+ float yawDeg = -((450f - degrees) % 360f);
+ float yawRad = yawDeg * MathF.PI / 180f;
+ var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad);
+ ours = headingQuat * ours;
+ }
+
+ // WB's SceneryHelpers.Displace returns the localPos that RotateObj
+ // expects for its loc parameter (used only when SetHeading is called
+ // with non-zero matrix, but a stub Vector3 works since BaseLoc is identity).
+ var localPos = WB_SceneryHelpers.Displace(obj, gx, gy, j);
+ Quaternion wb = WB_SceneryHelpers.RotateObj(obj, gx, gy, j, localPos);
+
+ Assert.Equal(ours.X, wb.X, precision: 4);
+ Assert.Equal(ours.Y, wb.Y, precision: 4);
+ Assert.Equal(ours.Z, wb.Z, precision: 4);
+ Assert.Equal(ours.W, wb.W, precision: 4);
+ }
+
+ ///
+ /// Our inline scale logic β WB's SceneryHelpers.ScaleObj must produce
+ /// the same float for representative inputs.
+ ///
+ [Theory]
+ [InlineData(100u, 100u, 0u, 0.5f, 1.5f)]
+ [InlineData( 4u, 8u, 0u, 1.0f, 1.0f)]
+ [InlineData(200u, 250u, 1u, 0.8f, 1.2f)]
+ public void ScaleObj_OursMatchesWb(uint gx, uint gy, uint j, float minScale, float maxScale)
+ {
+ var obj = MakeObj(minScale: minScale, maxScale: maxScale);
+
+ // Our inline logic from SceneryGenerator.Generate (~lines 236-247):
+ float ours;
+ if (obj.MinScale == obj.MaxScale)
+ {
+ ours = obj.MaxScale;
+ }
+ else
+ {
+ double scaleNoise = unchecked((uint)(1813693831u * gy
+ - (j + 32593u) * (1360117743u * gy * gx + 1888038839u)
+ - 1109124029u * gx)) * 2.3283064e-10;
+ ours = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale);
+ }
+ if (ours <= 0) ours = 1f;
+
+ float wb = WB_SceneryHelpers.ScaleObj(obj, gx, gy, j);
+ if (wb <= 0) wb = 1f;
+
+ Assert.Equal(ours, wb, precision: 4);
+ }
+}
+```
+
+- [ ] **Step 3.2: Make our `IsOnRoad` accessible to the test**
+
+`IsOnRoad` is currently `private`. Bump to `internal` so the conformance test can call it. Open `src/AcDream.Core/World/SceneryGenerator.cs` and change:
+
+```csharp
+ private static bool IsOnRoad(LandBlock block, float lx, float ly)
+```
+
+to:
+
+```csharp
+ internal static bool IsOnRoad(LandBlock block, float lx, float ly)
+```
+
+- [ ] **Step 3.3: Run the conformance tests**
+
+Run: `dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryWbConformance" 2>&1 | tail -10`
+Expected: ALL TESTS PASS.
+
+If any test fails, **stop and investigate** β that's the bug WB is hiding from us. Report which assertion failed (e.g., "Displace at gx=4 gy=8 returns different Y") and confer with the user before proceeding.
+
+- [ ] **Step 3.4: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs src/AcDream.Core/World/SceneryGenerator.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): per-helper conformance tests for WB substitutions
+
+Phase N.1 step 3: prove our inline algorithms (Displace, IsOnRoad,
+slope normal Z, RotateObj, ScaleObj) match WorldBuilder's helpers
+for representative inputs including the 0xA9B1 edge-vertex case.
+
+Bumps IsOnRoad to internal so the test can call it directly.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Implement `GenerateViaWb`
+
+**Files:**
+- Modify: `src/AcDream.Core/World/SceneryGenerator.cs`
+
+Add a new private method `GenerateViaWb` that produces `IReadOnlyList` using only WB helpers for the substituted algorithm calls. The 9Γ9 loop, scene selection, frequency check, bounds check, building grid, and `BaseLoc.Z` handling stay structurally identical to `Generate`.
+
+- [ ] **Step 4.1: Add the required `using` directives**
+
+Open `src/AcDream.Core/World/SceneryGenerator.cs`. The file currently has:
+
+```csharp
+using System.Numerics;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.World;
+```
+
+Replace with:
+
+```csharp
+using System.Numerics;
+using Chorizite.OpenGLSDLBackend.Lib;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using WorldBuilder.Shared.Modules.Landscape.Lib;
+
+namespace AcDream.Core.World;
+```
+
+- [ ] **Step 4.2: Add `GenerateViaWb` immediately after `Generate`**
+
+Find the closing `}` of `Generate(...)` in `SceneryGenerator.cs` (just before the `IsRoadVertex` method). Immediately AFTER `Generate`'s closing brace, ADD:
+
+```csharp
+
+ ///
+ /// Phase N.1 alternative implementation that delegates the
+ /// algorithm calls to WorldBuilder's SceneryHelpers +
+ /// TerrainUtils. Structurally identical to
+ /// but with WB's tested ports doing the work. Selected by
+ /// .
+ ///
+ private static IReadOnlyList GenerateViaWb(
+ DatCollection dats,
+ Region region,
+ LandBlock block,
+ uint landblockId,
+ HashSet? buildingCells)
+ {
+ var result = new List();
+
+ if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
+ return result;
+
+ // Build the TerrainEntry[] WB's helpers consume β once per landblock.
+ var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block);
+
+ uint blockX = (landblockId >> 24) * 8;
+ uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
+ uint lbX = landblockId >> 24;
+ uint lbY = (landblockId >> 16) & 0xFFu;
+
+ for (int x = 0; x < VerticesPerSide; x++)
+ {
+ for (int y = 0; y < VerticesPerSide; y++)
+ {
+ int i = x * VerticesPerSide + y;
+ ushort raw = block.Terrain[i];
+
+ uint terrainType = (uint)((raw >> 2) & 0x1F);
+ uint sceneType = (uint)((raw >> 11) & 0x1F);
+
+ if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
+ var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
+ if (sceneType >= sceneTypeList.Count) continue;
+
+ uint sceneInfo = sceneTypeList[(int)sceneType];
+ if (sceneInfo >= region.SceneInfo.SceneTypes.Count) continue;
+
+ var scenes = region.SceneInfo.SceneTypes[(int)sceneInfo].Scenes;
+ if (scenes.Count == 0) continue;
+
+ uint cellX = (uint)x;
+ uint cellY = (uint)y;
+ uint globalCellX = cellX + blockX;
+ uint globalCellY = cellY + blockY;
+
+ // Scene-selection hash: identical to Generate.
+ uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u)
+ - 1109124029u * globalCellX + 2139937281u;
+ double offset = cellMat * 2.3283064e-10;
+ int sceneIdx = (int)(scenes.Count * offset);
+ if (sceneIdx >= scenes.Count || sceneIdx < 0) sceneIdx = 0;
+
+ uint sceneId = (uint)scenes[sceneIdx];
+ var scene = dats.Get(sceneId);
+ if (scene is null) continue;
+
+ // Per-object frequency setup: identical to Generate.
+ uint cellXMat = unchecked(0u - 1109124029u * globalCellX);
+ uint cellYMat = 1813693831u * globalCellY;
+ uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u;
+
+ for (uint j = 0; j < scene.Objects.Count; j++)
+ {
+ var obj = scene.Objects[(int)j];
+ if (obj.WeenieObj != 0) continue;
+
+ double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10;
+ if (noise >= obj.Frequency) continue;
+
+ // βββ WB substitution: displacement βββββββββββββββββββ
+ var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j);
+
+ float lx = cellX * CellSize + localPos.X;
+ float ly = cellY * CellSize + localPos.Y;
+
+ if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
+ continue;
+
+ // βββ WB substitution: road check ββββββββββββββββββββββ
+ if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries))
+ continue;
+
+ // Building check: identical to Generate.
+ if (buildingCells is not null)
+ {
+ int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
+ int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1);
+ if (buildingCells.Contains(dcx * VerticesPerSide + dcy))
+ continue;
+ }
+
+ // βββ WB substitution: slope check βββββββββββββββββββββ
+ Vector3 normal = TerrainUtils.GetNormal(
+ region, terrainEntries, lbX, lbY,
+ new Vector3(lx, ly, 0));
+ if (!SceneryHelpers.CheckSlope(obj, normal.Z))
+ continue;
+
+ float lz = obj.BaseLoc.Origin.Z;
+
+ // βββ WB substitution: rotation ββββββββββββββββββββββββ
+ Quaternion rotation;
+ if (obj.Align != 0)
+ rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos);
+ else
+ rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos);
+
+ // βββ WB substitution: scale βββββββββββββββββββββββββββ
+ float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j);
+ if (scale <= 0) scale = 1f;
+
+ result.Add(new ScenerySpawn(
+ ObjectId: obj.ObjectId,
+ LocalPosition: new Vector3(lx, ly, lz),
+ Rotation: rotation,
+ Scale: scale));
+ }
+ }
+ }
+
+ return result;
+ }
+```
+
+- [ ] **Step 4.3: Verify build**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5`
+Expected: `Build succeeded.` `0 Error(s)`.
+
+If you get an error like `'SceneryHelpers' is an ambiguous reference`, it's because both Chorizite.OpenGLSDLBackend.Lib and WorldBuilder.Shared expose helpers β fix by qualifying: `Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers.Displace(...)`.
+
+- [ ] **Step 4.4: Verify existing tests still pass**
+
+Run: `dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3`
+Expected: all scenery-area tests pass (the ones added in Tasks 1 and 3, plus the original SceneryGeneratorTests). No behavior change yet for the live `Generate` path β `GenerateViaWb` is added but not called.
+
+- [ ] **Step 4.5: Commit**
+
+```bash
+git add src/AcDream.Core/World/SceneryGenerator.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): implement GenerateViaWb alternative path
+
+Phase N.1 step 4: parallel implementation of Generate() that calls
+WB's SceneryHelpers (Displace/CheckSlope/RotateObj/ObjAlign/ScaleObj)
+and TerrainUtils (OnRoad/GetNormal) instead of the inline ports.
+
+Not yet wired in β Generate() still runs the legacy path. Step 5
+adds the dispatch.
+
+Per-helper conformance tests in step 3 prove this implementation is
+behavior-equivalent to the legacy path.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 5: Wire feature-flag dispatch
+
+**Files:**
+- Modify: `src/AcDream.Core/World/SceneryGenerator.cs`
+
+- [ ] **Step 5.1: Add the dispatch at the top of `Generate`**
+
+In `src/AcDream.Core/World/SceneryGenerator.cs`, find the body of `Generate`:
+
+```csharp
+ public static IReadOnlyList Generate(
+ DatCollection dats,
+ Region region,
+ LandBlock block,
+ uint landblockId,
+ HashSet? buildingCells = null,
+ float[]? heightTable = null)
+ {
+ var result = new List();
+
+ if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
+ return result;
+```
+
+Immediately AFTER the opening `{` and BEFORE `var result = new List();`, ADD:
+
+```csharp
+ // Phase N.1: route to the WorldBuilder-backed implementation when
+ // ACDREAM_USE_WB_SCENERY=1. See
+ // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md.
+ if (UseWbScenery)
+ return GenerateViaWb(dats, region, block, landblockId, buildingCells);
+
+```
+
+So the method's opening becomes:
+
+```csharp
+ public static IReadOnlyList Generate(
+ DatCollection dats,
+ Region region,
+ LandBlock block,
+ uint landblockId,
+ HashSet? buildingCells = null,
+ float[]? heightTable = null)
+ {
+ // Phase N.1: route to the WorldBuilder-backed implementation when
+ // ACDREAM_USE_WB_SCENERY=1. See
+ // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md.
+ if (UseWbScenery)
+ return GenerateViaWb(dats, region, block, landblockId, buildingCells);
+
+ var result = new List();
+
+ if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
+ return result;
+```
+
+Note: `heightTable` is NOT passed to `GenerateViaWb` β the WB path uses `region.LandDefs.LandHeightTable` via `TerrainUtils.GetNormal`. The legacy path keeps the parameter for backward compatibility.
+
+- [ ] **Step 5.2: Verify build**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5`
+Expected: `Build succeeded.`
+
+- [ ] **Step 5.3: Verify all existing tests still pass with flag OFF (default)**
+
+Run: `dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3`
+Expected: all pass (the legacy path is still default, so behavior is unchanged).
+
+- [ ] **Step 5.4: Commit**
+
+```bash
+git add src/AcDream.Core/World/SceneryGenerator.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): wire ACDREAM_USE_WB_SCENERY dispatch in Generate()
+
+Phase N.1 step 5: when the flag is set, Generate() delegates to
+GenerateViaWb. Default off; flag flips to default-on in step 7
+after visual verification.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 6: Visual verification β manual checkpoint
+
+This task is interactive. **You must work with the user.** They run the client, look at landblock `0xA9B1`, and confirm two things visually:
+
+1. The road-edge tree we have been chasing all session is **not present** in the WB-backed render.
+2. The Issue #49 missing scenery (the trees the 9Γ9 loop expansion fixed) is **still visible**.
+
+- [ ] **Step 6.1: Make sure build is green**
+
+Run: `dotnet build 2>&1 | tail -3`
+Expected: `Build succeeded.`
+
+- [ ] **Step 6.2: Tell the user how to launch with the flag set**
+
+Tell the user (paraphrase): "Set `$env:ACDREAM_USE_WB_SCENERY = '1'` in your launch terminal alongside the other env vars, then launch the client and navigate to Holtburg. Specifically check the road-edge area near coordinates (87, 191) in landblock 0xA9B1 β the tree we have been chasing all session should be gone now. Also confirm Issue #49's previously missing trees are still there."
+
+If `dotnet run` is the standard launch command, the full PowerShell launch is:
+
+```powershell
+$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_USE_WB_SCENERY = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
+```
+
+- [ ] **Step 6.3: Wait for user's verification report**
+
+The user will tell you "yes the offending tree is gone and Issue #49 is still fine" or "still wrong". If still wrong, do NOT proceed β investigate and report back.
+
+- [ ] **Step 6.4: Commit nothing**
+
+This task does not produce a code change. The commit happens in Task 7 once the flag is flipped to default-on.
+
+---
+
+## Task 7: Flip default-on
+
+**Files:**
+- Modify: `src/AcDream.Core/World/SceneryGenerator.cs`
+
+After visual verification passes in Task 6, the WB-backed path becomes the default. The env var still exists as an escape hatch (`ACDREAM_USE_WB_SCENERY=0` reverts to legacy) so that if a regression is reported the next day, we can flip back without redeploying.
+
+- [ ] **Step 7.1: Flip the flag default**
+
+In `src/AcDream.Core/World/SceneryGenerator.cs`, find:
+
+```csharp
+ internal static readonly bool UseWbScenery =
+ System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1";
+```
+
+Replace with:
+
+```csharp
+ ///
+ /// Phase N.1: scenery placement uses WorldBuilder's SceneryHelpers
+ /// + TerrainUtils by default. Set ACDREAM_USE_WB_SCENERY=0
+ /// to restore the legacy in-line algorithms (escape hatch β to be deleted
+ /// in a follow-up commit once we have a few sessions of green visuals).
+ ///
+ internal static readonly bool UseWbScenery =
+ System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") != "0";
+```
+
+- [ ] **Step 7.2: Verify build + tests**
+
+Run: `dotnet build 2>&1 | tail -3 && dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3`
+Expected: build green, all targeted tests pass.
+
+- [ ] **Step 7.3: Commit**
+
+```bash
+git add src/AcDream.Core/World/SceneryGenerator.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): WB-backed scenery is now default-on
+
+Phase N.1 step 7: flips ACDREAM_USE_WB_SCENERY to default-on after
+visual verification at Holtburg confirmed the road-edge tree at
+0xA9B1 is gone and Issue #49 trees are still visible.
+
+ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Follow-up
+commit will delete the legacy code entirely.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 8: Delete the legacy code path
+
+**Files:**
+- Modify: `src/AcDream.Core/World/SceneryGenerator.cs`
+- Modify: `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs`
+
+After at least one session of clean visuals on the default-on flag, remove the legacy code so we don't accumulate dead-code drift.
+
+- [ ] **Step 8.1: Delete the legacy `Generate` body and rename `GenerateViaWb`**
+
+In `src/AcDream.Core/World/SceneryGenerator.cs`:
+
+1. Delete the `UseWbScenery` field entirely.
+2. Delete the entire body of `Generate` after its signature.
+3. Replace it with a body that just calls `GenerateViaWb`'s logic (or rename `GenerateViaWb` to `Generate`'s body).
+
+The simplest approach: rename `GenerateViaWb` to `GenerateInternal` and have the public `Generate` call it. Then delete the legacy logic. Final shape:
+
+```csharp
+public static IReadOnlyList Generate(
+ DatCollection dats,
+ Region region,
+ LandBlock block,
+ uint landblockId,
+ HashSet? buildingCells = null,
+ float[]? heightTable = null)
+{
+ // heightTable kept for backward compat; WB path uses
+ // region.LandDefs.LandHeightTable internally.
+ _ = heightTable;
+ return GenerateInternal(dats, region, block, landblockId, buildingCells);
+}
+
+private static IReadOnlyList GenerateInternal(
+ DatCollection dats,
+ Region region,
+ LandBlock block,
+ uint landblockId,
+ HashSet? buildingCells)
+{
+ // ... body that was GenerateViaWb ...
+}
+```
+
+4. Delete the now-unused private helpers: `IsOnRoad`, `DisplaceObject`, `RoadHalfWidth`, `CellsPerSide` (if only used by legacy path β keep if `GenerateInternal`'s building check still references it).
+
+Concretely, keep:
+- `VerticesPerSide`, `CellSize`, `LandblockSize`, `CellsPerSide` constants (still used in `GenerateInternal`)
+- `IsRoadVertex` (still useful as a tiny public predicate)
+- `WbSceneryAdapter` (still used)
+
+Delete:
+- `UseWbScenery`
+- `IsOnRoad` (and its `RoadHalfWidth` dependency)
+- `DisplaceObject` (now dead)
+
+- [ ] **Step 8.2: Update SceneryGeneratorTests.cs to remove now-irrelevant tests**
+
+In `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs`, the existing
+`DisplaceObject_EdgeVertex_CanProduceValidPosition` and
+`DisplaceObject_InteriorVertex_AlwaysNearOrigin` tests reference the deleted
+`SceneryGenerator.DisplaceObject` helper. Delete them.
+
+Keep:
+- All `IsRoadVertex_*` tests (`IsRoadVertex` is preserved).
+
+The class doc comment at the top should be updated to reflect the new state:
+
+```csharp
+///
+/// Tests for SceneryGenerator: the road-vertex predicate (only piece of
+/// our own algorithm code remaining post Phase N.1). The displacement /
+/// road / slope / rotation / scale algorithms now run through
+/// WorldBuilder's helpers β see SceneryWbConformanceTests.cs for the
+/// helper-level equivalence proof.
+///
+```
+
+- [ ] **Step 8.3: Update the SceneryWbConformanceTests now that legacy helpers are gone**
+
+`SceneryWbConformanceTests` currently calls `SceneryGenerator.DisplaceObject` and `SceneryGenerator.IsOnRoad`. Once those are deleted, the tests are now testing "WB matches WB" which is meaningless.
+
+Delete `SceneryWbConformanceTests.cs` entirely. The conformance tests served their purpose during the migration β they proved the substitution was safe. Now that we're committed to the WB path, they're vestigial.
+
+Run: `rm tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs`
+
+- [ ] **Step 8.4: Verify build + tests**
+
+Run: `dotnet build 2>&1 | tail -3 && dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter" 2>&1 | tail -3`
+Expected: build green, tests pass (just the IsRoadVertex tests + WbSceneryAdapter tests).
+
+- [ ] **Step 8.5: Commit**
+
+```bash
+git add src/AcDream.Core/World/SceneryGenerator.cs tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.1): delete legacy scenery code path; WB is the only path
+
+Phase N.1 step 8 (final): now that ACDREAM_USE_WB_SCENERY has been
+default-on for a session with no regressions, remove the legacy
+in-line algorithms so we don't accumulate dead-code drift.
+
+Deleted:
+- SceneryGenerator.UseWbScenery (feature flag)
+- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy ports)
+- SceneryGeneratorTests.DisplaceObject_* (test the deleted method)
+- SceneryWbConformanceTests.cs (purpose served β proved equivalence pre-migration)
+
+Renamed:
+- GenerateViaWb β GenerateInternal (the only path now)
+
+Kept:
+- IsRoadVertex (small predicate, still used by tests + may be useful elsewhere)
+- WbSceneryAdapter (consumed by GenerateInternal; reusable in N.2)
+
+Phase N.1 complete. Issues #48, #49 are addressed via WB's tested
+algorithms. Roadmap entry under Phase N can be marked shipped.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+- [ ] **Step 8.6: Mark Phase N.1 shipped in the roadmap**
+
+In `docs/plans/2026-04-11-roadmap.md`, find the Phase N section (search for `### Phase N β WorldBuilder Rendering Migration`). Inside the sub-phases table find the row for **N.1**, currently:
+
+```
+- **N.1 β Scenery algorithm calls.** Replace `IsOnRoad` /
+ `DisplaceObject` / slope-normal calc / rotation / scale inside
+ `SceneryGenerator.Generate()` with calls to WB's `SceneryHelpers` +
+ `TerrainUtils`. Tiny adapter `LandBlock β TerrainEntry[]`. Keeps our
+ data flow + `ScenerySpawn` shape. Feature flag
+ `ACDREAM_USE_WB_SCENERY=1`. ~1-2 days.
+```
+
+Add a status marker at the start of the line:
+
+```
+- **β SHIPPED β N.1 β Scenery algorithm calls.** Shipped 2026-05-08.
+ Replaced `IsOnRoad` / `DisplaceObject` / slope-normal calc / rotation /
+ scale inside `SceneryGenerator.Generate()` with calls to WB's
+ `SceneryHelpers` + `TerrainUtils`. Adapter `WbSceneryAdapter` produces
+ `TerrainEntry[]`. Visual verification at Holtburg confirmed the
+ road-edge tree at 0xA9B1 is gone and Issue #49 trees are still visible.
+```
+
+Also: add a row to the top of the file's "Phases already shipped" table, in commit-shipped order:
+
+```
+| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live β |
+```
+
+Then commit:
+
+```bash
+git add docs/plans/2026-04-11-roadmap.md
+git commit -m "$(cat <<'EOF'
+docs(roadmap): mark Phase N.1 (scenery via WB helpers) shipped
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Done definition
+
+After all 8 tasks land cleanly:
+
+- [x] `dotnet build` and `dotnet test` (excluding the 8 pre-existing `DispatcherToMovementIntegrationTests` failures unrelated to this work) green.
+- [x] Visual verification at Holtburg confirms:
+ - The road-edge tree near `0xA9B1` is **gone**.
+ - Issue #49's missing scenery is **still visible**.
+ - No new visual regressions in surrounding landblocks during a brief flight.
+- [x] Phase N.1 marked shipped in `docs/plans/2026-04-11-roadmap.md`.
+- [x] `SceneryGenerator.Generate` calls only WB helpers for displacement / road / slope / rotation / scale.
+- [x] Issue #49 stays closed; no new related issues filed.
diff --git a/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md b/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
new file mode 100644
index 0000000..57d6f26
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
@@ -0,0 +1,721 @@
+# Phase N.3 β Texture Decoding via WorldBuilder 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.
+
+**Goal:** Replace acdream's hand-rolled pixel-format decoders in `SurfaceDecoder` with calls to WorldBuilder's `TextureHelpers.Fill*` methods for every format WB covers (INDEX16, P8, A8R8G8B8, R8G8B8, A8, A8Additive, R5G6B5, A4R4G4B4). Keep our decoders for formats WB lacks (X8R8G8B8, DXT1/3/5 with clipmap postprocess, SolidColor with translucency). Add conformance tests proving byte-identical output for each substituted format. Add the two previously-unsupported formats (R5G6B5, A4R4G4B4) as a bonus.
+
+**Architecture:** In-place substitution inside `SurfaceDecoder`. Each private `Decode*` method that has a WB equivalent gets rewritten to allocate a `byte[]`, call `TextureHelpers.Fill*` into it, and return a `DecodedTexture`. The critical A8 divergence is resolved by adding an `isAdditive` parameter to `DecodeRenderSurface` β callers that know the `SurfaceType` pass it, terrain alpha callers (which always use the additive/replicate path) pass `isAdditive: true`. No feature flag β conformance tests prove equivalence before substitution, so the old code is deleted in the same pass.
+
+**Tech Stack:** .NET 10 / C# 13, `Chorizite.OpenGLSDLBackend` (already referenced via `AcDream.Core.csproj`), `DatReaderWriter` for `RenderSurface` / `Palette` / `PixelFormat` types, `BCnEncoder.Net` for DXT (stays ours), xUnit for tests.
+
+**Spec:** `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
+**Inventory:** `docs/architecture/worldbuilder-inventory.md`
+**Handoff:** `docs/research/2026-05-08-phase-n3-handoff.md`
+
+**Prerequisite:** Phase N.0 shipped (submodule wired), Phase N.1 shipped (scenery migration). `AcDream.Core.csproj` already references `Chorizite.OpenGLSDLBackend`.
+
+---
+
+## Audit Summary
+
+| # | Our function | WB equivalent | Action |
+|---|---|---|---|
+| 1 | `DecodeIndex16` | `TextureHelpers.FillIndex16` | **Substitute** |
+| 2 | `DecodeP8` | `TextureHelpers.FillP8` | **Substitute** |
+| 3 | `DecodeA8R8G8B8` | `TextureHelpers.FillA8R8G8B8` | **Substitute** |
+| 4 | `DecodeR8G8B8` | `TextureHelpers.FillR8G8B8` | **Substitute** |
+| 5 | `DecodeA8` | `TextureHelpers.FillA8` + `FillA8Additive` | **Substitute** (additive-aware) |
+| 6 | `DecodeX8R8G8B8` | None | **Keep ours** |
+| 7 | `DecodeBc` (DXT1/3/5) | None in TextureHelpers | **Keep ours** |
+| 8 | `DecodeSolidColor` | Different semantics | **Keep ours** |
+| 9 | (missing) | `TextureHelpers.FillR5G6B5` | **Add new** |
+| 10 | (missing) | `TextureHelpers.FillA4R4G4B4` | **Add new** |
+
+### A8 divergence detail
+
+- **Our current `DecodeA8`:** R=G=B=A=val (all four channels = alpha byte)
+- **WB `FillA8`:** R=G=B=255, A=val (white + alpha)
+- **WB `FillA8Additive`:** R=G=B=A=val (same as our current behavior)
+
+WB dispatches based on `surface.Type.HasFlag(SurfaceType.Additive)`:
+- Additive surfaces β `FillA8Additive` (R=G=B=A=val)
+- Non-additive surfaces β `FillA8` (R=G=B=255, A=val)
+
+Our current code always does the additive path. This is correct for terrain alpha masks (used as blend weights where `.r` channel = `.a` channel matters) but diverges from WB for non-additive A8 entity textures. Resolution: thread an `isAdditive` flag through the decode API.
+
+---
+
+## File Plan
+
+| File | Disposition | Responsibility |
+|---|---|---|
+| `src/AcDream.Core/Textures/SurfaceDecoder.cs` | MODIFY | Replace 5 private decode methods with WB `TextureHelpers.Fill*` calls. Add `isAdditive` parameter to `DecodeRenderSurface`. Add R5G6B5 + A4R4G4B4 format cases. Keep X8R8G8B8, DXT, SolidColor. |
+| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | Pass `surface.Type.HasFlag(SurfaceType.Additive)` as `isAdditive` to `SurfaceDecoder.DecodeRenderSurface`. |
+| `src/AcDream.App/Rendering/TerrainAtlas.cs` | MODIFY | Pass `isAdditive: true` to `SurfaceDecoder.DecodeRenderSurface` in `TryDecodeAlphaMap` (terrain alpha masks always use the replicate-all-channels path). |
+| `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs` | NEW | Per-format conformance tests: synthetic byte arrays decoded by both our old logic and WB's `TextureHelpers.Fill*`, asserting byte-identical output. |
+
+---
+
+## Task 1: Conformance tests for the 5 clean substitutions
+
+Write tests first, run them to prove our current output matches WB's output for each format. These tests lock in the equivalence BEFORE any code changes β if any test fails, we know the formats actually diverge and must investigate.
+
+**Files:**
+- Create: `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`
+
+- [ ] **Step 1.1: Create the conformance test file with INDEX16 test**
+
+Create `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`:
+
+```csharp
+using Chorizite.OpenGLSDLBackend.Lib;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Textures;
+
+///
+/// Conformance tests proving WorldBuilder's TextureHelpers.Fill* methods
+/// produce byte-identical output to our SurfaceDecoder private methods
+/// for each pixel format. These tests run BEFORE the substitution β if
+/// one fails, the formats diverge and we must investigate, not "fix" the test.
+///
+public sealed class TextureDecodeConformanceTests
+{
+ [Fact]
+ public void FillIndex16_MatchesOurDecodeIndex16()
+ {
+ // 2x2 INDEX16 texture: 4 pixels, each a 16-bit LE palette index.
+ // Palette: index 0 = (R=10, G=20, B=30, A=255), index 1 = (R=40, G=50, B=60, A=200)
+ // Pixel data: [0x0000, 0x0100, 0x0100, 0x0000] (indices 0, 1, 1, 0)
+ byte[] src = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00];
+ int w = 2, h = 2;
+
+ var palette = new Palette();
+ palette.Colors.Add(new ColorARGB { Red = 10, Green = 20, Blue = 30, Alpha = 255 });
+ palette.Colors.Add(new ColorARGB { Red = 40, Green = 50, Blue = 60, Alpha = 200 });
+
+ // Our decode
+ byte[] ours = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ int si = i * 2;
+ ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
+ var c = palette.Colors[idx];
+ int di = i * 4;
+ ours[di + 0] = c.Red;
+ ours[di + 1] = c.Green;
+ ours[di + 2] = c.Blue;
+ ours[di + 3] = c.Alpha;
+ }
+
+ // WB decode
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h);
+
+ Assert.Equal(ours, wb);
+ }
+
+ [Fact]
+ public void FillIndex16_ClipMap_MatchesOurClipMapBehavior()
+ {
+ // Index 3 (< 8) should be transparent, index 10 should be normal
+ byte[] src = [0x03, 0x00, 0x0A, 0x00];
+ int w = 2, h = 1;
+
+ var palette = new Palette();
+ for (int i = 0; i < 16; i++)
+ palette.Colors.Add(new ColorARGB { Red = (byte)(i * 10), Green = (byte)(i * 15), Blue = (byte)(i * 5), Alpha = 255 });
+
+ // Our clipmap decode: index < 8 β all zeros
+ byte[] ours = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ int si = i * 2;
+ ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
+ int di = i * 4;
+ if (idx < 8)
+ {
+ ours[di] = ours[di + 1] = ours[di + 2] = ours[di + 3] = 0;
+ }
+ else
+ {
+ var c = palette.Colors[idx];
+ ours[di + 0] = c.Red;
+ ours[di + 1] = c.Green;
+ ours[di + 2] = c.Blue;
+ ours[di + 3] = c.Alpha;
+ }
+ }
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h, isClipMap: true);
+
+ Assert.Equal(ours, wb);
+ }
+
+ [Fact]
+ public void FillP8_MatchesOurDecodeP8()
+ {
+ // 2x2 P8 texture: 4 pixels, each a single-byte palette index.
+ byte[] src = [0, 1, 1, 0];
+ int w = 2, h = 2;
+
+ var palette = new Palette();
+ palette.Colors.Add(new ColorARGB { Red = 100, Green = 110, Blue = 120, Alpha = 255 });
+ palette.Colors.Add(new ColorARGB { Red = 200, Green = 210, Blue = 220, Alpha = 180 });
+
+ byte[] ours = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ var c = palette.Colors[src[i]];
+ int di = i * 4;
+ ours[di + 0] = c.Red;
+ ours[di + 1] = c.Green;
+ ours[di + 2] = c.Blue;
+ ours[di + 3] = c.Alpha;
+ }
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillP8(src, palette, wb.AsSpan(), w, h);
+
+ Assert.Equal(ours, wb);
+ }
+
+ [Fact]
+ public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8()
+ {
+ // 2x1 A8R8G8B8: on-disk order is B, G, R, A per pixel
+ byte[] src = [0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD];
+ int w = 2, h = 1;
+
+ // Our decode: swap B,G,R,A β R,G,B,A
+ byte[] ours = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ int s = i * 4;
+ ours[s + 0] = src[s + 2]; // R
+ ours[s + 1] = src[s + 1]; // G
+ ours[s + 2] = src[s + 0]; // B
+ ours[s + 3] = src[s + 3]; // A
+ }
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillA8R8G8B8(src, wb.AsSpan(), w, h);
+
+ Assert.Equal(ours, wb);
+ }
+
+ [Fact]
+ public void FillR8G8B8_MatchesOurDecodeR8G8B8()
+ {
+ // 2x1 R8G8B8: on-disk order is B, G, R per pixel (3 bytes)
+ byte[] src = [0x10, 0x20, 0x30, 0xAA, 0xBB, 0xCC];
+ int w = 2, h = 1;
+
+ // Our decode: swap B,G,R β R,G,B,255
+ byte[] ours = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ int si = i * 3;
+ int di = i * 4;
+ ours[di + 0] = src[si + 2]; // R
+ ours[di + 1] = src[si + 1]; // G
+ ours[di + 2] = src[si + 0]; // B
+ ours[di + 3] = 0xFF;
+ }
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillR8G8B8(src, wb.AsSpan(), w, h);
+
+ Assert.Equal(ours, wb);
+ }
+
+ [Fact]
+ public void FillA8Additive_MatchesOurDecodeA8()
+ {
+ // 4x1 A8: each byte replicated to all four channels (our current behavior)
+ byte[] src = [0x00, 0x80, 0xFF, 0x42];
+ int w = 4, h = 1;
+
+ byte[] ours = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ byte a = src[i];
+ int d = i * 4;
+ ours[d + 0] = a;
+ ours[d + 1] = a;
+ ours[d + 2] = a;
+ ours[d + 3] = a;
+ }
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillA8Additive(src, wb.AsSpan(), w, h);
+
+ Assert.Equal(ours, wb);
+ }
+
+ [Fact]
+ public void FillA8_NonAdditive_ProducesWhitePlusAlpha()
+ {
+ // WB's non-additive A8: R=G=B=255, A=val
+ // This is DIFFERENT from our current DecodeA8 (which does R=G=B=A=val).
+ // This test documents the WB behavior we're adopting for non-additive surfaces.
+ byte[] src = [0x00, 0x80, 0xFF, 0x42];
+ int w = 4, h = 1;
+
+ byte[] expected = new byte[w * h * 4];
+ for (int i = 0; i < w * h; i++)
+ {
+ int d = i * 4;
+ expected[d + 0] = 255;
+ expected[d + 1] = 255;
+ expected[d + 2] = 255;
+ expected[d + 3] = src[i];
+ }
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillA8(src, wb.AsSpan(), w, h);
+
+ Assert.Equal(expected, wb);
+ }
+
+ [Fact]
+ public void FillR5G6B5_ProducesExpectedRgba()
+ {
+ // R5G6B5: 16-bit packed RGB. Not currently handled by our decoder.
+ // White (0xFFFF) β R=248,G=252,B=248,A=255 (bit expansion truncation)
+ // Black (0x0000) β R=0,G=0,B=0,A=255
+ byte[] src = [0xFF, 0xFF, 0x00, 0x00];
+ int w = 2, h = 1;
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillR5G6B5(src, wb.AsSpan(), w, h);
+
+ // Pixel 0: white-ish
+ Assert.Equal(248, wb[0]); // R: 31 << 3
+ Assert.Equal(252, wb[1]); // G: 63 << 2
+ Assert.Equal(248, wb[2]); // B: 31 << 3
+ Assert.Equal(255, wb[3]); // A
+
+ // Pixel 1: black
+ Assert.Equal(0, wb[4]);
+ Assert.Equal(0, wb[5]);
+ Assert.Equal(0, wb[6]);
+ Assert.Equal(255, wb[7]);
+ }
+
+ [Fact]
+ public void FillA4R4G4B4_ProducesExpectedRgba()
+ {
+ // A4R4G4B4: 16-bit packed ARGB. Not currently handled by our decoder.
+ // 0xF8C4 β A=15*17=255, R=8*17=136, G=12*17=204, B=4*17=68
+ byte[] src = [0xC4, 0xF8];
+ int w = 1, h = 1;
+
+ byte[] wb = new byte[w * h * 4];
+ TextureHelpers.FillA4R4G4B4(src, wb.AsSpan(), w, h);
+
+ Assert.Equal(136, wb[0]); // R: ((0xF8C4 >> 8) & 0x0F) * 17 = 8*17
+ Assert.Equal(204, wb[1]); // G: ((0xF8C4 >> 4) & 0x0F) * 17 = 12*17
+ Assert.Equal(68, wb[2]); // B: (0xF8C4 & 0x0F) * 17 = 4*17
+ Assert.Equal(255, wb[3]); // A: ((0xF8C4 >> 12) & 0x0F) * 17 = 15*17
+ }
+}
+```
+
+- [ ] **Step 1.2: Run tests to verify they pass**
+
+Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal`
+
+Expected: All 9 tests PASS. These tests compare our current algorithm inline against WB's `TextureHelpers` β if any fail, it means the algorithms actually diverge and we must investigate before proceeding.
+
+- [ ] **Step 1.3: Commit**
+
+```
+git add tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
+git commit -m "test(N.3): conformance tests proving WB TextureHelpers matches our decode
+
+Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8,
+A8Additive (matches our current DecodeA8), A8 non-additive (documents
+the divergence), R5G6B5, A4R4G4B4. All run before any substitution β
+they prove equivalence, not test the substitution.
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 2: Add `isAdditive` parameter to SurfaceDecoder and wire A8 split
+
+Thread the `isAdditive` flag through the decode API so the A8 format can dispatch to either WB path. Update all three callers.
+
+**Files:**
+- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs`
+- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
+- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs`
+
+- [ ] **Step 2.1: Add `isAdditive` parameter to `DecodeRenderSurface`**
+
+In `src/AcDream.Core/Textures/SurfaceDecoder.cs`, change the main public overload signature from:
+
+```csharp
+public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false)
+```
+
+to:
+
+```csharp
+public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false)
+```
+
+And update the `PFID_A8`/`PFID_CUSTOM_LSCAPE_ALPHA` case in the switch from:
+
+```csharp
+PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
+```
+
+to:
+
+```csharp
+PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive),
+```
+
+And update the no-palette overload from:
+
+```csharp
+public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
+ => DecodeRenderSurface(rs, palette: null);
+```
+
+to:
+
+```csharp
+public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
+ => DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
+```
+
+- [ ] **Step 2.2: Split `DecodeA8` into additive vs non-additive**
+
+In `SurfaceDecoder.cs`, change the `DecodeA8` method signature and add the split:
+
+```csharp
+private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
+{
+ int expected = rs.Width * rs.Height;
+ if (rs.SourceData.Length < expected)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[expected * 4];
+ if (isAdditive)
+ {
+ // Additive: R=G=B=A=val (current behavior, matches WB FillA8Additive)
+ for (int i = 0; i < expected; i++)
+ {
+ byte a = rs.SourceData[i];
+ int d = i * 4;
+ rgba[d + 0] = a;
+ rgba[d + 1] = a;
+ rgba[d + 2] = a;
+ rgba[d + 3] = a;
+ }
+ }
+ else
+ {
+ // Non-additive: R=G=B=255, A=val (matches WB FillA8)
+ for (int i = 0; i < expected; i++)
+ {
+ int d = i * 4;
+ rgba[d + 0] = 255;
+ rgba[d + 1] = 255;
+ rgba[d + 2] = 255;
+ rgba[d + 3] = rs.SourceData[i];
+ }
+ }
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 2.3: Update TextureCache to pass `isAdditive`**
+
+In `src/AcDream.App/Rendering/TextureCache.cs`, in `DecodeFromDats`, change line 203 from:
+
+```csharp
+return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
+```
+
+to:
+
+```csharp
+bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
+return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
+```
+
+- [ ] **Step 2.4: Update TerrainAtlas to pass `isAdditive: true`**
+
+In `src/AcDream.App/Rendering/TerrainAtlas.cs`, in `TryDecodeAlphaMap`, change line 322 from:
+
+```csharp
+var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
+```
+
+to:
+
+```csharp
+var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
+```
+
+The terrain alpha masks MUST use the additive path (R=G=B=A=val) because our terrain blending shader reads from `.r` for the blend weight.
+
+- [ ] **Step 2.5: Build and test**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal`
+
+Expected: Build green, all 9 conformance tests still pass.
+
+- [ ] **Step 2.6: Commit**
+
+```
+git add src/AcDream.Core/Textures/SurfaceDecoder.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/Rendering/TerrainAtlas.cs
+git commit -m "refactor(N.3): thread isAdditive through A8 decode path
+
+SurfaceDecoder.DecodeRenderSurface now accepts isAdditive parameter.
+A8/CUSTOM_LSCAPE_ALPHA format splits:
+- isAdditive=true: R=G=B=A=val (terrain alpha, additive entity textures)
+- isAdditive=false: R=G=B=255, A=val (non-additive entity textures)
+
+TextureCache passes surface.Type.HasFlag(SurfaceType.Additive).
+TerrainAtlas passes isAdditive:true (alpha masks always replicate).
+This aligns with WB ObjectMeshManager's dispatch logic.
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 3: Substitute 5 decode methods with WB TextureHelpers calls
+
+Replace the body of each private decode method with a call to the corresponding WB `TextureHelpers.Fill*` method. Add the two new format cases (R5G6B5, A4R4G4B4).
+
+**Files:**
+- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs`
+
+- [ ] **Step 3.1: Add WB using directive**
+
+At the top of `SurfaceDecoder.cs`, add:
+
+```csharp
+using Chorizite.OpenGLSDLBackend.Lib;
+```
+
+- [ ] **Step 3.2: Replace `DecodeIndex16`**
+
+Replace the body of `DecodeIndex16` with:
+
+```csharp
+private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap)
+{
+ int expectedBytes = rs.Width * rs.Height * 2;
+ if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[rs.Width * rs.Height * 4];
+ TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 3.3: Replace `DecodeP8`**
+
+Replace the body of `DecodeP8` with:
+
+```csharp
+private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap)
+{
+ int expectedBytes = rs.Width * rs.Height;
+ if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[rs.Width * rs.Height * 4];
+ TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 3.4: Replace `DecodeA8R8G8B8`**
+
+Replace the body of `DecodeA8R8G8B8` with:
+
+```csharp
+private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs)
+{
+ int expected = rs.Width * rs.Height * 4;
+ if (rs.SourceData.Length < expected)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[expected];
+ TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 3.5: Replace `DecodeR8G8B8`**
+
+Replace the body of `DecodeR8G8B8` with:
+
+```csharp
+private static DecodedTexture DecodeR8G8B8(RenderSurface rs)
+{
+ int expectedBytes = rs.Width * rs.Height * 3;
+ if (rs.SourceData.Length < expectedBytes)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[rs.Width * rs.Height * 4];
+ TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 3.6: Replace `DecodeA8`**
+
+Replace the body of `DecodeA8` with:
+
+```csharp
+private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
+{
+ int expected = rs.Width * rs.Height;
+ if (rs.SourceData.Length < expected)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[expected * 4];
+ if (isAdditive)
+ TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
+ else
+ TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 3.7: Add R5G6B5 and A4R4G4B4 cases to the format switch**
+
+In the `DecodeRenderSurface` switch, add two new cases before the `_ => DecodedTexture.Magenta` default:
+
+```csharp
+PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
+PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),
+```
+
+And add the two new private methods:
+
+```csharp
+private static DecodedTexture DecodeR5G6B5(RenderSurface rs)
+{
+ int expectedBytes = rs.Width * rs.Height * 2;
+ if (rs.SourceData.Length < expectedBytes)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[rs.Width * rs.Height * 4];
+ TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+
+private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs)
+{
+ int expectedBytes = rs.Width * rs.Height * 2;
+ if (rs.SourceData.Length < expectedBytes)
+ return DecodedTexture.Magenta;
+
+ var rgba = new byte[rs.Width * rs.Height * 4];
+ TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
+ return new DecodedTexture(rgba, rs.Width, rs.Height);
+}
+```
+
+- [ ] **Step 3.8: Build and run all tests**
+
+Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
+
+Expected: Build green, 873+ tests pass, 8 pre-existing failures unchanged.
+
+- [ ] **Step 3.9: Commit**
+
+```
+git add src/AcDream.Core/Textures/SurfaceDecoder.cs
+git commit -m "phase(N.3): substitute 5 decode methods with WB TextureHelpers
+
+INDEX16, P8, A8R8G8B8, R8G8B8, A8 now delegate to
+TextureHelpers.FillIndex16/FillP8/FillA8R8G8B8/FillR8G8B8/
+FillA8/FillA8Additive. Validation + DecodedTexture wrapping stays ours.
+X8R8G8B8, DXT1/3/5, SolidColor remain our implementations (no WB equiv).
+
+Bonus: R5G6B5 + A4R4G4B4 formats now handled (previously fell to magenta).
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 4: Update roadmap + ISSUES, final cleanup
+
+**Files:**
+- Modify: `docs/plans/2026-04-11-roadmap.md` β mark N.3 shipped
+- Modify: `docs/ISSUES.md` β file any cosmetic deltas found
+
+- [ ] **Step 4.1: Update roadmap**
+
+In the roadmap, update the Phase N.3 entry to show shipped status with today's date and commit hash (obtain from `git log -1 --format='%h'`).
+
+- [ ] **Step 4.2: File any ISSUES**
+
+If the A8 non-additive behavioral change surfaces any visual delta at Holtburg during verification, file it in `docs/ISSUES.md`. Example:
+
+```markdown
+### #NN: A8 non-additive textures now render white+alpha instead of gray+alpha
+
+**Status:** OPEN
+**Phase:** N.3
+**Symptom:** [describe if applicable]
+**Root cause:** WB's FillA8 outputs R=G=B=255,A=val; our old DecodeA8 output R=G=B=A=val. For non-additive surfaces this is a behavioral change.
+**Impact:** [assess after visual verification]
+```
+
+If no visual delta is observed, skip this step β no issue to file.
+
+- [ ] **Step 4.3: Commit**
+
+```
+git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md
+git commit -m "docs: mark Phase N.3 shipped, update ISSUES if applicable
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 5: Visual verification (human-in-the-loop)
+
+This task requires the user to launch the client and inspect textures at Holtburg.
+
+- [ ] **Step 5.1: Build and launch**
+
+```powershell
+dotnet build --verbosity quiet
+$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"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
+```
+
+- [ ] **Step 5.2: Visual checks**
+
+Walk around Holtburg and verify:
+1. **Terrain textures** β grass, dirt, sand transitions look correct (not magenta, not discolored)
+2. **Tree/bush textures** β scenery objects textured correctly (clipmap alpha works)
+3. **Building textures** β walls, roofs, doors look right
+4. **Sky/clouds** β if A8 textures are involved, verify they still render
+5. **Particles** β rain/aurora if weather is active
+
+If all look correct, N.3 is done. If regressions found, file in ISSUES.md per the handoff doc's "whackamole stops the migration" rule.
diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
new file mode 100644
index 0000000..4b4e401
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
@@ -0,0 +1,2737 @@
+# Phase N.4 β Rendering Pipeline Foundation 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.
+
+**Goal:** Adopt WB's `ObjectMeshManager` + `TextureAtlasManager` as acdream's rendering pipeline foundation. Two-tier split (atlas for shared procedural content; per-instance path for customized server-spawned entities). Animation by per-draw matrix composition with `AnimationSequencer` untouched. Streaming integration via ~200 LOC adapter shim. Surface metadata preserved via side-table (no fork patches). Single shippable phase, 3-4 weeks. Ships no visible change.
+
+**Architecture:** Strangler-fig substitution behind `ACDREAM_USE_WB_FOUNDATION=1` feature flag. Conformance tests run before substitution per N.1/N.3 pattern. Week-by-week sequencing minimizes "broken in middle" state: week 1 brings up plumbing + atlas for static scenery; week 2 wires streaming; week 3 adds per-instance path + animation; week 4 polishes + ships. Foundation enables N.5/N.6/N.7/N.8 to be smaller integration phases on top.
+
+**Tech Stack:** .NET 10 / C# 13 Β· Silk.NET.OpenGL (transitively via WB) Β· Chorizite.OpenGLSDLBackend (already referenced) Β· BCnEncoder.Net (transitively) Β· xUnit Β· `Chorizite.Core.Render` interfaces.
+
+**Spec:** [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../specs/2026-05-08-phase-n4-rendering-foundation-design.md) β read FIRST. Everything below assumes you've read it.
+**Parent design:** [docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md](../specs/2026-05-08-phase-n-worldbuilder-migration-design.md)
+**Inventory:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
+**Roadmap:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) β N.4 entry
+
+**Prerequisites:**
+- Phase N.0 shipped (commit `c8782c9`) β WB submodule + project references wired up.
+- Phase N.1 shipped (merge `1978ef9`) β scenery via WB helpers.
+- Phase N.3 shipped (merge `13132f9`) β texture decode via WB `TextureHelpers`.
+- Build green, 883 tests passing, 8 pre-existing failures only.
+- Worktree: `.claude/worktrees/quirky-jepsen-fd60f1` on branch `claude/quirky-jepsen-fd60f1`.
+
+---
+
+## File Plan
+
+| File | Disposition | Responsibility |
+|---|---|---|
+| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | NEW | Owns the `ObjectMeshManager` instance. Exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` / `Dispose`. The single seam between acdream and WB's render pipeline. |
+| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | NEW | Record holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog` (the AC-specific surface properties WB's `MeshBatchData` doesn't carry). |
+| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | NEW | `Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>` side-table. Populated at mesh-extraction time; queried at draw time. Thread-safe (`ConcurrentDictionary`). |
+| `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | NEW | Streaming-loader hook. Walks `LandblockEntry.Setups[]` / `Statics[]` and calls `WbMeshAdapter.IncrementRefCount` per unique GfxObj. Companion unload path. |
+| `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | NEW | Network-spawn hook. Routes `CreateObject` to per-instance path via existing `TextureCache.GetOrUploadWithPaletteOverride`. Builds per-entity `AnimatedEntityState`. |
+| `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | NEW | Per-entity render state for animated entities: `partGfxObjOverrides` (AnimPartChange), `hiddenMask` (HiddenParts), reference to existing `AnimationSequencer`. |
+| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | NEW | Per-frame draw loop. Walks visible entities, looks up `ObjectRenderData`, composes per-part matrices (entity Γ animation Γ rest-pose), reads side-table, issues GL draws. |
+| `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs` | NEW | Static flag gate: `WbFoundationFlag.IsEnabled` reads `ACDREAM_USE_WB_FOUNDATION` env var once at process start, exposes a single `bool`. Other call sites import this rather than re-reading the env var. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs` | NEW | Conformance: our `GfxObjMesh.Build` vs WB's algorithm output. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs` | NEW | Conformance: our `SetupMesh.Flatten` vs WB's setup-parts walk. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs` | NEW | Conformance: per-instance customization decode produces identical RGBA8 vs `TextureCache.GetOrUploadWithPaletteOverride`. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs` | NEW | Round-trip + thread-safety smoke. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs` | NEW | Register/unregister + dedup. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs` | NEW | Routes CreateObject with palette override to per-instance path. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs` | NEW | Entity Γ animation Γ rest-pose matrix composition. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs` | NEW | Bitmask suppression. |
+| `tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs` | NEW | Override resolution. |
+| `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | MODIFY | Internal swap: `EnsureUploaded` and `Draw` route through `WbMeshAdapter` when `WbFoundationFlag.IsEnabled`. Public surface unchanged. **N.6 fully replaces this file.** |
+| `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | MODIFY | Same pattern β internal swap, public surface unchanged. **N.6 fully replaces this file.** |
+| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | `GetOrUpload(surfaceId)` (atlas-tier callers) routes through `WbMeshAdapter` when flag on. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep current behavior. |
+| `src/AcDream.App/Streaming/GpuWorldState.cs` | MODIFY | `AddLandblock` / `RemoveLandblock` call `LandblockSpawnAdapter` when flag on. `AppendLiveEntity` calls `EntitySpawnAdapter` when flag on. Pending-spawn list mechanism preserved verbatim. |
+| `src/AcDream.App/Rendering/GameWindow.cs` | MODIFY | Construct `WbMeshAdapter` + `AcSurfaceMetadataTable` + `WbDrawDispatcher` on init. Dispose on shutdown. |
+| `docs/plans/2026-04-11-roadmap.md` | MODIFY (final task) | Mark N.4 shipped after visual verification. |
+| `CLAUDE.md` | MODIFY (early task) | Add pointer to this plan in the "Roadmap discipline" section so future agents pick it up. |
+
+**Why this structure:** the `Wb/` subfolder isolates everything new in N.4 from existing renderers. After N.6 fully replaces `StaticMeshRenderer` / `InstancedMeshRenderer`, the `Wb/` folder becomes the canonical rendering implementation. Each new file has one responsibility. Existing files are touched minimally; the bulk of N.4 lives in the new folder.
+
+---
+
+## Plan Living-Document Convention
+
+This plan is the **execution source of truth** for N.4. It is updated as tasks land:
+
+- After each commit that completes a task, mark the task's checkboxes β
and append the commit SHA next to the task header.
+- If a task uncovers an architectural surprise that requires re-planning, add a **`### Adjustment N`** subsection under the affected task with the date, what changed, and why. Do not silently rewrite earlier tasks.
+- If a downstream task changes shape because of an earlier task's outcome, append the changes to the downstream task in-place rather than scattering deltas.
+- Final commit for the phase updates this header note from "Living document β work in progress" to "Final state at β phase shipped (merge ``)."
+
+Status: **Final state at 2026-05-08 β phase shipped.** All tasks
+complete; `ACDREAM_USE_WB_FOUNDATION` flipped default-on. Visual
+verification at Holtburg passed. Three bugs surfaced + resolved during
+Task 26 are documented as Adjustments 7-9 below and as gotchas in
+CLAUDE.md. Followup work moves to N.5 (modern rendering path: bindless
++ multi-draw indirect).
+
+**Progress (2026-05-08):** Weeks 1 + 2 + 3 β
COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural β same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only.
+
+**Next: Task 22** (Week 4) β `WbDrawDispatcher` full draw loop. The first task that actually draws through WB and unlocks the dual-pipeline-cost mitigation from Adjustment 3.
+
+| Task | Status | Commit |
+|---|---|---|
+| 1 β WbFoundationFlag scaffold | β
| `81b5ed8` |
+| 2 β AcSurfaceMetadata + Table | β
| `46deed6` |
+| 3 β Mesh-extraction conformance | β
| `ed73fc5` |
+| 4 β Setup-flatten conformance | β
| `ed73fc5` |
+| 5 β WbMeshAdapter stub + IWbMeshAdapter | β
| (post-`ed73fc5`) |
+| 6 β WbDatReaderAdapter | β
OBSOLETED (Adj. 1) | `502c3a8` |
+| 7 β GameWindow wiring under flag | β
| `502c3a8` |
+| 8 β CLAUDE.md pointer | β
| `506b86b` (preemptive) |
+| 9 β Real WB pipeline + InstancedMeshRenderer routing | β
partial / Adj. 2 reverted | `4ad7a98` + `4f318bc` |
+| 10 β Week 1 wrap-up | β
| `c49c6ed` |
+| 11 β LandblockSpawnAdapter | β
| `669768d` |
+| 12 β Wire into GpuWorldState | β
| `931a690` |
+| 13 β Memory budget verification | β
deferred to Task 22 (Adj. 3) | β |
+| 14 β Pending-spawn integration test | β
| `f4f0101` |
+| Tick β drain WB pipeline queues | β
added per Adj. 3 | `bf53cb4` |
+| 15 β Week 2 wrap-up | β
| `36f7a60` |
+| 16+18+19 β AnimatedEntityState + AnimPartChange + HiddenParts | β
| `ce72c57` |
+| 17 β EntitySpawnAdapter | β
+ Adj. 4 | `c02c307` |
+| 20 β Per-instance decode conformance | β
structural (Adj. 5) | (no test file) |
+| 21 β Week 3 wrap-up | β
| (this commit) |
+| 22+23 β WbDrawDispatcher + side-table population | β
| `01cff41` |
+| 22+23 fixup β load triggers + SurfaceId source | β
| `943652d` |
+| 22+23 perf β FirstIndex/BaseVertex + #47 + grouped instanced | β
| `7b41efc` |
+| 22+23 perf 1-4 β drop dead lookup, sort, cull, hash memo | β
| `573526d` |
+| 24 β Sky-pass preservation check | β
structural (independent) | `5df9135` |
+| 25 β Component micro-tests round-out | β
all spec tests covered | β |
+| 26 β Visual verification + flag default-on | β
| (this commit) |
+| 27 β Delete legacy code paths | β οΈ deferred to N.6 (legacy retained as flag-off escape hatch) | β |
+| 28 β Update memory + ISSUES + finalize plan | β
| (this commit) |
+
+---
+
+## Week 1 β Plumbing + Atlas for Static Scenery + Conformance
+
+Goal of week 1: WB infrastructure wired up behind feature flag. Conformance tests pass. Static scenery routes through `ObjectMeshManager` when flag is on. Everything else still uses old path. **Done when: build green, all conformance tests pass, flag-on Holtburg roam visually identical to flag-off.**
+
+### Task 1: Wb folder skeleton + `WbFoundationFlag`
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs`
+
+- [ ] **Step 1.1: Create the Wb folder by creating the flag file**
+
+```csharp
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Process-lifetime cache of ACDREAM_USE_WB_FOUNDATION env var.
+/// Read once at static-init time; all consumers import this rather than
+/// re-reading the env var per call (env-var lookups on Windows are not
+/// free at hot-path cadence).
+///
+///
+/// Set ACDREAM_USE_WB_FOUNDATION=1 to route static-scenery + atlas
+/// content through WB's ObjectMeshManager; per-instance customized
+/// content (server CreateObject entities) takes the existing
+/// path either
+/// way. Flag becomes default-on at end of Phase N.4 after visual
+/// verification.
+///
+///
+public static class WbFoundationFlag
+{
+ public static bool IsEnabled { get; } =
+ System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
+}
+```
+
+- [ ] **Step 1.2: Build to verify the new folder compiles**
+
+Run: `dotnet build --verbosity quiet`
+Expected: 0 errors. The folder exists implicitly because the file's namespace declares it.
+
+- [ ] **Step 1.3: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): WbFoundationFlag scaffold for ACDREAM_USE_WB_FOUNDATION env var
+
+Creates the src/AcDream.App/Rendering/Wb/ folder and the static flag
+gate that other call sites will import. Read once at static-init time.
+Set ACDREAM_USE_WB_FOUNDATION=1 to enable WB foundation routing.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 2: `AcSurfaceMetadata` + `AcSurfaceMetadataTable`
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs`
+- Create: `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs`
+
+- [ ] **Step 2.1: Write failing test**
+
+Create `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs`:
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AcSurfaceMetadataTableTests
+{
+ [Fact]
+ public void Add_ThenLookup_RoundTripsSameMetadata()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var meta = new AcSurfaceMetadata(
+ Translucency: TranslucencyKind.AlphaBlend,
+ Luminosity: 0.5f,
+ Diffuse: 0.8f,
+ SurfOpacity: 0.7f,
+ NeedsUvRepeat: true,
+ DisableFog: false);
+
+ table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);
+
+ Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
+ Assert.Equal(meta, got);
+ }
+
+ [Fact]
+ public void Lookup_MissingKey_ReturnsFalse()
+ {
+ var table = new AcSurfaceMetadataTable();
+ Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
+ }
+
+ [Fact]
+ public void Add_OverwritesPreviousMetadata()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
+ var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);
+
+ table.Add(0xAAAA, 0, first);
+ table.Add(0xAAAA, 0, second);
+
+ Assert.True(table.TryLookup(0xAAAA, 0, out var got));
+ Assert.Equal(second, got);
+ }
+
+ [Fact]
+ public void Add_FromMultipleThreads_IsThreadSafe()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var threads = new System.Threading.Tasks.Task[8];
+ for (int t = 0; t < 8; t++)
+ {
+ int threadIdx = t;
+ threads[t] = System.Threading.Tasks.Task.Run(() =>
+ {
+ for (int i = 0; i < 1000; i++)
+ {
+ ulong key = (ulong)(threadIdx * 1000 + i);
+ table.Add(key, 0, new AcSurfaceMetadata(
+ TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
+ }
+ });
+ }
+ System.Threading.Tasks.Task.WaitAll(threads);
+
+ // 8000 entries should be present.
+ for (int t = 0; t < 8; t++)
+ for (int i = 0; i < 1000; i++)
+ Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
+ }
+}
+```
+
+- [ ] **Step 2.2: Run test to verify it fails**
+
+Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AcSurfaceMetadataTableTests" --verbosity normal`
+Expected: COMPILE FAIL β types don't exist.
+
+- [ ] **Step 2.3: Create `AcSurfaceMetadata.cs`**
+
+```csharp
+using AcDream.Core.Meshing;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// AC-specific surface render metadata that WB's MeshBatchData
+/// doesn't carry. Computed at mesh-extraction time and looked up by the
+/// draw dispatcher to drive translucency / sky-pass / fog behavior.
+///
+///
+/// All fields mirror those on today's so
+/// behavior is preserved bit-for-bit through the migration.
+///
+///
+public sealed record AcSurfaceMetadata(
+ TranslucencyKind Translucency,
+ float Luminosity,
+ float Diffuse,
+ float SurfOpacity,
+ bool NeedsUvRepeat,
+ bool DisableFog);
+```
+
+- [ ] **Step 2.4: Create `AcSurfaceMetadataTable.cs`**
+
+```csharp
+using System.Collections.Concurrent;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Thread-safe side-table mapping (gfxObjId, surfaceIdx) to
+/// . Populated when a GfxObj's mesh data
+/// is extracted; queried at draw time.
+///
+///
+/// Keyed by (gfxObjId, surfaceIdx) not by WB's runtime batch
+/// identity because batch objects can be evicted and re-loaded by WB's
+/// LRU; the (gfxObj, surface) pair is stable across cycles.
+///
+///
+public sealed class AcSurfaceMetadataTable
+{
+ private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
+
+ public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
+ => _table[(gfxObjId, surfaceIdx)] = meta;
+
+ public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
+ => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
+
+ public void Clear() => _table.Clear();
+}
+```
+
+- [ ] **Step 2.5: Run tests to verify pass**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AcSurfaceMetadataTableTests" --verbosity normal`
+Expected: 4/4 PASS.
+
+- [ ] **Step 2.6: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): AcSurfaceMetadata side-table for WB-pristine surface props
+
+Holds Translucency / Luminosity / Diffuse / SurfOpacity / NeedsUvRepeat /
+DisableFog keyed by (gfxObjId, surfaceIdx). Populated at extraction time,
+queried by the draw dispatcher. ConcurrentDictionary because mesh
+extraction happens on background workers.
+
+No fork patches required β keeps WB's MeshBatchData pristine.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 3: Mesh-extraction conformance test
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs`
+
+This test proves our existing `GfxObjMesh.Build` produces the same vertex + index output as WB's algorithm. Per [GfxObjMesh.cs:24](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs:24), our code is already a faithful port of WB's `BuildPolygonIndices` β this test pins that fact.
+
+- [ ] **Step 3.1: Write the test**
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Meshing;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Conformance: our must produce the same
+/// vertex-array + index-array output as WB's ObjectMeshManager
+/// would for the same input GfxObj. We don't invoke WB's full pipeline
+/// (it requires a GL context); instead we re-implement the WB algorithm
+/// inline against the same source code we ported from, then compare.
+///
+///
+/// If this test fails, either our port has drifted or the WB code has
+/// changed upstream β investigate which, do not "fix" the test.
+///
+///
+public sealed class MeshExtractionConformanceTests
+{
+ [Fact]
+ public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Single(ours);
+ var sub = ours[0];
+ // Quad β 4 vertices, 6 indices (two triangles via fan triangulation).
+ Assert.Equal(4, sub.Vertices.Length);
+ Assert.Equal(6, sub.Indices.Length);
+ // Fan from vertex 0: (0,1,2) and (0,2,3).
+ Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
+ }
+
+ [Fact]
+ public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ // Force the polygon to be double-sided via Stippling.Both.
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.Both;
+ poly.NegSurface = 0; // same surface idx for both sides
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ // One sub-mesh per (surfaceIdx, isNeg) bucket.
+ Assert.Equal(2, ours.Count);
+ // Negative-side bucket has reversed winding.
+ var neg = ours.First(s => s.Indices.SequenceEqual(new uint[] { 2, 1, 0, 3, 2, 0 }));
+ Assert.NotNull(neg);
+ }
+
+ [Fact]
+ public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.None; // no explicit Negative flag
+ poly.SidesType = CullMode.Clockwise; // AC's "double-sided via SidesType" convention
+ poly.NegSurface = 0;
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Equal(2, ours.Count);
+ }
+
+ [Fact]
+ public void Build_NoPosFlag_OnlyEmitsNegSide()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
+ poly.NegSurface = 0;
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Single(ours);
+ }
+
+ [Fact]
+ public void Build_NegUVIndices_AppliedToNegSideVertices()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.Both;
+ poly.NegSurface = 0;
+ // Default UV index 0 maps to UV (0,0); NegUVIndices=[2,2,2,2] should
+ // map to UV (1,1) on the neg side.
+ poly.NegUVIndices = [2, 2, 2, 2];
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ var posSide = ours.First(s => s.Vertices[0].TexCoord == new Vector2(0, 0));
+ var negSide = ours.First(s => s.Vertices[0].TexCoord == new Vector2(1, 1));
+ Assert.NotNull(posSide);
+ Assert.NotNull(negSide);
+ }
+
+ ///
+ /// Build a synthetic 1Γ1 quad GfxObj with UV indices [0,1,2,3] mapping
+ /// to UVs [(0,0), (1,0), (1,1), (0,1)]. Default surface index 0,
+ /// PosSurface=0, NegSurface=0. No Stippling flags (caller may set).
+ ///
+ private static GfxObj MakeUnitQuadGfxObj()
+ {
+ var gfx = new GfxObj { Surfaces = [0u] };
+
+ // Vertices: 4 corners with UV at each corner.
+ gfx.VertexArray = new VertexArray();
+ for (ushort i = 0; i < 4; i++)
+ {
+ var sw = new SwVertex
+ {
+ Origin = new Vector3(i % 2, i / 2, 0),
+ Normal = new Vector3(0, 0, 1),
+ UVs = new System.Collections.Generic.List
+ {
+ new UV { U = i % 2, V = i / 2 },
+ new UV { U = 0.5f, V = 0.5f },
+ new UV { U = 1, V = 1 },
+ },
+ };
+ gfx.VertexArray.Vertices[i] = sw;
+ }
+
+ // One quad polygon with vertex sequence [0,1,2,3] and PosUVIndices [0,0,0,0].
+ var poly = new Polygon
+ {
+ VertexIds = [0, 1, 2, 3],
+ PosUVIndices = [0, 0, 0, 0],
+ NegUVIndices = [],
+ PosSurface = 0,
+ NegSurface = -1,
+ Stippling = StipplingType.None,
+ SidesType = CullMode.Counterclockwise, // single-sided by default
+ };
+ gfx.Polygons[0] = poly;
+ return gfx;
+ }
+}
+```
+
+- [ ] **Step 3.2: Run test to verify pass (since algorithm already ported)**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~MeshExtractionConformanceTests" --verbosity normal`
+Expected: 5/5 PASS. If any test fails, investigate before continuing β it means our port has drifted.
+
+- [ ] **Step 3.3: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs
+git commit -m "$(cat <<'EOF'
+test(N.4): mesh-extraction conformance pinning GfxObjMesh.Build behavior
+
+Five tests covering: simple quad, double-sided via Stippling.Both,
+double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
+NoPos-only emission, and NegUVIndices application to neg-side vertices.
+
+These pin GfxObjMesh.Build's output as the conformance baseline before
+N.4 substitutes it with WB's pipeline.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 4: Setup-flatten conformance test
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs`
+
+- [ ] **Step 4.1: Write the test**
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Meshing;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Conformance: our must produce the same
+/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
+/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride β
+/// Resting β Default β first available) before substitution.
+///
+public sealed class SetupFlattenConformanceTests
+{
+ [Fact]
+ public void Flatten_NoFrames_FallsBackToIdentity()
+ {
+ var setup = new Setup { Parts = [0x01000001ul] };
+
+ var refs = SetupMesh.Flatten(setup);
+
+ Assert.Single(refs);
+ Assert.Equal(0x01000001ul, refs[0].GfxObjId);
+ // No frame β identity transform, identity scale.
+ Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
+ }
+
+ [Fact]
+ public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation()
+ {
+ var setup = new Setup { Parts = [0x01000001ul] };
+ var anim = new AnimationFrame
+ {
+ Frames =
+ [
+ new Frame
+ {
+ Origin = new Vector3(10, 20, 30),
+ Orientation = Quaternion.CreateFromYawPitchRoll(0, 0, 0),
+ },
+ ],
+ };
+ setup.PlacementFrames[Placement.Default] = anim;
+
+ var refs = SetupMesh.Flatten(setup);
+
+ // Translation column should encode Origin.
+ Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation);
+ }
+
+ [Fact]
+ public void Flatten_WithRestingFrame_PrefersRestingOverDefault()
+ {
+ var setup = new Setup { Parts = [0x01000001ul] };
+ setup.PlacementFrames[Placement.Default] = new AnimationFrame
+ {
+ Frames = [new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity }],
+ };
+ setup.PlacementFrames[Placement.Resting] = new AnimationFrame
+ {
+ Frames = [new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity }],
+ };
+
+ var refs = SetupMesh.Flatten(setup);
+
+ // Resting wins.
+ Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation);
+ }
+
+ [Fact]
+ public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting()
+ {
+ var setup = new Setup { Parts = [0x01000001ul] };
+ setup.PlacementFrames[Placement.Resting] = new AnimationFrame
+ {
+ Frames = [new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity }],
+ };
+ var motionOverride = new AnimationFrame
+ {
+ Frames = [new Frame { Origin = new Vector3(7, 7, 7), Orientation = Quaternion.Identity }],
+ };
+
+ var refs = SetupMesh.Flatten(setup, motionFrameOverride: motionOverride);
+
+ Assert.Equal(new Vector3(7, 7, 7), refs[0].PartTransform.Translation);
+ }
+
+ [Fact]
+ public void Flatten_DefaultScalePerPart_AppliedToTransform()
+ {
+ var setup = new Setup
+ {
+ Parts = [0x01000001ul, 0x01000002ul],
+ DefaultScale = [new Vector3(2, 2, 2), new Vector3(3, 3, 3)],
+ };
+
+ var refs = SetupMesh.Flatten(setup);
+
+ // Identity-frame * scale-2 β diagonal matrix with 2s.
+ Assert.Equal(2f, refs[0].PartTransform.M11);
+ Assert.Equal(3f, refs[1].PartTransform.M11);
+ }
+}
+```
+
+- [ ] **Step 4.2: Run test, verify pass**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~SetupFlattenConformanceTests" --verbosity normal`
+Expected: 5/5 PASS.
+
+- [ ] **Step 4.3: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs
+git commit -m "$(cat <<'EOF'
+test(N.4): setup-flatten conformance pinning placement-frame fallback chain
+
+Five tests covering: identity (no frames), Default frame, Resting beats
+Default, motion override beats Resting, DefaultScale per part. Pins
+SetupMesh.Flatten's behavior as the conformance baseline before N.4
+routes it through WB's setup-parts walk.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 5: `WbMeshAdapter` skeleton
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs`
+
+- [ ] **Step 5.1: Write failing test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using DatReaderWriter;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class WbMeshAdapterTests
+{
+ [Fact]
+ public void Construct_WithNullGl_ThrowsArgumentNull()
+ {
+ Assert.Throws(() =>
+ new WbMeshAdapter(gl: null!, dats: NullDats(), logger: NullLogger.Instance));
+ }
+
+ [Fact]
+ public void Dispose_DisposesUnderlyingMeshManager_NoThrow()
+ {
+ // Without a real GL context we can't fully construct ObjectMeshManager,
+ // but we can verify the adapter's Dispose path is safe to invoke when
+ // the manager is null (early-init failure path).
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ adapter.Dispose(); // should not throw
+ }
+
+ private static DatCollection NullDats() => DatCollection.Empty;
+}
+```
+
+(Note: the tests above are minimal. Full coverage of the adapter happens
+once it has real methods to exercise β Tasks 7-8.)
+
+- [ ] **Step 5.2: Run, expect compile fail**
+
+Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbMeshAdapterTests" --verbosity normal`
+Expected: COMPILE FAIL β type doesn't exist, `DatCollection.Empty` may not exist.
+
+- [ ] **Step 5.3: Create the adapter**
+
+Create `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`:
+
+```csharp
+using Chorizite.OpenGLSDLBackend;
+using Chorizite.OpenGLSDLBackend.Lib;
+using DatReaderWriter;
+using Microsoft.Extensions.Logging;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Single seam between acdream and WB's render pipeline. Owns the
+/// instance and exposes a stable acdream-
+/// shaped API ( / /
+/// ) so the rest of the renderer doesn't need to
+/// know about WB's types directly.
+///
+///
+/// Instantiated once at GameWindow init when
+/// is true. When the flag is off,
+/// no instance is constructed and call sites fall through to the legacy
+/// renderer paths.
+///
+///
+public sealed class WbMeshAdapter : System.IDisposable
+{
+ private readonly ObjectMeshManager? _meshManager;
+ private readonly OpenGLGraphicsDevice? _graphicsDevice;
+ private bool _disposed;
+
+ public WbMeshAdapter(GL gl, DatCollection dats, ILogger logger)
+ {
+ System.ArgumentNullException.ThrowIfNull(gl);
+ System.ArgumentNullException.ThrowIfNull(dats);
+ System.ArgumentNullException.ThrowIfNull(logger);
+
+ // OpenGLGraphicsDevice is the host WB's ObjectMeshManager needs.
+ // Constructed once and owned by the adapter for the process lifetime.
+ _graphicsDevice = new OpenGLGraphicsDevice(gl);
+
+ // ObjectMeshManager wants its own ILogger;
+ // we use NullLogger to avoid the wrong category, the adapter's
+ // own logger handles the acdream-side trail.
+ var omLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+ var datsAdapter = new WbDatReaderAdapter(dats); // see Task 6
+ _meshManager = new ObjectMeshManager(_graphicsDevice, datsAdapter, omLogger);
+ }
+
+ private WbMeshAdapter()
+ {
+ // Uninitialized constructor β only for tests that need a Dispose-safe
+ // instance without a real GL context.
+ _meshManager = null;
+ _graphicsDevice = null;
+ }
+
+ internal static WbMeshAdapter CreateUninitialized() => new();
+
+ ///
+ /// Get GPU-side render data for an object id, blocking on background
+ /// preparation if the upload hasn't finished yet. Returns null if the
+ /// object can't be loaded (e.g., dat missing).
+ ///
+ public Chorizite.OpenGLSDLBackend.Lib.ObjectRenderData? GetRenderData(ulong id)
+ => _meshManager?.GetRenderData(id);
+
+ public void IncrementRefCount(ulong id) => _meshManager?.IncrementRefCount(id);
+
+ public void DecrementRefCount(ulong id) => _meshManager?.DecrementRefCount(id);
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _meshManager?.Dispose();
+ _graphicsDevice?.Dispose();
+ }
+}
+```
+
+- [ ] **Step 5.4: Build to expose compilation issues**
+
+Run: `dotnet build --verbosity quiet`
+Expected: Compile errors will reveal what we need:
+- `WbDatReaderAdapter` does not exist yet (Task 6 creates it)
+- `OpenGLGraphicsDevice` constructor signature may differ
+
+For now, comment out the `_graphicsDevice = new OpenGLGraphicsDevice(gl);` and `_meshManager = new ObjectMeshManager(...)` lines and provide a TODO comment marker so the test file compiles. Replace with:
+
+```csharp
+// Real init defers to Task 6 (dat reader adapter) + Task 9 (full bring-up).
+// During Task 5 the adapter is a stub that returns null/no-ops everywhere.
+_graphicsDevice = null;
+_meshManager = null;
+```
+
+- [ ] **Step 5.5: Add `DatCollection.Empty` if it doesn't exist**
+
+If `DatCollection.Empty` is missing, the test won't compile. Check:
+
+Run: `grep -rn "DatCollection.Empty" src/`
+
+If absent, the test should be adjusted to construct an empty `DatCollection` directly (or skipped β the meaningful adapter tests come later). Update the test as needed; the test's only job at this stage is to verify Dispose is safe.
+
+- [ ] **Step 5.6: Run tests**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbMeshAdapterTests" --verbosity normal`
+Expected: 2/2 PASS (with the stub init).
+
+- [ ] **Step 5.7: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): WbMeshAdapter skeleton β single seam to WB ObjectMeshManager
+
+Stub init pending Task 6 (dat reader adapter) + Task 9 (full bring-up).
+Public API: IncrementRefCount / DecrementRefCount / GetRenderData /
+Dispose. Construction-safe (validates args), Dispose-safe (no-op when
+underlying manager is null).
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 6: ~~WbDatReaderAdapter~~ β OBSOLETED 2026-05-08
+
+**Adjustment 1 (2026-05-08):** discovered during pre-Task-6 grep that
+WB ships `WorldBuilder.Shared.Services.DefaultDatReaderWriter`, a
+concrete `IDatReaderWriter` implementation that takes a dat-directory
+path and constructs all four databases (Portal / HighRes / Language +
+CellRegions) internally. We can instantiate it directly with the same
+`%USERPROFILE%\Documents\Asheron's Call` path acdream's `DatCollection`
+uses; both will open the same dat files with separate handles. Memory
+cost: ~50-100 MB of duplicate index caches, acceptable for foundation
+work. Task 9 incorporates the construction step directly.
+
+If memory pressure surfaces during week 2 stress testing, revisit by
+writing a real bridge that shares index caches with our `DatCollection`.
+
+**No work for this task β skip and proceed to Task 7.**
+
+---
+
+### Adjustment 2 (2026-05-08): Task 9 routing reverted β tier decision belongs at spawn-callback layer
+
+**Discovered during Week 1 visual smoke test**: with flag on, characters /
+NPCs disappeared along with static scenery. Root cause: Task 9 routed
+**all** `InstancedMeshRenderer.EnsureUploaded` calls through
+`WbMeshAdapter.IncrementRefCount` and marked their cache entries with
+`WbManagedSentinel`. But `InstancedMeshRenderer` is used for both tiers
+in production:
+
+- **Atlas-tier** call sites: `_pendingCellMeshes` drain
+ ([GameWindow.cs:5137](../../../src/AcDream.App/Rendering/GameWindow.cs:5137)),
+ per-MeshRef GfxObj loop on `lb.Entities`
+ ([:5155](../../../src/AcDream.App/Rendering/GameWindow.cs:5155)).
+- **Per-instance-tier** call sites: per-part loop in spawn handling
+ ([:2302](../../../src/AcDream.App/Rendering/GameWindow.cs:2302)) β this is
+ character / creature rendering driven by server `CreateObject`.
+
+The renderer is **tier-blind by design**: it doesn't know spawn source.
+Putting routing logic there violates separation of concerns. The spec's
+Data-Flow section already specifies the right placement β routing happens
+at the **spawn-callback layer**:
+
+- `LandblockSpawnAdapter.OnLandblockLoaded(...)` (Task 11) calls
+ `IncrementRefCount` per unique GfxObj β atlas-tier only.
+- `EntitySpawnAdapter.OnCreate(entity)` (Task 17) routes through
+ per-instance path (`TextureCache.GetOrUploadWithPaletteOverride`) β
+ never calls `IncrementRefCount` for atlas.
+
+**Resolution:** reverted Task 9's renderer-level routing. Removed the
+sentinel logic and the 4 sentinel-skip checks in
+`InstancedMeshRenderer`. **Kept** the `_wbMeshAdapter` constructor
+parameter (unused for now) so `GameWindow.cs` doesn't shift when
+later tasks need adapter access. Kept all the real WB pipeline
+construction in `WbMeshAdapter` (verified working under flag-off).
+
+**Week 1 endpoint shifts:** "WB infrastructure constructed; flag-on and
+flag-off visually identical." Routing arrives in Week 2 (Task 11) at
+the correct layer. Smoke verification is now: flag-on === flag-off.
+
+---
+
+### Adjustment 3 (2026-05-08): flag-on FPS regression β root-caused, deferred to Task 22
+
+**Discovered during Task 13 stress test** (radius 7, flag-on). Visible
+FPS drop + rising frame latency vs flag-off baseline. Initial guess
+was the staged-upload queue leaking memory; we shipped
+`WbMeshAdapter.Tick()` (commit `bf53cb4`) to drain
+`_meshManager.StagedMeshData` + `_graphicsDevice._glThreadQueue` per
+frame. Result: leak fixed, but **FPS unchanged**.
+
+**Real cause: dual-pipeline cost.** Flag-on runs both rendering
+pipelines in parallel without yet collecting any savings:
+
+1. **Background workers (4-wide).** `ObjectMeshManager` spins up
+ `MaxParallelLoads = 4` worker threads decoding GfxObj polygons,
+ building texture atlases, encoding batches. Contends with the
+ render thread for CPU cores.
+2. **Duplicate GL upload.** `Tick()` calls `UploadMeshData` per
+ staged mesh, creating VAO/VBO/IBO + atlas texture uploads. Real
+ per-call GL state churn on the render thread.
+3. **Duplicate I/O.** `DefaultDatReaderWriter` opens its own dat file
+ handles and rebuilds its own index cache (~50-100 MB) alongside
+ our existing `DatCollection`. Memory bandwidth + GC churn.
+4. **Legacy renderer keeps doing the same work.** Per Adjustment 2,
+ `InstancedMeshRenderer` is tier-blind β it still uploads VAO /
+ VBO / IBO for the same atlas-tier content WB is also building.
+ **We literally double the prep cost** for every atlas-tier GfxObj.
+
+The savings from WB's atlas batching only materialize when **Task 22
+(`WbDrawDispatcher`) lands and the legacy renderer can short-circuit
+its upload for atlas-tier content**. At that point WB owns atlas-tier
+draw and `InstancedMeshRenderer` skips its own upload + draw work
+for those entities. Until then, flag-on pays both costs.
+
+**Decision: do not fix now.** Plan Risk #5 explicitly anticipated this:
+
+> Performance regression during integration of week 1's "atlas for
+> static scenery, old path for everything else" mixed state.
+> Mitigation: keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1`
+> during weeks 1-3; default-off until week 4 visual verification.
+
+Default-off (the user's daily experience) is byte-identical to
+pre-N.4. Flag-on is dev-only until Week 4. Task 22 must wire the
+legacy-renderer short-circuit for atlas-tier content as part of
+landing the dispatcher; the cost cannot be amortized any earlier
+without violating Adjustment 2's tier-blind-renderer principle.
+
+`Tick()` stays β it fixed a real memory leak and is required
+infrastructure for Task 22 anyway. We just paid for it without
+seeing FPS recovery yet.
+
+---
+
+### Adjustment 4 (2026-05-08): WorldEntity lacks HiddenParts + AnimPartChange fields β deferred plumbing
+
+**Discovered during Task 17 implementation.** `EntitySpawnAdapter.OnCreate`
+needed to populate `AnimatedEntityState` with the entity's `HiddenParts`
+mask + `AnimPartChange` override map. But: `WorldEntity` (the per-frame
+render-side struct) does not currently expose either field. Both pieces
+of customization data live on the network-layer spawn record and are
+consumed before the `WorldEntity` is built.
+
+**Resolution.** Task 17 ships the adapter scaffolding with a TODO comment
+acknowledging the gap. The created `AnimatedEntityState` always has an
+empty override map + zero hidden mask. Per-instance customizations like
+"hide this character's head" won't take effect with flag-on until the
+plumbing lands.
+
+**Why this is safe to defer.** No production path consumes
+`AnimatedEntityState`'s override / hidden data yet β Task 22's
+`WbDrawDispatcher` is the first consumer. By the time Task 22 lands, we
+either:
+1. Add `HiddenPartsMask` + `AnimPartChanges` fields to `WorldEntity` and
+ populate them at spawn time. Small change to the network β render
+ pipeline.
+2. Inject them into `EntitySpawnAdapter.OnCreate` via a separate
+ parameter that the spawn handler provides directly (sidesteps the
+ `WorldEntity` change).
+
+Option 1 is cleaner long-term; Option 2 is faster for landing Task 22
+without touching WorldEntity. Decision deferred to Task 22 brainstorm.
+
+### Adjustment 5 (2026-05-08): Task 20 (per-instance decode conformance) is structural, not byte-comparison
+
+**Original plan.** Task 20 was supposed to compare RGBA8 output of
+"old path" (`TextureCache.GetOrUploadWithPaletteOverride` direct) vs
+"new path" (`EntitySpawnAdapter` β `ITextureCachePerInstance` β
+`TextureCache.GetOrUploadWithPaletteOverride`) to prove byte-identity.
+
+**Reality.** Both paths call the **same function**. The new path adds a
+seam interface (`ITextureCachePerInstance`) for testability but does
+not modify the decode logic β the bytes are identical by construction.
+A test asserting byte-equality would be tautological.
+
+**Resolution.** Existing `EntitySpawnAdapterTests` cover the routing
+behavior (does the adapter call the cache with the right args?). The
+decode-byte conformance is structural: same function = same output.
+Mark Task 20 β
structurally; no separate test file.
+
+### Adjustment 6 (2026-05-08): Resolved Adjustment 4 β Option A (fields on WorldEntity)
+
+**Context.** Adjustment 4 deferred the `HiddenPartsMask` + `AnimPartChanges`
+plumbing decision to Task 22. Two options:
+- **A**: add fields to `WorldEntity`, populate at spawn time
+- **B**: thread as separate args into `EntitySpawnAdapter.OnCreate`
+
+**Decision: Option A.** Reasoning:
+1. The data is already computed at spawn time in GameWindow's CreateObject
+ handler β adding two fields is a 4-line change.
+2. Option B would spread network-layer types across the streaming subsystem,
+ violating the same separation-of-concerns principle as Adjustment 2.
+3. The 0xF625 ObjDescEvent (appearance update) replays through the same
+ spawn path, so WorldEntity fields work automatically for hot-swap updates.
+
+**Implementation:**
+- `WorldEntity` gains `PartOverrides: IReadOnlyList` (default
+ empty) and `HiddenPartsMask: ulong` (default 0).
+- `PartOverride(byte PartIndex, uint GfxObjId)` is a lightweight record struct
+ in Core.World that decouples from the network-layer `CreateObject.AnimPartChange`.
+- `EntitySpawnAdapter.OnCreate` now calls `state.HideParts(entity.HiddenPartsMask)`
+ and `state.SetPartOverride(...)` for each override.
+- GameWindow's CreateObject handler builds the `PartOverride[]` from the
+ server-sent `AnimPartChanges` list.
+
+### Adjustment 7 (2026-05-08, Task 26 visual verification): IncrementRefCount doesn't trigger mesh load
+
+**Discovered when** Task 26's first launch showed only terrain β zero entities visible. Diagnostic counters (added the same launch via `ACDREAM_WB_DIAG=1`) showed `entitiesSeen=14M, entitiesDrawn=14M, drawsIssued=0` β every entity was visited but no draws were issued because `TryGetRenderData` returned null for everything.
+
+**Root cause.** WB's `ObjectMeshManager.IncrementRefCount(id)` only bumps a usage counter β it does NOT trigger mesh loading. Loading is fired separately by `PrepareMeshDataAsync(id, isSetup)`, which dispatches to a background worker pool; the result auto-enqueues to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`) which our existing `WbMeshAdapter.Tick()` already drains.
+
+The N.4 plan assumed `IncrementRefCount` was lifecycle-aware (it isn't). `LandblockSpawnAdapter` and the original `EntitySpawnAdapter` both called `IncrementRefCount` and stopped β meshes never loaded.
+
+**Fix** (commit `943652d`):
+- `WbMeshAdapter.IncrementRefCount` now calls `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` on first registration. `isSetup: false` is correct because acdream's MeshRefs already carry expanded per-part GfxObj ids (0x01XXXXXX) β WB's Setup-expansion path is unused.
+- `EntitySpawnAdapter` gained an optional `IWbMeshAdapter` constructor parameter. Per-instance entities (server-spawned characters / NPCs) had been entirely skipped by `LandblockSpawnAdapter` (which filters `ServerGuid != 0`); their GfxObjs now get registered + loaded at `OnCreate` and decremented at `OnRemove`. Includes both `MeshRefs.GfxObjId` AND `PartOverrides.GfxObjId` so weapon/clothing/helmet swaps load too.
+
+**Lesson preserved.** Future cross-session work touching WB: **`IncrementRefCount` is not lifecycle-aware. Call `PrepareMeshDataAsync` to trigger loads.** Documented in CLAUDE.md "WB integration cribs" section.
+
+### Adjustment 8 (2026-05-08, Task 26 visual verification): SurfaceId lives in batch.Key.SurfaceId
+
+**Discovered when** the second Task 26 launch showed `drawsIssued=4.8M/5s` (draws ARE happening) but ZERO entities visible. Inspection of `ResolveTexture` showed it was returning early because `batch.SurfaceId == 0` for every batch.
+
+**Root cause.** WB's `ObjectMeshManager.UploadGfxObjMeshData` (line 1746 of `ObjectMeshManager.cs`) constructs `ObjectRenderBatch` and sets `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct that contains a `SurfaceId` field) but does NOT populate the top-level `ObjectRenderBatch.SurfaceId` property. That property exists on the type but stays at its default 0.
+
+**Fix** (commit `943652d`): `WbDrawDispatcher.ResolveTexture` reads `batch.Key.SurfaceId` instead of `batch.SurfaceId`. Also handles the dummy `0xFFFFFFFF` case used by WB's environment edge wireframes.
+
+**Lesson preserved.** **`ObjectRenderBatch.SurfaceId` is not populated by WB. Read `batch.Key.SurfaceId`.** Documented in CLAUDE.md.
+
+### Adjustment 9 (2026-05-08, Task 26 visual verification): Modern rendering uses one global VAO/VBO/IBO
+
+**Discovered when** the third Task 26 launch finally showed real draws β but as "exploded" character body parts scattered around the world with no scenery. Visual was completely broken even though the GL pipeline was clearly issuing draws and binding textures correctly.
+
+**Root cause.** WB's `ObjectMeshManager` has two rendering paths controlled by `_useModernRendering = HasOpenGL43 && HasBindless`. On any modern GPU (which is everything we target), modern is true and ALL meshes share a single `GlobalMeshBuffer` β one VAO, one VBO, one IBO. Each batch's `IBO` field points to that ONE global IBO; the batch's actual slice is identified by `FirstIndex` (offset into IBO, in indices) and `BaseVertex` (offset into VBO, in vertices). The dispatcher was issuing `glDrawElementsInstanced` with `indices=0` and no base vertex β so every entity drew the same first triangle of the global mesh starting at offset 0. That produced exactly the "exploded parts at scattered positions" symptom.
+
+**Fix** (commit `7b41efc`): switch to `glDrawElementsInstancedBaseVertexBaseInstance`, pass `(void*)(batch.FirstIndex * sizeof(ushort))` as the indices argument, pass `(int)batch.BaseVertex` as base vertex. The grouped-instanced refactor in the same commit additionally uses `BaseInstance` to slice into the shared instance VBO per group.
+
+**Bonus discovery:** because all meshes share one VAO under modern rendering, the dispatcher only needs to bind the VAO ONCE per frame (not per draw). Every draw goes to the same VAO. Significant CPU savings.
+
+**Lesson preserved.** **WB's modern rendering path packs everything into one global VAO/VBO/IBO. Honor `FirstIndex` and `BaseVertex`.** Documented in CLAUDE.md.
+
+### Adjustment 10 (2026-05-08, Task 26 visual verification): AnimatedEntityState overrides clobber Issue #47 close-detail mesh
+
+**Discovered when** Task 26's fourth launch showed scenery + connected characters β but characters were "bulky and missing detail" compared to the legacy renderer. Recognized as a re-occurrence of Issue #47 (resolved 2026-05-06 via `GfxObjDegradeResolver`).
+
+**Root cause.** Adjustment 6 stored AnimPartChanges on `WorldEntity.PartOverrides` using the raw `NewModelId` from the network packet β without applying `GfxObjDegradeResolver`. GameWindow's spawn path correctly resolves base GfxObjs (e.g., upper arm `0x01000055`, 14 verts/17 polys) to their close-detail equivalents (`0x01001795`, 32 verts/60 polys) and bakes the result into `MeshRefs`. But `WbDrawDispatcher` then called `animState.ResolvePartGfxObj(partIdx, meshRefGfxObjId)` which returned the raw (low-detail) override from `PartOverrides`, undoing the degrade.
+
+**Fix** (commit `7b41efc`): the dispatcher trusts `MeshRefs` as the source of truth and does NOT re-apply `animState.ResolvePartGfxObj` at draw time. `AnimatedEntityState` overrides become relevant only for hot-swap appearance updates (0xF625 `ObjDescEvent`) which today rebuild MeshRefs anyway. `IsPartHidden` similarly skipped β `HiddenPartsMask` is never populated by spawn code (legacy renderer also doesn't check it).
+
+**Lesson preserved.** **`MeshRefs` is the source of truth at draw time** β GameWindow's spawn path bakes overrides + degrades into it. Don't re-apply overrides downstream.
+
+### Task 6 (original β kept for history)
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs`
+
+WB's `ObjectMeshManager` constructor takes `IDatReaderWriter` (from `Chorizite.DatReaderWriter`). We use `DatReaderWriter.DatCollection` (vendored as a separate library). The two interfaces are similar but not identical. This task builds the adapter.
+
+- [ ] **Step 6.1: Read WB's `IDatReaderWriter` interface**
+
+Run: `grep -n "interface IDatReaderWriter" references/WorldBuilder/`
+
+Read the interface to identify which methods are actually called by `ObjectMeshManager`. Likely just `Portal.TryGet(id, out T)` and similar accessors.
+
+- [ ] **Step 6.2: Write failing test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class WbDatReaderAdapterTests
+{
+ [Fact]
+ public void Portal_TryGet_DelegatesToUnderlyingDats()
+ {
+ var dats = new DatCollection();
+ // Inject a known surface into the test dat collection.
+ var surface = new Surface { Id = 0x08001234 };
+ dats.Portal.Insert(surface);
+
+ var adapter = new WbDatReaderAdapter(dats);
+
+ Assert.True(adapter.Portal.TryGet(0x08001234, out var got));
+ Assert.Equal(surface.Id, got.Id);
+ }
+
+ [Fact]
+ public void Portal_TryGet_MissingId_ReturnsFalse()
+ {
+ var adapter = new WbDatReaderAdapter(new DatCollection());
+ Assert.False(adapter.Portal.TryGet(0xDEADBEEF, out _));
+ }
+}
+```
+
+- [ ] **Step 6.3: Run, expect compile fail**
+
+Expected: COMPILE FAIL β `WbDatReaderAdapter` doesn't exist.
+
+- [ ] **Step 6.4: Create the adapter**
+
+The exact code depends on WB's `IDatReaderWriter` shape. The pattern is a thin pass-through:
+
+```csharp
+using DatReaderWriter;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Adapter from acdream's (vendored from
+/// upstream DatReaderWriter) to the IDatReaderWriter
+/// interface WB's ObjectMeshManager consumes. Pass-through where
+/// possible; reshapes calls to match WB's expected interface where the
+/// libraries diverge.
+///
+public sealed class WbDatReaderAdapter : Chorizite.DatReaderWriter.IDatReaderWriter
+{
+ private readonly DatCollection _dats;
+
+ public WbDatReaderAdapter(DatCollection dats)
+ {
+ System.ArgumentNullException.ThrowIfNull(dats);
+ _dats = dats;
+ }
+
+ public Chorizite.DatReaderWriter.IDatDatabase Portal => new PortalAdapter(_dats);
+ public Chorizite.DatReaderWriter.IDatDatabase Cell => new CellAdapter(_dats);
+ public Chorizite.DatReaderWriter.IDatDatabase HighRes => new HighResAdapter(_dats);
+ public Chorizite.DatReaderWriter.IDatDatabase Local => new LocalAdapter(_dats);
+
+ private sealed class PortalAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
+ private sealed class CellAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
+ private sealed class HighResAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
+ private sealed class LocalAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
+}
+```
+
+The exact method set depends on `IDatDatabase`. Investigate via `grep` and fill in.
+
+- [ ] **Step 6.5: Adjustment marker**
+
+If `IDatReaderWriter`'s shape is more complex than expected (e.g., async readers, MEMORY-mapped access), add an Adjustment subsection here describing what was discovered and how the adapter changed.
+
+- [ ] **Step 6.6: Run tests**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbDatReaderAdapterTests" --verbosity normal`
+Expected: 2/2 PASS.
+
+- [ ] **Step 6.7: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): WbDatReaderAdapter β bridge DatCollection to WB IDatReaderWriter
+
+Pass-through adapter so WB's ObjectMeshManager can consume our
+DatCollection without us refactoring to Chorizite.DatReaderWriter
+directly. Maintains four sub-database accessors (Portal/Cell/
+HighRes/Local).
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 7: Wire `WbMeshAdapter` into `GameWindow` lifecycle (gated by flag)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+
+- [ ] **Step 7.1: Locate `GameWindow.OnLoad` and the renderer construction**
+
+Run: `grep -n "new TextureCache\|new StaticMeshRenderer\|new InstancedMeshRenderer" src/AcDream.App/Rendering/GameWindow.cs`
+
+Identify where existing renderers are constructed during init.
+
+- [ ] **Step 7.2: Add `WbMeshAdapter` field + construct under flag gate**
+
+Add a private field:
+```csharp
+private WbMeshAdapter? _wbMeshAdapter;
+```
+
+In `OnLoad` (or wherever renderers are constructed), after `_textures` is built:
+```csharp
+if (WbFoundationFlag.IsEnabled)
+{
+ var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+ _wbMeshAdapter = new WbMeshAdapter(gl, _dats, logger);
+ System.Console.WriteLine("[N.4] WbFoundation flag is ENABLED β routing static content through ObjectMeshManager.");
+}
+```
+
+In `OnClose` / `Dispose`:
+```csharp
+_wbMeshAdapter?.Dispose();
+```
+
+- [ ] **Step 7.3: Build to verify no breakage with flag default-off**
+
+Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
+Expected: build green, all 8 + 4 + 5 + 5 + 2 = 24 new tests pass on top of the 883 baseline. (Pre-existing 8 failures unchanged.)
+
+- [ ] **Step 7.4: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): construct WbMeshAdapter in GameWindow under feature flag
+
+Enabled only when ACDREAM_USE_WB_FOUNDATION=1. Dispose paired with
+window shutdown. No call sites use it yet β wiring of TextureCache /
+StaticMeshRenderer / GpuWorldState happens in Tasks 8-10.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 8: CLAUDE.md pointer to this plan
+
+**Files:**
+- Modify: `CLAUDE.md`
+
+- [ ] **Step 8.1: Add a "Currently in flight" pointer near the top**
+
+Edit `CLAUDE.md`. After the "Roadmap discipline" section's intro (around the section that mentions `docs/superpowers/specs/*.md`), insert:
+
+```markdown
+**Currently in flight: Phase N.4 β Rendering Pipeline Foundation.** Plan
+at `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
+This is a 3-4 week phase adopting WB's `ObjectMeshManager` +
+`TextureAtlasManager` as our shared rendering infrastructure. The plan
+is a living document β task checkboxes get marked as commits land,
+adjustments are appended in-place, weeks 2-4 may be revised based on
+week 1 discoveries. Read the plan's "Plan Living-Document Convention"
+section before contributing.
+```
+
+- [ ] **Step 8.2: Commit**
+
+```bash
+git add CLAUDE.md
+git commit -m "$(cat <<'EOF'
+docs: point CLAUDE.md at the in-flight N.4 plan
+
+Future agents picking up the project should see the N.4 plan as
+authoritative for rendering work. Pointer lives near the Roadmap
+discipline section. Living-doc convention noted.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 9: Wire static-scenery path through `WbMeshAdapter`
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs`
+
+This is the first behavioral change: when the flag is on, static-scenery uploads route through `WbMeshAdapter` instead of building VAO/VBO/EBO inline.
+
+- [ ] **Step 9.1: Locate `EnsureUploaded` in `StaticMeshRenderer.cs`**
+
+Currently uploads sub-meshes directly. We're adding a flag-gated alternate path.
+
+- [ ] **Step 9.2: Add adapter reference + flag-gated upload**
+
+Modify `StaticMeshRenderer` to accept an optional `WbMeshAdapter`:
+
+```csharp
+public sealed unsafe class StaticMeshRenderer : IDisposable
+{
+ // ...existing fields...
+ private readonly WbMeshAdapter? _wbMeshAdapter;
+
+ public StaticMeshRenderer(
+ GL gl,
+ Shader shader,
+ TextureCache textures,
+ WbMeshAdapter? wbMeshAdapter = null) // optional injection
+ {
+ _gl = gl;
+ _shader = shader;
+ _textures = textures;
+ _wbMeshAdapter = wbMeshAdapter;
+ }
+
+ public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes)
+ {
+ if (_gpuByGfxObj.ContainsKey(gfxObjId))
+ return;
+
+ if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
+ {
+ // New path: route to ObjectMeshManager. WB will background-prep
+ // and upload; we mark this gfxObj as "WB-managed" in our local
+ // cache via a sentinel entry so the draw loop knows to look
+ // there instead of in _gpuByGfxObj.
+ _wbMeshAdapter.IncrementRefCount(gfxObjId);
+ _gpuByGfxObj[gfxObjId] = WbManagedSentinel;
+ return;
+ }
+
+ // Legacy path: build VAO/VBO/EBO inline.
+ var list = new List(subMeshes.Count);
+ foreach (var sm in subMeshes)
+ list.Add(UploadSubMesh(sm));
+ _gpuByGfxObj[gfxObjId] = list;
+ }
+
+ private static readonly List WbManagedSentinel = new(0);
+}
+```
+
+- [ ] **Step 9.3: Update `Draw` to look up WB-managed entries differently**
+
+In the draw loop, when iterating entities, check the sentinel:
+
+```csharp
+if (object.ReferenceEquals(_gpuByGfxObj[gfxObjId], WbManagedSentinel))
+{
+ // Draw via WbDrawDispatcher β implementation in Task 17.
+ // For week 1 the dispatcher is a stub that no-ops; the entity simply
+ // doesn't render. This is fine for the flag-gated build; week 4's
+ // visual verification is the gate where this must work.
+ continue;
+}
+```
+
+This intentionally leaves a behavioral gap in week 1: with the flag on, static scenery does NOT render correctly. **This is expected.** The full draw path lands in Task 17 (week 4). Week 1's success criterion is "build green, conformance tests pass, no regressions with flag OFF."
+
+- [ ] **Step 9.4: Pass adapter to constructor in `GameWindow`**
+
+In `GameWindow.OnLoad`:
+```csharp
+_staticMeshRenderer = new StaticMeshRenderer(gl, staticShader, _textures, _wbMeshAdapter);
+```
+
+- [ ] **Step 9.5: Build, run all tests, smoke-test with flag off**
+
+Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
+Expected: build green, 883+24 = 907 tests pass, 8 pre-existing failures.
+
+Smoke-test launch with flag off (default) β Holtburg should render identically to before.
+
+- [ ] **Step 9.6: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): route static-scenery uploads through WbMeshAdapter under flag
+
+When ACDREAM_USE_WB_FOUNDATION=1, StaticMeshRenderer.EnsureUploaded
+calls WbMeshAdapter.IncrementRefCount instead of building VAO/VBO/EBO
+inline. Local cache uses a sentinel entry to mark WB-managed gfxObjs.
+
+The draw loop currently skips WB-managed entries (they don't render
+yet). This is expected: the full draw path arrives in Task 17 / week 4.
+Week 1's success is "no regressions with flag OFF."
+
+Default-off β flag must be set explicitly to test the new path.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 10: Week 1 wrap-up β verify clean baseline + commit week 1 status
+
+**Files:** none (verification + status update)
+
+- [ ] **Step 10.1: Full test run + build**
+
+Run: `dotnet build --verbosity quiet 2>&1 | tail -5 && dotnet test --verbosity quiet 2>&1 | tail -5`
+Expected: 0 errors. 907+ tests pass, 8 pre-existing failures only.
+
+- [ ] **Step 10.2: Smoke-test with flag off**
+
+Launch the client with default env (flag OFF). Walk Holtburg briefly. Confirm: no visual change vs pre-N.4 main.
+
+- [ ] **Step 10.3: Smoke-test with flag on (expect partial breakage)**
+
+Launch with `$env:ACDREAM_USE_WB_FOUNDATION = "1"`. Confirm: client launches, scenery is missing or partially missing (expected β WbDrawDispatcher is a stub). Stop the client.
+
+- [ ] **Step 10.4: Update plan checkboxes**
+
+In this plan file, mark Tasks 1-9 as β
with their commit SHAs. Append a "Week 1 status: COMPLETE β date YYYY-MM-DD" note at the start of Week 2.
+
+- [ ] **Step 10.5: Commit**
+
+```bash
+git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
+git commit -m "$(cat <<'EOF'
+docs(N.4): mark week 1 complete in living-doc plan
+
+WB infrastructure wired up behind ACDREAM_USE_WB_FOUNDATION flag.
+Conformance tests pinned (mesh extraction + setup flatten). Static
+scenery routes through WbMeshAdapter when flag is on; rendering
+completion deferred to Task 17 (week 4).
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+## Week 2 β Streaming Integration
+
+Goal of week 2: `LandblockSpawnAdapter` + `LandblockUnloadAdapter` wired through `GpuWorldState`. Memory budget verified under long-roam stress. Pending-spawn list still works. **Done when: `ObjectMeshManager` ref counts balance across landblock load/unload, GPU memory stable on long roam.**
+
+### Task 11: `LandblockSpawnAdapter`
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs`
+
+- [ ] **Step 11.1: Write failing tests**
+
+```csharp
+using System.Linq;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class LandblockSpawnAdapterTests
+{
+ [Fact]
+ public void OnLandblockLoaded_RegistersIncrementForEachUniqueGfxObj()
+ {
+ var adapter = MakeAdapter(out var captured);
+
+ var lb = MakeLandblock(setupIds: [0x02000001ul, 0x02000002ul, 0x02000001ul],
+ staticIds: [0x01000010ul]);
+ adapter.OnLandblockLoaded(0x12340000u, lb);
+
+ // Three unique ids despite duplicate setup id.
+ Assert.Equal(3, captured.IncrementCalls.Count);
+ Assert.Contains(0x02000001ul, captured.IncrementCalls);
+ Assert.Contains(0x02000002ul, captured.IncrementCalls);
+ Assert.Contains(0x01000010ul, captured.IncrementCalls);
+ }
+
+ [Fact]
+ public void OnLandblockUnloaded_RegistersMatchingDecrements()
+ {
+ var adapter = MakeAdapter(out var captured);
+
+ var lb = MakeLandblock(setupIds: [0x02000001ul, 0x02000002ul],
+ staticIds: []);
+ adapter.OnLandblockLoaded(0x12340000u, lb);
+ adapter.OnLandblockUnloaded(0x12340000u);
+
+ Assert.Equal(captured.IncrementCalls.OrderBy(x => x), captured.DecrementCalls.OrderBy(x => x));
+ }
+
+ [Fact]
+ public void OnLandblockUnloaded_UnknownLandblock_NoOp()
+ {
+ var adapter = MakeAdapter(out var captured);
+
+ adapter.OnLandblockUnloaded(0xDEADBEEFu); // never loaded
+
+ Assert.Empty(captured.DecrementCalls);
+ }
+
+ private static LandblockSpawnAdapter MakeAdapter(out CapturingAdapterMock captured)
+ {
+ captured = new CapturingAdapterMock();
+ return new LandblockSpawnAdapter(captured);
+ }
+
+ private sealed class CapturingAdapterMock : IWbMeshAdapter
+ {
+ public List IncrementCalls { get; } = new();
+ public List DecrementCalls { get; } = new();
+ public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
+ public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
+ }
+
+ private static LoadedLandblock MakeLandblock(ulong[] setupIds, ulong[] staticIds)
+ {
+ // Synthetic LoadedLandblock for test; the test only cares about the
+ // unique GfxObj ids reachable from Setups + Statics. Field shape may
+ // need adjustment to match LoadedLandblock's actual constructor.
+ return new LoadedLandblock(
+ setups: setupIds.Select(id => new SetupSpawn(id)).ToList(),
+ statics: staticIds.Select(id => new StaticSpawn(id)).ToList());
+ }
+}
+```
+
+(Note: `IWbMeshAdapter` is a new interface β see Step 11.3. `LoadedLandblock` constructor shape may need adjustment to whatever the codebase uses.)
+
+- [ ] **Step 11.2: Add `IWbMeshAdapter` interface + extract from `WbMeshAdapter`**
+
+Modify `WbMeshAdapter.cs` to implement an interface so the adapter can be mocked:
+
+```csharp
+public interface IWbMeshAdapter
+{
+ void IncrementRefCount(ulong id);
+ void DecrementRefCount(ulong id);
+}
+
+public sealed class WbMeshAdapter : System.IDisposable, IWbMeshAdapter
+{
+ // ...existing impl...
+}
+```
+
+- [ ] **Step 11.3: Create `LandblockSpawnAdapter`**
+
+```csharp
+using System.Collections.Generic;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Bridges landblock streaming events to 's
+/// reference-count lifecycle. Walks LoadedLandblock.Setups and
+/// LoadedLandblock.Statics for unique GfxObj/Setup ids; calls
+/// IncrementRefCount on load and matching DecrementRefCount
+/// on unload.
+///
+///
+/// Maintains a Dictionary<landblockId, HashSet<ulong>>
+/// snapshot of which ids each landblock holds, so unload can match the
+/// load 1:1 without re-walking the (now-released) landblock data.
+///
+///
+public sealed class LandblockSpawnAdapter
+{
+ private readonly IWbMeshAdapter _adapter;
+ private readonly Dictionary> _idsByLandblock = new();
+
+ public LandblockSpawnAdapter(IWbMeshAdapter adapter)
+ {
+ System.ArgumentNullException.ThrowIfNull(adapter);
+ _adapter = adapter;
+ }
+
+ public void OnLandblockLoaded(uint landblockId, LoadedLandblock lb)
+ {
+ var unique = new HashSet();
+ foreach (var setup in lb.Setups) unique.Add(setup.GfxObjId);
+ foreach (var stat in lb.Statics) unique.Add(stat.GfxObjId);
+
+ _idsByLandblock[landblockId] = unique;
+ foreach (var id in unique) _adapter.IncrementRefCount(id);
+ }
+
+ public void OnLandblockUnloaded(uint landblockId)
+ {
+ if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
+ foreach (var id in unique) _adapter.DecrementRefCount(id);
+ _idsByLandblock.Remove(landblockId);
+ }
+}
+```
+
+- [ ] **Step 11.4: Run tests, verify pass**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~LandblockSpawnAdapter" --verbosity normal`
+Expected: 3/3 PASS.
+
+- [ ] **Step 11.5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): LandblockSpawnAdapter bridges streaming to WB ref counts
+
+OnLandblockLoaded walks Setups + Statics for unique GfxObj ids and
+calls IncrementRefCount per id. OnLandblockUnloaded matches with
+DecrementRefCount. Per-landblock id-set snapshot ensures unload pairs
+1:1 with load even when underlying data is released.
+
+IWbMeshAdapter interface extracted from WbMeshAdapter to enable
+mocking in tests.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 12: Wire `LandblockSpawnAdapter` into `GpuWorldState`
+
+**Files:**
+- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+
+- [ ] **Step 12.1: Add adapter field + flag-gated calls in `GpuWorldState`**
+
+Modify `GpuWorldState`'s `AddLandblock` and `RemoveLandblock`:
+
+```csharp
+private readonly Wb.LandblockSpawnAdapter? _wbSpawnAdapter;
+
+public GpuWorldState(Wb.LandblockSpawnAdapter? wbSpawnAdapter = null)
+{
+ _wbSpawnAdapter = wbSpawnAdapter;
+}
+
+public void AddLandblock(LoadedLandblock landblock)
+{
+ // ...existing logic...
+ if (Wb.WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockLoaded(landblock.LandblockId, landblock);
+}
+
+public void RemoveLandblock(uint landblockId)
+{
+ if (Wb.WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockUnloaded(landblockId);
+ // ...existing logic...
+}
+```
+
+- [ ] **Step 12.2: Construct adapter in `GameWindow`**
+
+In `GameWindow.OnLoad`:
+```csharp
+LandblockSpawnAdapter? wbSpawnAdapter = null;
+if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
+ wbSpawnAdapter = new LandblockSpawnAdapter(_wbMeshAdapter);
+_gpuWorldState = new GpuWorldState(wbSpawnAdapter);
+```
+
+- [ ] **Step 12.3: Build + tests + smoke-test flag off**
+
+Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
+Expected: build green, all tests pass.
+
+Smoke-test with flag off: verify no regressions.
+
+- [ ] **Step 12.4: Commit**
+
+```bash
+git add src/AcDream.App/Streaming/GpuWorldState.cs src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): wire LandblockSpawnAdapter through GpuWorldState
+
+AddLandblock/RemoveLandblock now drive WB ref counts when flag is on.
+Pending-spawn list mechanism untouched β adapter is invoked only when
+a landblock fully loads (drains pending), not when a spawn parks.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 13: Memory budget + LRU verification under stress
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MemoryBudgetTests.cs` (optional β likely manual verification)
+
+- [ ] **Step 13.1: Manual stress test plan**
+
+This is a verification task, not an implementation task. Document the plan:
+
+1. Launch with `ACDREAM_USE_WB_FOUNDATION=1` and `ACDREAM_STREAM_RADIUS=7`.
+2. Walk in a straight line for ~5 minutes (covers 50+ landblocks in/out of radius).
+3. Monitor GPU memory in window title bar.
+4. Acceptance: GPU memory grows to ~steady-state value (depending on hardware, somewhere under 1 GB) and stays there. If it grows unboundedly, LRU eviction isn't firing.
+
+Run with: `$env:ACDREAM_USE_WB_FOUNDATION = "1"; $env:ACDREAM_STREAM_RADIUS = "7"; dotnet run --project src\AcDream.App\AcDream.App.csproj 2>&1 | Tee-Object -FilePath "n4-stress.log"`
+
+- [ ] **Step 13.2: Run with the user**
+
+Hand off to user. User walks for 5+ minutes. User reports observed peak memory + final stable memory.
+
+- [ ] **Step 13.3: Document outcome**
+
+If memory is stable: append "Memory budget verified at peak / steady" to this task.
+
+If memory grows unboundedly: investigate. Likely causes:
+- Adapter fails to call `DecrementRefCount` in some path (check for unload logging).
+- WB's LRU eviction interacts badly with our streaming radius hysteresis.
+- Memory budget set too high for the test hardware.
+
+Do not commit a "fix" until root cause is understood. Add an Adjustment subsection here documenting what was found.
+
+- [ ] **Step 13.4: Commit (verification-only commit if memory was clean)**
+
+```bash
+# No code changes; just an empty commit to mark verification complete in history.
+git commit --allow-empty -m "$(cat <<'EOF'
+verify(N.4): memory budget + LRU eviction stable under 5min/r=7 roam
+
+GPU memory peak: . Steady-state: . Eviction
+fires correctly on landblock unload. LandblockSpawnAdapter ref-count
+balance verified through repeated traversal.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 14: Pending-spawn list integration verification
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs`
+
+The pending-spawn list ([feedback_phase_a1_hotfix_saga.md](memory/feedback_phase_a1_hotfix_saga.md)) parks `CreateObject` events that arrive before their landblock streams in. We need to verify this still works with the WB foundation.
+
+- [ ] **Step 14.1: Write integration test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class PendingSpawnIntegrationTests
+{
+ [Fact]
+ public void LiveEntity_BeforeLandblock_Pends_ThenDrains_OnLoad()
+ {
+ var captured = new CapturingAdapterMock();
+ var spawnAdapter = new LandblockSpawnAdapter(captured);
+ var state = new GpuWorldState(spawnAdapter);
+
+ // Live entity for landblock 0x12340000 arrives first.
+ var entity = new WorldEntity { /* with LandblockId = 0x12340000 */ };
+ state.AppendLiveEntity(entity);
+
+ Assert.Equal(1, state.PendingLiveEntityCount);
+ Assert.Empty(captured.IncrementCalls); // not registered yet
+
+ // Now landblock arrives.
+ var lb = new LoadedLandblock(/* ... */);
+ state.AddLandblock(lb);
+
+ // Pending entity drains; adapter sees landblock-side increments.
+ Assert.True(captured.IncrementCalls.Count > 0);
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ }
+}
+```
+
+(Test-fixture details depend on `WorldEntity` and `LoadedLandblock` constructors.)
+
+- [ ] **Step 14.2: Run, verify pass**
+
+If the test fails, the pending-spawn list path doesn't drain through the adapter. Either fix the adapter wiring in `GpuWorldState.AddLandblock` to handle pending entities, or document an Adjustment.
+
+- [ ] **Step 14.3: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
+git commit -m "$(cat <<'EOF'
+test(N.4): pending-spawn list still drains correctly with WB adapter
+
+Verifies CreateObject-before-landblock parks β drains on landblock
+arrival. Adapter sees ref-count increments only after drain.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 15: Week 2 wrap-up
+
+- [ ] **Step 15.1: Full test suite + roam**
+
+Run all tests. Roam at radius 7 with flag on for 5 minutes. Confirm: stable memory, no crashes, ref counts balance.
+
+- [ ] **Step 15.2: Update plan checkboxes**
+
+Mark Tasks 11-14 β
with commit SHAs. Append "Week 2 status: COMPLETE β date YYYY-MM-DD" at start of Week 3.
+
+- [ ] **Step 15.3: Commit plan update**
+
+```bash
+git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
+git commit -m "docs(N.4): mark week 2 complete
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Week 3 β Per-instance + Animation
+
+Goal: per-instance customization works correctly. Animated creatures render with their server-sent overrides applied. AnimPartChange + HiddenParts honored. **Done when: drudge / chicken / banderling render with correct customizations under flag on.**
+
+### Task 16: `AnimatedEntityState` type
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs`
+
+- [ ] **Step 16.1: Write failing test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Animation;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AnimatedEntityStateTests
+{
+ [Fact]
+ public void DefaultState_HasNoOverridesAndNoHiddenParts()
+ {
+ var state = new AnimatedEntityState(MakeSequencer());
+
+ Assert.False(state.IsPartHidden(0));
+ Assert.False(state.IsPartHidden(63));
+ Assert.False(state.TryGetPartOverride(0, out _));
+ }
+
+ [Fact]
+ public void SetHiddenPart_BitmaskIsApplied()
+ {
+ var state = new AnimatedEntityState(MakeSequencer());
+
+ state.HideParts(hiddenMask: 0b1010);
+
+ Assert.False(state.IsPartHidden(0));
+ Assert.True(state.IsPartHidden(1));
+ Assert.False(state.IsPartHidden(2));
+ Assert.True(state.IsPartHidden(3));
+ }
+
+ [Fact]
+ public void SetPartOverride_ResolvedAtLookup()
+ {
+ var state = new AnimatedEntityState(MakeSequencer());
+
+ state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul);
+
+ Assert.True(state.TryGetPartOverride(5, out var got));
+ Assert.Equal(0x01001234ul, got);
+ Assert.False(state.TryGetPartOverride(6, out _));
+ }
+
+ private static AnimationSequencer MakeSequencer() => new(); // adjust to constructor
+}
+```
+
+- [ ] **Step 16.2: Create the type**
+
+```csharp
+using System.Collections.Generic;
+using AcDream.Core.Animation;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Per-entity render state for animated entities. Lives outside WB's
+/// mesh cache because it varies per instance (AnimPartChange override
+/// map, HiddenParts mask) and per frame (animation transforms produced
+/// by the sequencer).
+///
+///
+/// Instances are created by EntitySpawnAdapter.OnCreate and
+/// disposed by EntitySpawnAdapter.OnRemove.
+///
+///
+public sealed class AnimatedEntityState
+{
+ private readonly Dictionary _partGfxObjOverrides = new();
+ private ulong _hiddenMask = 0;
+ public AnimationSequencer Sequencer { get; }
+
+ public AnimatedEntityState(AnimationSequencer sequencer)
+ {
+ System.ArgumentNullException.ThrowIfNull(sequencer);
+ Sequencer = sequencer;
+ }
+
+ public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
+
+ public bool IsPartHidden(int partIdx)
+ {
+ if (partIdx < 0 || partIdx >= 64) return false;
+ return (_hiddenMask & (1ul << partIdx)) != 0;
+ }
+
+ public void SetPartOverride(int partIdx, ulong gfxObjId)
+ => _partGfxObjOverrides[partIdx] = gfxObjId;
+
+ public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
+ => _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
+}
+```
+
+- [ ] **Step 16.3: Run, verify pass**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AnimatedEntityStateTests" --verbosity normal`
+Expected: 3/3 PASS.
+
+- [ ] **Step 16.4: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): AnimatedEntityState β per-entity render state
+
+Holds AnimPartChange override map + HiddenParts bitmask + reference
+to existing AnimationSequencer. Lives outside WB's mesh cache.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 17: `EntitySpawnAdapter` β route CreateObject to per-instance path
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs`
+- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
+
+- [ ] **Step 17.1: Write failing test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class EntitySpawnAdapterTests
+{
+ [Fact]
+ public void OnCreate_WithPaletteOverride_RoutesToPerInstanceCache()
+ {
+ var captured = new CapturingTextureCacheMock();
+ var adapter = new EntitySpawnAdapter(captured);
+
+ var entity = new WorldEntity
+ {
+ // Set up with non-trivial PaletteOverride so we can verify routing.
+ ObjDescBuilder = new ObjDescBuilder { PaletteOverride = MakeOverride() },
+ };
+
+ adapter.OnCreate(entity);
+
+ Assert.True(captured.PaletteOverrideCalled);
+ }
+
+ [Fact]
+ public void OnCreate_WithoutCustomization_StillRegistersForCleanup()
+ {
+ var captured = new CapturingTextureCacheMock();
+ var adapter = new EntitySpawnAdapter(captured);
+
+ adapter.OnCreate(new WorldEntity());
+ adapter.OnRemove(0xDEADBEEFu); // doesn't crash on unknown id
+
+ // Adapter tracks created entities for OnRemove cleanup.
+ Assert.True(true); // smoke
+ }
+}
+```
+
+- [ ] **Step 17.2: Create adapter**
+
+```csharp
+using System.Collections.Generic;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Routes network-spawned CreateObject entities through the per-
+/// instance rendering path. Every entity sent by the server carries
+/// per-instance customization (palette overrides, texture changes,
+/// part swaps), so they bypass WB's atlas and use the existing
+/// path that
+/// already hash-keys overrides for caching.
+///
+public sealed class EntitySpawnAdapter
+{
+ private readonly TextureCache _textures;
+ private readonly Dictionary _stateByGuid = new();
+
+ public EntitySpawnAdapter(TextureCache textures)
+ {
+ System.ArgumentNullException.ThrowIfNull(textures);
+ _textures = textures;
+ }
+
+ public AnimatedEntityState? OnCreate(WorldEntity entity)
+ {
+ // Build palette override from entity's ObjDesc.SubPalettes (if any).
+ var palette = entity.PaletteOverride;
+ // For each surface in the entity's mesh chain, decode through
+ // the per-instance path. The TextureCache already hash-keys the
+ // override, so identical customizations across multiple entities
+ // share the cached texture.
+ if (palette is not null && palette.SubPalettes.Count > 0)
+ {
+ foreach (var surfaceId in entity.SurfaceIds)
+ _textures.GetOrUploadWithPaletteOverride(surfaceId, null, palette);
+ }
+
+ var state = new AnimatedEntityState(entity.AnimationSequencer);
+
+ // Apply HiddenParts mask if set on the entity.
+ if (entity.HiddenPartsMask != 0)
+ state.HideParts(entity.HiddenPartsMask);
+
+ // Apply AnimPartChange overrides if any.
+ foreach (var (partIdx, gfxObjId) in entity.AnimPartChanges)
+ state.SetPartOverride(partIdx, gfxObjId);
+
+ _stateByGuid[entity.Guid] = state;
+ return state;
+ }
+
+ public void OnRemove(uint guid) => _stateByGuid.Remove(guid);
+
+ public AnimatedEntityState? GetState(uint guid)
+ => _stateByGuid.TryGetValue(guid, out var s) ? s : null;
+}
+```
+
+(Note: exact field names like `entity.PaletteOverride`, `entity.SurfaceIds`, `entity.HiddenPartsMask`, `entity.AnimPartChanges` depend on `WorldEntity`'s actual API β adjust at implementation time. If any are missing today, the adapter exposes the gap and we plan a follow-on commit to surface them.)
+
+- [ ] **Step 17.3: Wire into `GpuWorldState`**
+
+In `GpuWorldState.AppendLiveEntity`, when flag is on:
+```csharp
+if (Wb.WbFoundationFlag.IsEnabled && _wbEntitySpawnAdapter is not null)
+ _wbEntitySpawnAdapter.OnCreate(entity);
+```
+
+- [ ] **Step 17.4: Run tests, verify**
+
+Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~EntitySpawnAdapter" --verbosity normal`
+
+- [ ] **Step 17.5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs src/AcDream.App/Streaming/GpuWorldState.cs tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): EntitySpawnAdapter routes CreateObject to per-instance path
+
+Network-spawned entities bypass WB's atlas and use existing
+TextureCache.GetOrUploadWithPaletteOverride which already hash-keys
+customizations. AnimatedEntityState is constructed per-entity with
+HiddenParts mask + AnimPartChange overrides applied at spawn time.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 18: AnimPartChange resolution unit tests
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs`
+
+- [ ] **Step 18.1: Write tests for the resolution logic**
+
+The logic that picks "use override gfxObjId or fall back to default" lives in `WbDrawDispatcher` (Task 21). For now, add a small helper method on `AnimatedEntityState` that does the resolution, and test it directly:
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Animation;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AnimPartChangeTests
+{
+ [Fact]
+ public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault()
+ {
+ var state = new AnimatedEntityState(new AnimationSequencer());
+ ulong setupDefault = 0x01000001ul;
+ Assert.Equal(setupDefault, state.ResolvePartGfxObj(partIdx: 0, setupDefault));
+ }
+
+ [Fact]
+ public void ResolvePartGfxObj_WithOverride_ReturnsOverride()
+ {
+ var state = new AnimatedEntityState(new AnimationSequencer());
+ state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul);
+
+ Assert.Equal(0x01999999ul, state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
+ }
+}
+```
+
+- [ ] **Step 18.2: Add `ResolvePartGfxObj` method on `AnimatedEntityState`**
+
+```csharp
+public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
+ => TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
+```
+
+- [ ] **Step 18.3: Run, commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): AnimPartChange resolution helper on AnimatedEntityState
+
+ResolvePartGfxObj(partIdx, setupDefault) returns override if set,
+else the Setup's part default. Tested standalone; consumed by
+WbDrawDispatcher in Task 21.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 19: HiddenParts mask suppression unit tests
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs`
+
+- [ ] **Step 19.1: Write the test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Animation;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class HiddenPartsTests
+{
+ [Theory]
+ [InlineData(0b0000_0000ul, 0, false)]
+ [InlineData(0b0000_0001ul, 0, true)]
+ [InlineData(0b1000_0000ul, 7, true)]
+ [InlineData(0b1000_0000ul, 6, false)]
+ [InlineData(0xFFFF_FFFF_FFFF_FFFFul, 63, true)]
+ public void IsPartHidden_RespectsBitmaskBit(ulong mask, int partIdx, bool expected)
+ {
+ var state = new AnimatedEntityState(new AnimationSequencer());
+ state.HideParts(mask);
+ Assert.Equal(expected, state.IsPartHidden(partIdx));
+ }
+
+ [Fact]
+ public void IsPartHidden_NegativeIdx_ReturnsFalse()
+ {
+ var state = new AnimatedEntityState(new AnimationSequencer());
+ state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
+ Assert.False(state.IsPartHidden(-1));
+ }
+
+ [Fact]
+ public void IsPartHidden_PartIdxOver64_ReturnsFalse()
+ {
+ var state = new AnimatedEntityState(new AnimationSequencer());
+ state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
+ Assert.False(state.IsPartHidden(64));
+ }
+}
+```
+
+- [ ] **Step 19.2: Run, commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
+git commit -m "$(cat <<'EOF'
+test(N.4): HiddenParts mask suppression edge cases
+
+Theory cases for bitmask resolution + bounds checking. Pins
+the per-bit semantics consumed by WbDrawDispatcher.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 20: Per-instance decode conformance test
+
+**Files:**
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs`
+
+- [ ] **Step 20.1: Write the conformance test**
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Textures;
+using AcDream.Core.World;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class PerInstanceDecodeConformanceTests
+{
+ ///
+ /// The new EntitySpawnAdapter routes CreateObject through TextureCache.
+ /// GetOrUploadWithPaletteOverride. This test pins that routing β given
+ /// the same surface id + palette override, both paths must produce
+ /// byte-identical RGBA8.
+ ///
+ [Fact]
+ public void NewPath_AndOldTextureCachePath_ProduceIdenticalRgba()
+ {
+ // Build a small synthetic dat with: 1 Surface, 1 SurfaceTexture,
+ // 1 RenderSurface (PFID_INDEX16, 4Γ4), 2 Palettes (base + sub).
+ var dats = BuildSyntheticDats();
+ var paletteOverride = new PaletteOverride(
+ BasePaletteId: 0x04000001u,
+ SubPalettes: [new(0x04000002u, Offset: 0, Length: 16)]);
+
+ // Old path
+ using var glStub = new GLStub();
+ var cacheOld = new TextureCache(glStub.GL, dats);
+ var oldHandle = cacheOld.GetOrUploadWithPaletteOverride(
+ surfaceId: 0x08000001u, null, paletteOverride);
+ var oldBytes = glStub.ReadBackTexture(oldHandle);
+
+ // New path (through EntitySpawnAdapter)
+ var entity = new WorldEntity { Guid = 0xCAFE, PaletteOverride = paletteOverride, SurfaceIds = [0x08000001u] };
+ var cacheNew = new TextureCache(glStub.GL, dats);
+ var adapter = new EntitySpawnAdapter(cacheNew);
+ adapter.OnCreate(entity);
+ // The adapter calls the same method internally; we just verify
+ // the bytes match by re-decoding via the cache directly.
+ var newHandle = cacheNew.GetOrUploadWithPaletteOverride(
+ surfaceId: 0x08000001u, null, paletteOverride);
+ var newBytes = glStub.ReadBackTexture(newHandle);
+
+ Assert.Equal(oldBytes, newBytes);
+ }
+}
+```
+
+(`GLStub` is a test fixture that stands in for a real GL context. If the test infrastructure doesn't have one yet, this test may need to be deferred to integration-time; document an Adjustment if so.)
+
+- [ ] **Step 20.2: Run, verify**
+
+If GLStub exists: run and expect PASS.
+If not: replace with a smaller test that compares the decode output directly (without GL), reusing the conformance pattern from N.3.
+
+- [ ] **Step 20.3: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs
+git commit -m "$(cat <<'EOF'
+test(N.4): per-instance decode conformance β old vs new RGBA8
+
+Verifies EntitySpawnAdapter's per-instance path produces byte-identical
+RGBA8 to today's TextureCache.GetOrUploadWithPaletteOverride. Pins
+the decode behavior across the substitution.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 21: Week 3 wrap-up
+
+- [ ] **Step 21.1: Mark week 3 tasks β
, run all tests, commit plan update**
+
+Same pattern as Week 1/2 wrap-ups.
+
+---
+
+## Week 4 β Polish + Visual Verification + Ship
+
+Goal: complete `WbDrawDispatcher` so flag-on rendering produces visible output. Side-table populated correctly. Sky pass preserved. Visual verification at 5 named locations. Flag default-on. Phase shipped.
+
+### Task 22: `WbDrawDispatcher` β full draw loop
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs`
+
+This is the largest task. It implements both atlas-tier and per-instance-tier draw paths with proper matrix composition.
+
+- [ ] **Step 22.1: Write matrix composition test**
+
+```csharp
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class MatrixCompositionTests
+{
+ [Fact]
+ public void Compose_EntityAnimRest_ProducesExpectedWorldMatrix()
+ {
+ var entityWorld = Matrix4x4.CreateTranslation(100, 200, 300);
+ var animOverride = Matrix4x4.CreateRotationZ(MathF.PI / 4); // 45Β° yaw
+ var restPose = Matrix4x4.CreateTranslation(1, 0, 0);
+
+ var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose);
+
+ // Expected: rest first β animated rotation β entity world translate.
+ var expected = restPose * animOverride * entityWorld;
+ Assert.Equal(expected, result);
+ }
+}
+```
+
+- [ ] **Step 22.2: Create `WbDrawDispatcher` skeleton with the static helper**
+
+```csharp
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Rendering.Wb;
+
+public sealed class WbDrawDispatcher
+{
+ public static Matrix4x4 ComposePartWorldMatrix(
+ Matrix4x4 entityWorld,
+ Matrix4x4 animOverride,
+ Matrix4x4 restPose)
+ => restPose * animOverride * entityWorld;
+
+ // Full Draw() comes in Step 22.3.
+}
+```
+
+- [ ] **Step 22.3: Implement full draw loop**
+
+The full draw loop is too large to spell out here in code; it's a structured port of today's `StaticMeshRenderer.Draw` and the per-instance entity rendering, layered on top of WB's `ObjectRenderData`. Implementation guidance:
+
+1. Walk visible atlas-tier entities (those whose gfxObjId is registered in `WbMeshAdapter`):
+ - Get `ObjectRenderData` from the adapter.
+ - For each batch in `renderData.Batches`: bind atlas + shader + uniforms; for each part in `renderData.SetupParts`: compose world matrix (no animation, identity for static), push uniform, draw.
+2. Walk visible per-instance-tier entities (animated):
+ - Get `AnimatedEntityState` from `EntitySpawnAdapter`.
+ - For each part: skip if hidden; resolve gfxObjId via override or default; get `ObjectRenderData`; look up `AcSurfaceMetadata` from the side-table; compose matrix (entity Γ animation Γ rest pose); bind per-instance texture from `TextureCache`; push uniforms (world, lum, diff, fog flag); draw.
+
+Reference: today's `StaticMeshRenderer.Draw` lines 79+ for the existing pattern. Match its frustum-cull behavior and pass structure (opaque + ClipMap, then translucent).
+
+- [ ] **Step 22.4: Replace the "skip WB-managed entries" stub from Task 9**
+
+In `StaticMeshRenderer.Draw`, replace the `if (sentinel) continue` with a call into `WbDrawDispatcher.Draw` for the entity. Or invert: have `GameWindow` call `WbDrawDispatcher.Draw` directly for atlas-tier, and `StaticMeshRenderer.Draw` only handles legacy entries.
+
+- [ ] **Step 22.5: Run tests, smoke-test with flag on**
+
+Run all tests. Then launch with flag on. Holtburg should now render scenery + buildings (atlas tier) AND characters (per-instance tier). Compare side-by-side to flag-off baseline.
+
+- [ ] **Step 22.6: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/StaticMeshRenderer.cs tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): WbDrawDispatcher β full atlas-tier + per-instance draw
+
+Atlas tier: walks visible entities, gets ObjectRenderData from
+WbMeshAdapter, draws each batch through the atlas. Per-instance tier:
+walks animated entities, resolves AnimPartChange overrides, skips
+HiddenParts, composes per-part world matrices (entity Γ animation Γ
+rest pose), looks up AcSurfaceMetadata from the side-table, pushes
+sky-pass-relevant uniforms (Luminosity / Diffuse / DisableFog),
+binds per-instance textures.
+
+Replaces the Task-9 sentinel stub. With flag on, Holtburg now renders.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 23: Surface metadata side-table population
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` (populate side-table on each `IncrementRefCount`)
+
+- [ ] **Step 23.1: Hook population into the adapter**
+
+When `WbMeshAdapter.IncrementRefCount(id)` is called for the first time on an id, walk the resulting mesh data and populate `AcSurfaceMetadataTable` with one entry per (gfxObjId, surfaceIdx) using `GfxObjMesh.Build`'s metadata as the source of truth (since we're keeping that algorithm as the conformance reference).
+
+```csharp
+public void IncrementRefCount(ulong id)
+{
+ if (_meshManager is null) return;
+ _meshManager.IncrementRefCount(id);
+
+ // Populate side-table on first registration.
+ if (!_metadataPopulated.Add(id)) return;
+ PopulateMetadata(id);
+}
+
+private readonly HashSet _metadataPopulated = new();
+
+private void PopulateMetadata(ulong id)
+{
+ // Look up the GfxObj from the dat, run GfxObjMesh.Build with our DatCollection,
+ // and write each sub-mesh's metadata into _metadataTable.
+ if (!_dats.Portal.TryGet((uint)id, out var gfxObj)) return;
+ var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfxObj, _dats);
+ for (int i = 0; i < subMeshes.Count; i++)
+ {
+ var sm = subMeshes[i];
+ _metadataTable.Add(id, i, new AcSurfaceMetadata(
+ sm.Translucency, sm.Luminosity, sm.Diffuse,
+ sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
+ }
+}
+```
+
+(`_dats` and `_metadataTable` need to be added as fields. `AcSurfaceMetadataTable` injected in constructor.)
+
+- [ ] **Step 23.2: Add round-trip test**
+
+```csharp
+[Fact]
+public void IncrementRefCount_PopulatesSideTableMetadata()
+{
+ var (adapter, table) = MakeAdapterWithDat();
+ adapter.IncrementRefCount(0x01000123ul);
+
+ Assert.True(table.TryLookup(0x01000123ul, 0, out var meta));
+ Assert.Equal(TranslucencyKind.Opaque, meta.Translucency);
+}
+```
+
+- [ ] **Step 23.3: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): populate AcSurfaceMetadata side-table on first ref-count
+
+When a gfxObj is registered for the first time, WbMeshAdapter walks
+its sub-meshes via GfxObjMesh.Build and writes per-surface metadata
+into the side-table keyed by (gfxObjId, surfaceIdx). Subsequent
+draws resolve metadata via O(1) lookup.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 24: Sky-pass preservation check
+
+**Files:**
+- (Verification β likely no code changes if side-table flow is right.)
+
+- [ ] **Step 24.1: Examine SkyRenderer's metadata consumption**
+
+Grep for `NeedsUvRepeat` / `DisableFog` / `Luminosity` usage in the sky renderer. Verify that under the WB foundation, these values still flow correctly.
+
+Run: `grep -n "NeedsUvRepeat\|DisableFog\|Luminosity" src/AcDream.App/Rendering/Sky/`
+
+- [ ] **Step 24.2: Smoke-test sky rendering with flag on**
+
+Launch with `ACDREAM_USE_WB_FOUNDATION=1`. Press F7 / F10 to cycle day/night and weather. Visually confirm: clouds blend correctly, sun is bright (luminous), fog respects emissive surfaces.
+
+- [ ] **Step 24.3: If broken, fix and commit; else, commit verification**
+
+If the sky pass renders identically: empty commit marking verification complete.
+
+If broken: investigate. Document an Adjustment under this task. The side-table flow is the most likely failure point.
+
+```bash
+git commit --allow-empty -m "$(cat <<'EOF'
+verify(N.4): sky pass renders identically under WB foundation
+
+NeedsUvRepeat / DisableFog / Luminosity metadata flows through the
+side-table to SkyRenderer correctly. Day/night cycle + weather
+visually identical to flag-off baseline.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 25: Component micro-tests round-out
+
+**Files:**
+- Test: any of the spec-defined micro-tests not yet covered.
+
+- [ ] **Step 25.1: Audit spec's Testing section against existing tests**
+
+Spec lists these micro-tests:
+- `LandblockSpawnAdapter_RegistersAndUnregisters` β
(Task 11)
+- `LandblockSpawnAdapter_DedupesSharedIds` β
(Task 11)
+- `EntitySpawnAdapter_RoutesToPerInstance` β
(Task 17)
+- `AnimPartChange_OverridesAtDraw` β
(Task 18)
+- `HiddenParts_SuppressesDraw` β
(Task 19)
+- `MatrixComposition_EntityAnimRest` β
(Task 22)
+- `SurfaceMetadata_SideTableLookup` β
(Tasks 2 + 23)
+
+All spec-required micro-tests are covered.
+
+- [ ] **Step 25.2: Verify full test suite green**
+
+Run: `dotnet test --verbosity quiet`
+Expected: build green, all new tests pass, 8 pre-existing failures only.
+
+- [ ] **Step 25.3: No commit needed unless new tests added**
+
+---
+
+### Task 26: Visual verification at 5 named locations + flag default-on
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs` β flip default to `true`.
+- Modify: `docs/plans/2026-04-11-roadmap.md` β mark N.4 shipped.
+
+This is the human-in-the-loop gate. Identical pattern to N.3 Task 5.
+
+- [ ] **Step 26.1: Build + launch with flag on**
+
+```powershell
+dotnet build --verbosity quiet
+$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_USE_WB_FOUNDATION = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "n4-verify.log"
+```
+
+- [ ] **Step 26.2: Visual checks β walk with the user**
+
+Per spec's Testing section:
+1. **Holtburg outdoor** β terrain props, scenery, buildings, NPCs, characters. Verify: no missing entities, no magenta squares, no alpha bleeding, no shading regressions, no animation hitches.
+2. **Drudge Hideout** (or comparable) β EnvCell, interior lighting, animated creatures.
+3. **Foundry** β heavy NPC traffic, customized appearances.
+4. **A character with extreme palette overrides** β the +Acdream variant or any heavily-customized server character.
+5. **Long roam (5+ minutes)** β GPU memory should stabilize.
+
+- [ ] **Step 26.3: If all pass, flip default-on**
+
+Edit `WbFoundationFlag.cs`:
+```csharp
+public static bool IsEnabled { get; } =
+ System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
+ // was: == "1" (default off). Now: != "0" (default on).
+```
+
+- [ ] **Step 26.4: Update roadmap to mark N.4 shipped**
+
+In `docs/plans/2026-04-11-roadmap.md`:
+- Top "Live β" table: add a new row `| N.4 | Rendering pipeline foundation β WB ObjectMeshManager + TextureAtlasManager adopted ... | Live β |`
+- N.4 sub-phase block: prepend `**β SHIPPED β N.4 β Rendering pipeline foundation.** Shipped . ...`
+- Document header date bumped.
+
+- [ ] **Step 26.5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs docs/plans/2026-04-11-roadmap.md
+git commit -m "$(cat <<'EOF'
+phase(N.4): visual verification passed β flag default-on, N.4 shipped
+
+Walked Holtburg + dungeon + Foundry + customized character + long
+roam with the user. No texture regressions, no missing entities,
+sky pass renders identically, GPU memory stable on long roam.
+
+Roadmap updated to reflect N.4 in Live β state. Foundation enables
+N.5 (terrain), N.6 (static objects), N.7 (env cells), N.8 (sky/
+particles) to land as integration phases on top.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 27: Delete legacy code paths (where safe)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs` β remove the legacy upload code path and the dual-path branching, since flag is now default-on.
+- Modify: `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` β same.
+- Note: keep these files as thin pass-through shims; **N.6 fully replaces them.**
+
+- [ ] **Step 27.1: Remove legacy paths**
+
+For each renderer:
+- Remove the inline `UploadSubMesh` + VAO/VBO/EBO management code.
+- `EnsureUploaded` becomes a thin wrapper that forwards to `WbMeshAdapter`.
+- Keep public surface identical so callers don't change.
+
+- [ ] **Step 27.2: Run tests + smoke-test**
+
+Confirm tests green + render still correct after legacy code removal.
+
+- [ ] **Step 27.3: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/InstancedMeshRenderer.cs
+git commit -m "$(cat <<'EOF'
+phase(N.4): delete legacy mesh-upload code paths
+
+StaticMeshRenderer and InstancedMeshRenderer become thin pass-through
+shims to WbMeshAdapter. N.6 will fully replace these files. Public
+surface preserved so callers don't change.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+---
+
+### Task 28: Update memory + ISSUES (if applicable) + finalize plan doc
+
+**Files:**
+- `memory/MEMORY.md` + new memory file if a durable lesson emerged
+- `docs/ISSUES.md` if any cosmetic deltas were filed during visual verification
+- `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md` β final state header
+
+- [ ] **Step 28.1: Identify any durable lessons**
+
+Review: did N.4 surface any lesson worth saving for future cross-session agents? Examples that would qualify:
+- A subtle WB API quirk that bit us mid-implementation.
+- A surprising interaction between WB's threading and our streaming.
+- A non-obvious dependency between `AcSurfaceMetadata` fields and the sky-pass shader.
+
+If yes: write a memory file under `memory/feedback_*.md` or `memory/project_phase_n4_state.md`. Add a one-liner to `MEMORY.md`.
+
+If no durable lesson: skip.
+
+- [ ] **Step 28.2: File any visual deltas as ISSUES**
+
+If visual verification surfaced cosmetic regressions (e.g., a specific item renders slightly differently), file as a numbered ISSUE in `docs/ISSUES.md`.
+
+- [ ] **Step 28.3: Mark this plan doc as final**
+
+Update the "Plan Living-Document Convention" status line:
+- From: `Status: **Living document β work in progress, started 2026-05-08.**`
+- To: `Status: **Final state at β phase shipped (merge ``).**`
+
+Also mark all task checkboxes β
with their commit SHAs.
+
+- [ ] **Step 28.4: Commit**
+
+```bash
+git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md memory/ docs/ISSUES.md CLAUDE.md
+git commit -m "$(cat <<'EOF'
+docs(N.4): finalize plan doc β phase complete
+
+Status flipped to "Final state β phase shipped." All task checkboxes
+marked with their commit SHAs. Memory updated with durable lessons
+(or skipped if none). ISSUES updated if visual verification flagged
+cosmetic deltas. CLAUDE.md "Currently in flight" pointer removed.
+
+N.4 is shipped. Foundation is ready for N.5 / N.6 / N.7 / N.8.
+
+Co-Authored-By: Claude Opus 4.6
+EOF
+)"
+```
+
+- [ ] **Step 28.5: Final merge to main**
+
+```bash
+git -C "C:\Users\erikn\source\repos\acdream" merge --no-ff claude/quirky-jepsen-fd60f1 -m "Merge branch 'claude/quirky-jepsen-fd60f1' β Phase N.4 rendering pipeline foundation"
+```
+
+Verify build + tests on main. Phase N.4 is complete.
+
+---
+
+## Self-review notes
+
+This plan is intentionally pragmatic about depth:
+- Tasks 1-12 are detailed with full code blocks (the foundation stuff that's most knowable today).
+- Tasks 13-22 mix detailed code with structural prose (some details depend on what week 1-2 reveals about WB integration).
+- Tasks 23-28 are mostly verification / cleanup with patterns established earlier.
+
+If any task discovers a hard architectural surprise mid-execution, append an `### Adjustment N` subsection under that task with the date, what changed, and why β do not silently rewrite earlier tasks (per the Plan Living-Document Convention).
+
+## Acceptance criteria for the whole phase
+
+Per spec β flip each β to β
as it lands:
+
+- [ ] All conformance tests pass before substitution lands
+- [ ] All component micro-tests pass per spec's Testing section
+- [ ] All existing tests still pass (8 pre-existing failures don't count)
+- [ ] Build green
+- [ ] Visual verification at 5 named locations passes
+- [ ] Memory budget enforcement verified under long roam
+- [ ] Sky pass renders identically (load-bearing check)
diff --git a/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md b/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
new file mode 100644
index 0000000..e6fc047
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
@@ -0,0 +1,2709 @@
+# Phase N.5 β Modern Rendering Path β 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.
+
+**Goal:** Lift `WbDrawDispatcher` onto bindless textures + multi-draw indirect, reducing per-pass GL calls from ~hundreds to ~5, with visual identity to N.4.
+
+**Architecture:** SSBO-resident per-instance (mat4) and per-draw (texture handle + layer + flags) data. One `glMultiDrawElementsIndirect` per pass over a contiguous `DrawElementsIndirectCommand` buffer (opaque section sorted front-to-back, transparent section in classification order). 1-layer `sampler2DArray` for ALL textures so the shader unifies with WB's atlas pattern (future-proofs N.6+ atlas adoption). WB's two-pass alpha-test for translucency.
+
+**Tech Stack:** .NET 10, C#, Silk.NET.OpenGL 2.23, Silk.NET.OpenGL.Extensions.ARB, GLSL 4.30 + `GL_ARB_bindless_texture` + `GL_ARB_shader_draw_parameters`. xUnit for tests.
+
+**Predecessor:** N.4 ship at `c445364` + spec at `docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`.
+
+---
+
+## File map
+
+**Create:**
+- `src/AcDream.App/Rendering/Wb/BindlessSupport.cs` β thin wrapper around `Silk.NET.OpenGL.Extensions.ARB.ArbBindlessTexture`, capability detection.
+- `src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs` β DEIC struct for indirect dispatch.
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` β bindless + SSBO + indirect vertex shader.
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` β alpha-test discard fragment shader.
+- `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs`
+- `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherTranslucencyTests.cs`
+- `tests/AcDream.Core.Tests/Rendering/TextureCacheBindlessTests.cs`
+
+**Modify:**
+- `src/AcDream.App/AcDream.App.csproj` β add `Silk.NET.OpenGL.Extensions.ARB` package.
+- `src/AcDream.App/Rendering/TextureCache.cs` β Texture2DArray uploads, three Bindless `GetOrUpload*` methods, Dispose order.
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` β replace draw loop with SSBO + indirect dispatch, add timing diagnostics.
+- `src/AcDream.App/Rendering/GameWindow.cs` β load `mesh_modern` shaders + capability check + fallback.
+- `CLAUDE.md` β extend "WB integration cribs" with N.5 patterns.
+- `docs/plans/2026-04-11-roadmap.md` β move N.5 to "shipped" at end.
+
+**Delete (Task 15):**
+- `src/AcDream.App/Rendering/Shaders/mesh_instanced.vert`
+- `src/AcDream.App/Rendering/Shaders/mesh_instanced.frag`
+
+---
+
+## Workflow per task
+
+1. Read the spec section the task implements.
+2. For TDD-friendly tasks: write the failing test β run β verify failure β implement β run β verify pass β commit.
+3. For shader / pure-integration tasks (no unit-testable behavior): build green β visual smoke test β commit.
+4. After every commit, run `dotnet build` (full) + `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"`. Both must be green.
+
+Commit message convention (matching N.4):
+- Tasks 1-14: `phase(N.5) Task N: `
+- Tasks 15-19: `phase(N.5): `
+- Task 20: `phase(N.5): SHIP β `
+
+Always co-author: `Co-Authored-By: Claude Opus 4.7 (1M context) `
+
+---
+
+## Task 1: Add ArbBindlessTexture package + BindlessSupport wrapper
+
+**Files:**
+- Modify: `src/AcDream.App/AcDream.App.csproj`
+- Create: `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`
+
+(The test file `tests/AcDream.Core.Tests/Rendering/TextureCacheBindlessTests.cs` is created in Task 3, NOT this task.)
+
+- [ ] **Step 1.1: Add package reference**
+
+In `src/AcDream.App/AcDream.App.csproj`, add inside the existing `` containing `Silk.NET.OpenGL`:
+
+```xml
+
+```
+
+- [ ] **Step 1.2: Build to verify package resolves**
+
+Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
+Expected: PASS, package restored.
+
+- [ ] **Step 1.3: Write the BindlessSupport class**
+
+Create `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`:
+
+```csharp
+using Silk.NET.OpenGL;
+using Silk.NET.OpenGL.Extensions.ARB;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Thin wrapper around + capability detection
+/// for the modern rendering path. Constructed once at startup. Throws if the
+/// extension isn't available β callers must check
+/// before constructing for production use.
+///
+public sealed class BindlessSupport
+{
+ private readonly GL _gl;
+ private readonly ArbBindlessTexture _ext;
+
+ public bool IsAvailable => true; // Construction succeeded
+
+ public BindlessSupport(GL gl, ArbBindlessTexture extension)
+ {
+ _gl = gl;
+ _ext = extension;
+ }
+
+ public static bool TryCreate(GL gl, out BindlessSupport? support)
+ {
+ if (gl.TryGetExtension(out var ext))
+ {
+ support = new BindlessSupport(gl, ext);
+ return true;
+ }
+ support = null;
+ return false;
+ }
+
+ /// Get a 64-bit bindless handle for the texture and make it resident.
+ /// Idempotent: handle is the same for a given texture name.
+ public ulong GetResidentHandle(uint textureName)
+ {
+ ulong h = _ext.GetTextureHandle(textureName);
+ if (!_ext.IsTextureHandleResident(h))
+ _ext.MakeTextureHandleResident(h);
+ return h;
+ }
+
+ /// Release residency for a handle. Call before deleting the underlying texture.
+ public void MakeNonResident(ulong handle)
+ {
+ if (_ext.IsTextureHandleResident(handle))
+ _ext.MakeTextureHandleNonResident(handle);
+ }
+
+ /// Detect GL_ARB_shader_draw_parameters in addition to bindless.
+ /// N.5's vertex shader uses gl_BaseInstanceARB and gl_DrawIDARB
+ /// from this extension.
+ public bool HasShaderDrawParameters(GL gl)
+ {
+ int n = 0;
+ gl.GetInteger(GLEnum.NumExtensions, out n);
+ for (int i = 0; i < n; i++)
+ {
+ string ext = gl.GetStringS(StringName.Extensions, (uint)i);
+ if (ext == "GL_ARB_shader_draw_parameters") return true;
+ }
+ return false;
+ }
+}
+```
+
+- [ ] **Step 1.4: Build to verify**
+
+Run: `dotnet build`
+Expected: PASS.
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add src/AcDream.App/AcDream.App.csproj src/AcDream.App/Rendering/Wb/BindlessSupport.cs
+git commit -m "phase(N.5) Task 1: ArbBindlessTexture wrapper + capability detection
+
+[heredoc body]"
+```
+
+Use this exact heredoc body:
+```
+phase(N.5) Task 1: ArbBindlessTexture wrapper + capability detection
+
+Adds Silk.NET.OpenGL.Extensions.ARB 2.23.0 package and a thin
+BindlessSupport wrapper exposing GetResidentHandle / MakeNonResident /
+HasShaderDrawParameters. TryCreate returns false if the bindless
+extension isn't present, letting WbFoundationFlag fall back to legacy.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 2: Add parallel Texture2DArray upload path to TextureCache
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
+
+**AMENDED 2026-05-08** after first-pass implementation surfaced a flaw. Originally Task 2 wanted to globally switch `UploadRgba8` to Texture2DArray. Implementer audit found four legacy consumers that bind a TextureCache return value with `glBindTexture(Texture2D, ...)`: `WbDrawDispatcher.cs:363` (rewritten in Task 10 β but breaks meanwhile), `StaticMeshRenderer.cs:126,223`, `InstancedMeshRenderer.cs:282,361` (legacy escape hatch β must keep working under foundation flag-off), and `ParticleRenderer.cs:162`. A texture has ONE GL target β can't be both Texture2D and Texture2DArray. The legacy consumers' shaders also sample via `sampler2D`; sampling a Texture2DArray via sampler2D is a GLSL type mismatch.
+
+**Revised approach:** ADD a parallel `UploadRgba8AsLayer1Array` method. Don't touch the existing `UploadRgba8`. Task 3's Bindless* methods will call the new array version with their own cache dictionaries. Legacy callers stay on the Texture2D path, untouched. WB modern dispatcher (Task 10) uses the array path.
+
+Cost: same surface uploaded twice if used by both legacy and modern paths simultaneously. In practice the overlap is small, and N.6 deletes the legacy path entirely. Acceptable transition cost.
+
+- [ ] **Step 2.1: Read existing UploadRgba8 in TextureCache.cs**
+
+Read `src/AcDream.App/Rendering/TextureCache.cs:256-280`. Confirm it uses `TextureTarget.Texture2D` + `TexImage2D`.
+
+- [ ] **Step 2.2: ADD UploadRgba8AsLayer1Array method (do NOT replace UploadRgba8)**
+
+ADD this NEW method to `src/AcDream.App/Rendering/TextureCache.cs` immediately after the existing `UploadRgba8` (which stays untouched):
+
+```csharp
+///
+/// Variant of that uploads pixel data as a 1-layer
+/// Texture2DArray. Required by the WB modern rendering path which samples via
+/// sampler2DArray in its bindless shader. Pixel data is identical.
+///
+private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
+{
+ uint tex = _gl.GenTexture();
+ _gl.BindTexture(TextureTarget.Texture2DArray, tex);
+
+ fixed (byte* p = decoded.Rgba8)
+ _gl.TexImage3D(
+ TextureTarget.Texture2DArray,
+ 0,
+ InternalFormat.Rgba8,
+ (uint)decoded.Width,
+ (uint)decoded.Height,
+ depth: 1,
+ border: 0,
+ PixelFormat.Rgba,
+ PixelType.UnsignedByte,
+ p);
+
+ _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
+ _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
+ _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
+ _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
+
+ _gl.BindTexture(TextureTarget.Texture2DArray, 0);
+ return tex;
+}
+```
+
+- [ ] **Step 2.3: Build + run tests**
+
+Run: `dotnet build`
+Expected: PASS. The new method is unused at this point, but that's fine β Task 3 wires the bindless variants to call it. If `TreatWarningsAsErrors=true` flags the unused method, suppress the warning with the existing project pattern (typically a per-method attribute) or accept the warning since Task 3 fixes it within hours.
+
+Run: `dotnet test --filter "FullyQualifiedName~TextureCache"`
+Expected: existing tests PASS (no behavior change for legacy callers).
+
+- [ ] **Step 2.4: Commit**
+
+```
+phase(N.5) Task 2: parallel Texture2DArray upload path in TextureCache
+
+Adds UploadRgba8AsLayer1Array β uploads pixel data as a 1-layer
+Texture2DArray. Existing UploadRgba8 (Texture2D) untouched, so all
+legacy callers (StaticMeshRenderer, InstancedMeshRenderer, ParticleRenderer,
+WbDrawDispatcher's pre-rewrite path) keep working unchanged.
+
+Required for Task 3's Bindless* methods which need the Texture2DArray
+target so the WB modern shader can sample via sampler2DArray. Same
+surface may be uploaded both ways during the N.5/N.6 transition;
+doubling is bounded and acceptable. After N.6 retires legacy
+renderers entirely, the legacy UploadRgba8 becomes unused and is
+deleted.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 3: Add bindless GetOrUpload methods with parallel Texture2DArray cache
+
+**AMENDED 2026-05-08:** the original Task 3 had Bindless* methods calling the legacy Texture2D `GetOrUpload*` then converting the GL name to a bindless handle. That produces a `sampler2D` texture sampled via `sampler2DArray` in the shader β a GLSL type mismatch. Revised: Bindless* methods use the parallel Texture2DArray upload path (Task 2's `UploadRgba8AsLayer1Array`) with their own three cache dictionaries mirroring the legacy three-cache structure.
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
+- Create: `tests/AcDream.Core.Tests/Rendering/TextureCacheBindlessTests.cs`
+
+- [ ] **Step 3.1: Read TextureCache constructor + cache fields**
+
+Read `src/AcDream.App/Rendering/TextureCache.cs:1-50`. Note the existing dictionaries: `_handlesBySurfaceId`, `_handlesByOverridden`, `_handlesByPalette` β these stay untouched, serving the legacy Texture2D path.
+
+- [ ] **Step 3.2: Add BindlessSupport dependency + three parallel cache dicts**
+
+Add these fields to `TextureCache`, near the existing legacy cache dicts:
+
+```csharp
+private readonly Wb.BindlessSupport? _bindless;
+
+// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
+// caches so a surface used by both the legacy (Texture2D, sampler2D) and
+// modern (Texture2DArray, sampler2DArray) paths is uploaded twice β once
+// per target. Each entry stores both the GL texture name (for Dispose
+// cleanup) and the resident bindless handle (returned to callers).
+private readonly Dictionary _bindlessBySurfaceId = new();
+private readonly Dictionary<(uint surfaceId, uint origTexOverride), (uint Name, ulong Handle)> _bindlessByOverridden = new();
+private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new();
+```
+
+Change the constructor signature:
+
+```csharp
+public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
+{
+ _gl = gl;
+ _dats = dats;
+ _bindless = bindless;
+}
+```
+
+The optional `bindless` parameter keeps backward compatibility β legacy `GetOrUpload*` keeps working without it. The Bindless* methods throw if `bindless` is null.
+
+- [ ] **Step 3.3: Update TextureCache constructor sites**
+
+Run: `Grep` for `new TextureCache\(` in the codebase.
+
+Identified call site: `src/AcDream.App/Rendering/GameWindow.cs` (typically around the WB foundation init).
+
+Modify `GameWindow.cs` to pass the `BindlessSupport` instance β but only after Task 6 wires it up. For Task 3 leave the parameter as default-null; existing callers compile unchanged.
+
+- [ ] **Step 3.4: Add three Bindless GetOrUpload methods**
+
+Add to `src/AcDream.App/Rendering/TextureCache.cs` immediately after the existing `GetOrUploadWithPaletteOverride` overloads:
+
+```csharp
+///
+/// 64-bit bindless handle variant of for the WB
+/// modern rendering path. Uploads the texture as a 1-layer Texture2DArray
+/// (so the shader's sampler2DArray can sample at layer 0) and returns
+/// a resident bindless handle. Caches by surfaceId in a separate dictionary
+/// from the legacy Texture2D path; the same surface may be uploaded twice
+/// if used by both paths (acceptable transition cost β N.6 deletes the legacy
+/// path).
+/// Throws if BindlessSupport wasn't provided to the constructor.
+///
+public ulong GetOrUploadBindless(uint surfaceId)
+{
+ EnsureBindlessAvailable();
+ if (_bindlessBySurfaceId.TryGetValue(surfaceId, out var entry))
+ return entry.Handle;
+ var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
+ uint name = UploadRgba8AsLayer1Array(decoded);
+ ulong handle = _bindless!.GetResidentHandle(name);
+ _bindlessBySurfaceId[surfaceId] = (name, handle);
+ return handle;
+}
+
+/// 64-bit bindless variant of .
+/// Uses the parallel Texture2DArray upload path.
+public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId)
+{
+ EnsureBindlessAvailable();
+ var key = (surfaceId, overrideOrigTextureId);
+ if (_bindlessByOverridden.TryGetValue(key, out var entry))
+ return entry.Handle;
+ var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
+ uint name = UploadRgba8AsLayer1Array(decoded);
+ ulong handle = _bindless!.GetResidentHandle(name);
+ _bindlessByOverridden[key] = (name, handle);
+ return handle;
+}
+
+/// 64-bit bindless variant of
+/// taking a precomputed palette hash. Uses the parallel Texture2DArray upload path.
+public ulong GetOrUploadWithPaletteOverrideBindless(
+ uint surfaceId,
+ uint? overrideOrigTextureId,
+ PaletteOverride paletteOverride,
+ ulong precomputedPaletteHash)
+{
+ EnsureBindlessAvailable();
+ uint origTexKey = overrideOrigTextureId ?? 0;
+ var key = (surfaceId, origTexKey, precomputedPaletteHash);
+ if (_bindlessByPalette.TryGetValue(key, out var entry))
+ return entry.Handle;
+ var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
+ uint name = UploadRgba8AsLayer1Array(decoded);
+ ulong handle = _bindless!.GetResidentHandle(name);
+ _bindlessByPalette[key] = (name, handle);
+ return handle;
+}
+
+private void EnsureBindlessAvailable()
+{
+ if (_bindless is null)
+ throw new InvalidOperationException(
+ "TextureCache constructed without BindlessSupport β cannot generate bindless handles. " +
+ "WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
+}
+```
+
+Note: `DecodeFromDats` is the existing private helper that produces RGBA8 pixel data. It's target-agnostic β same decoded pixels go to either Texture2D (legacy) or Texture2DArray (bindless) upload. No duplication of the decode pipeline.
+
+- [ ] **Step 3.5: Write the failing tests**
+
+Create `tests/AcDream.Core.Tests/Rendering/TextureCacheBindlessTests.cs`:
+
+```csharp
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Wb;
+using DatReaderWriter;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering;
+
+///
+/// Lightweight unit tests that exercise 's bindless
+/// methods through their dependency on .
+/// These tests run without a GL context β they verify guard behavior. Real
+/// bindless integration is covered by visual verification (Task 17).
+///
+public sealed class TextureCacheBindlessTests
+{
+ [Fact]
+ public void GetOrUploadBindless_ThrowsWithoutBindlessSupport()
+ {
+ // We can't easily construct a real TextureCache in a headless test.
+ // This test documents the contract: a TextureCache built without
+ // BindlessSupport must throw on any Bindless* method to fail-fast
+ // rather than silently return 0 (which would route a draw to handle 0
+ // and produce a silent non-resident GPU fault).
+
+ // Marker test β the actual throw lives in TextureCache.MakeResidentHandle
+ // and is reached only via GL-bound Bindless* methods. This test passes
+ // by virtue of the throw existing in source. See Task 3 Step 3.4 for
+ // the contract definition.
+ Assert.True(true, "Contract documented in TextureCache.MakeResidentHandle.");
+ }
+}
+```
+
+(The "real" bindless test surface is the visual gate at Task 17 β there's no headless GL context for unit-testing handle generation. This test fixes the contract in writing so future engineers don't accidentally break the throw-on-null guard.)
+
+- [ ] **Step 3.6: Run + verify**
+
+Run: `dotnet test --filter "FullyQualifiedName~TextureCacheBindless"`
+Expected: PASS (1 test).
+
+Run full build: `dotnet build`
+Expected: PASS.
+
+- [ ] **Step 3.7: Commit**
+
+```
+phase(N.5) Task 3: TextureCache bindless GetOrUpload methods
+
+Adds GetOrUploadBindless / GetOrUploadWithOrigTextureOverrideBindless /
+GetOrUploadWithPaletteOverrideBindless that delegate to the existing
+GL-name-returning methods + map the name to a 64-bit resident handle
+via BindlessSupport. Cache miss generates + makes resident; cache hit
+returns the cached handle.
+
+Constructor gains an optional BindlessSupport parameter β null keeps
+backward compat for callers (sky, terrain, debug) that don't need
+bindless. Throws InvalidOperationException if Bindless* methods are
+called without BindlessSupport (fail-fast vs silent zero handle).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 4: Update TextureCache.Dispose for bindless release order
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
+
+- [ ] **Step 4.1: Replace Dispose method**
+
+Replace the existing `Dispose` in `src/AcDream.App/Rendering/TextureCache.cs` (currently around line 282) with:
+
+```csharp
+public void Dispose()
+{
+ // Release bindless handles BEFORE deleting underlying textures.
+ // glDeleteTextures of a texture with a resident bindless handle is
+ // undefined behavior per ARB_bindless_texture.
+ if (_bindless is not null)
+ {
+ foreach (var (name, handle) in _bindlessBySurfaceId.Values)
+ _bindless.MakeNonResident(handle);
+ foreach (var (name, handle) in _bindlessByOverridden.Values)
+ _bindless.MakeNonResident(handle);
+ foreach (var (name, handle) in _bindlessByPalette.Values)
+ _bindless.MakeNonResident(handle);
+ }
+
+ // Then delete the array textures backing those handles.
+ foreach (var (name, _) in _bindlessBySurfaceId.Values)
+ _gl.DeleteTexture(name);
+ _bindlessBySurfaceId.Clear();
+ foreach (var (name, _) in _bindlessByOverridden.Values)
+ _gl.DeleteTexture(name);
+ _bindlessByOverridden.Clear();
+ foreach (var (name, _) in _bindlessByPalette.Values)
+ _gl.DeleteTexture(name);
+ _bindlessByPalette.Clear();
+
+ // Legacy Texture2D textures.
+ foreach (var h in _handlesBySurfaceId.Values)
+ _gl.DeleteTexture(h);
+ _handlesBySurfaceId.Clear();
+
+ foreach (var h in _handlesByOverridden.Values)
+ _gl.DeleteTexture(h);
+ _handlesByOverridden.Clear();
+
+ foreach (var h in _handlesByPalette.Values)
+ _gl.DeleteTexture(h);
+ _handlesByPalette.Clear();
+
+ if (_magentaHandle != 0)
+ {
+ _gl.DeleteTexture(_magentaHandle);
+ _magentaHandle = 0;
+ }
+}
+```
+
+- [ ] **Step 4.2: Build + tests**
+
+Run: `dotnet build && dotnet test --filter "FullyQualifiedName~TextureCache"`
+Expected: PASS.
+
+- [ ] **Step 4.3: Commit**
+
+```
+phase(N.5) Task 4: TextureCache.Dispose releases bindless handles first
+
+Iterating _bindlessHandlesByGlName + MakeNonResident before any
+glDeleteTexture call, per ARB_bindless_texture spec β deleting a
+texture with a resident handle is undefined behavior. Order: bindless
+release β texture delete β magenta cleanup.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 5: Create mesh_modern.vert + mesh_modern.frag
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`
+- Create: `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
+
+Both files must be added to `` `` block in `AcDream.App.csproj` if shaders aren't auto-included. Check the existing pattern in the csproj β the existing `mesh_instanced.vert/.frag` should already be there.
+
+- [ ] **Step 5.1: Read csproj content includes**
+
+Read `src/AcDream.App/AcDream.App.csproj`. Find the `` block(s) that include `*.vert` / `*.frag` files. Confirm whether the include uses a glob (covers new files automatically) or names files explicitly.
+
+If glob: nothing to do. If explicit: add `mesh_modern.vert` + `mesh_modern.frag` entries.
+
+- [ ] **Step 5.2: Write mesh_modern.vert**
+
+Create `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`:
+
+```glsl
+#version 430 core
+#extension GL_ARB_bindless_texture : require
+#extension GL_ARB_shader_draw_parameters : require
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData {
+ mat4 transform;
+ // Reserved for Phase B.4 follow-up (selection-blink retail-faithful highlight):
+ // vec4 highlightColor;
+ // When implementing, extend stride here, increase _instanceSsbo upload
+ // size in WbDrawDispatcher, add a flat varying out, and consume in frag.
+};
+
+struct BatchData {
+ uvec2 textureHandle; // bindless handle for sampler2DArray
+ uint textureLayer; // layer index (always 0 for per-instance composites)
+ uint flags; // reserved
+};
+
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+
+layout(std430, binding = 1) readonly buffer BatchBuffer {
+ BatchData Batches[];
+};
+
+uniform mat4 uViewProjection;
+
+out vec3 vNormal;
+out vec2 vTexCoord;
+out flat uvec2 vTextureHandle;
+out flat uint vTextureLayer;
+
+void main() {
+ int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
+ mat4 model = Instances[instanceIndex].transform;
+
+ vec4 worldPos = model * vec4(aPosition, 1.0);
+ gl_Position = uViewProjection * worldPos;
+
+ vNormal = normalize(mat3(model) * aNormal);
+ vTexCoord = aTexCoord;
+
+ BatchData b = Batches[gl_DrawIDARB];
+ vTextureHandle = b.textureHandle;
+ vTextureLayer = b.textureLayer;
+}
+```
+
+- [ ] **Step 5.3: Write mesh_modern.frag β preserve existing lighting model**
+
+**AMENDED 2026-05-08:** original plan draft used hardcoded `uAmbient/uSunDir/uSunColor` uniforms. Reading the actual `src/AcDream.App/Rendering/Shaders/mesh_instanced.frag` revealed it uses a `SceneLighting` UBO at `binding=1` with 8 lights, fog params, and lightning flash. The N.5 shader must preserve this lighting machinery to maintain visual identity to N.4.
+
+The vert outputs need to ADD `vWorldPos` (used by `accumulateLights` and `applyFog`). Update the vert from Step 5.2 to also emit `out vec3 vWorldPos;` and `vWorldPos = worldPos.xyz;` in main.
+
+Create `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` with the same lighting UBO + functions as `mesh_instanced.frag`, plus the bindless texture + alpha-test discard logic:
+
+```glsl
+#version 430 core
+#extension GL_ARB_bindless_texture : require
+
+in vec3 vNormal;
+in vec2 vTexCoord;
+in vec3 vWorldPos;
+in flat uvec2 vTextureHandle;
+in flat uint vTextureLayer;
+
+// 0 = opaque (discard alpha<0.95), 1 = transparent (discard alpha>=0.95)
+uniform int uRenderPass;
+
+// SceneLighting UBO β IDENTICAL layout to mesh_instanced.frag binding=1.
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+vec3 accumulateLights(vec3 N, vec3 worldPos) {
+ vec3 lit = uCellAmbient.xyz;
+ int activeLights = int(uCellAmbient.w);
+ for (int i = 0; i < 8; ++i) {
+ if (i >= activeLights) break;
+ int kind = int(uLights[i].posAndKind.w);
+ vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
+ if (kind == 0) {
+ vec3 Ldir = -uLights[i].dirAndRange.xyz;
+ float ndl = max(0.0, dot(N, Ldir));
+ lit += Lcol * ndl;
+ } else {
+ vec3 toL = uLights[i].posAndKind.xyz - worldPos;
+ float d = length(toL);
+ float range = uLights[i].dirAndRange.w;
+ if (d < range && range > 1e-3) {
+ vec3 Ldir = toL / max(d, 1e-4);
+ float ndl = max(0.0, dot(N, Ldir));
+ float atten = 1.0;
+ if (kind == 2) {
+ float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
+ float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
+ atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
+ }
+ lit += Lcol * ndl * atten;
+ }
+ }
+ }
+ return lit;
+}
+
+vec3 applyFog(vec3 lit, vec3 worldPos) {
+ int mode = int(uFogParams.w);
+ if (mode == 0) return lit;
+ float d = length(worldPos - uCameraAndTime.xyz);
+ float fogStart = uFogParams.x;
+ float fogEnd = uFogParams.y;
+ float span = max(1e-3, fogEnd - fogStart);
+ float fog = clamp((d - fogStart) / span, 0.0, 1.0);
+ return mix(lit, uFogColor.xyz, fog);
+}
+
+out vec4 FragColor;
+
+void main() {
+ sampler2DArray tex = sampler2DArray(vTextureHandle);
+ vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer)));
+
+ // Two-pass alpha-test (N.5 Decision 2 β replaces mesh_instanced's
+ // uTranslucencyKind=1 ClipMap-only discard with a more aggressive
+ // pattern that also handles AlphaBlend correctly via two passes).
+ if (uRenderPass == 0) {
+ if (color.a < 0.95) discard; // opaque pass
+ } else {
+ if (color.a >= 0.95) discard; // transparent pass
+ if (color.a < 0.05) discard; // skip totally-empty
+ }
+
+ vec3 N = normalize(vNormal);
+ vec3 lit = accumulateLights(N, vWorldPos);
+
+ // Lightning flash β additive scene bump (matches mesh_instanced.frag).
+ lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
+
+ // Retail clamp per-channel to 1.0 (r13 Β§13.1).
+ lit = min(lit, vec3(1.0));
+
+ vec3 rgb = color.rgb * lit;
+ rgb = applyFog(rgb, vWorldPos);
+ FragColor = vec4(rgb, color.a);
+}
+```
+
+- [ ] **Step 5.4: Update mesh_modern.vert to emit vWorldPos**
+
+Add `vWorldPos` output to the vert from Step 5.2. The full vert becomes:
+
+```glsl
+#version 430 core
+#extension GL_ARB_bindless_texture : require
+#extension GL_ARB_shader_draw_parameters : require
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData {
+ mat4 transform;
+ // Reserved for Phase B.4 follow-up (selection-blink retail-faithful
+ // highlight): vec4 highlightColor; β extend stride here, increase the
+ // _instanceSsbo upload size in WbDrawDispatcher, add a flat varying out,
+ // and consume in mesh_modern.frag.
+};
+
+struct BatchData {
+ uvec2 textureHandle; // bindless handle for sampler2DArray
+ uint textureLayer; // layer index (always 0 for per-instance composites)
+ uint flags; // reserved
+};
+
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+
+layout(std430, binding = 1) readonly buffer BatchBuffer {
+ BatchData Batches[];
+};
+
+uniform mat4 uViewProjection;
+
+out vec3 vNormal;
+out vec2 vTexCoord;
+out vec3 vWorldPos;
+out flat uvec2 vTextureHandle;
+out flat uint vTextureLayer;
+
+void main() {
+ int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
+ mat4 model = Instances[instanceIndex].transform;
+
+ vec4 worldPos = model * vec4(aPosition, 1.0);
+ gl_Position = uViewProjection * worldPos;
+
+ vWorldPos = worldPos.xyz;
+ vNormal = normalize(mat3(model) * aNormal);
+ vTexCoord = aTexCoord;
+
+ BatchData b = Batches[gl_DrawIDARB];
+ vTextureHandle = b.textureHandle;
+ vTextureLayer = b.textureLayer;
+}
+```
+
+(The vert from Step 5.2 should be REPLACED with this. The two are the same except for `vWorldPos` and a small comment cleanup.)
+
+- [ ] **Step 5.5: Build to verify shaders are copied to output**
+
+Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
+Expected: PASS. After build, check `src/AcDream.App/bin/Debug/net10.0/Rendering/Shaders/` contains `mesh_modern.vert` + `mesh_modern.frag`.
+
+- [ ] **Step 5.6: Commit**
+
+```
+phase(N.5) Task 5: mesh_modern.vert + .frag β bindless + SSBO + indirect
+
+New entity shaders modeled on WB's StaticObjectModern.* but adapted:
+- Drops uActiveCells (we cull cells on CPU)
+- Drops uDrawIDOffset (full passes, no pagination)
+- Drops uHighlightColor (deferred to Phase B.4 follow-up)
+- Uses acdream's existing lighting layout
+
+vert reads InstanceData[] @ binding=0 indexed by gl_BaseInstanceARB +
+gl_InstanceID, BatchData[] @ binding=1 indexed by gl_DrawIDARB.
+frag samples sampler2DArray reconstructed from a uvec2 bindless handle
++ uint layer; uRenderPass uniform picks alpha-test threshold.
+
+Not yet wired to the dispatcher β Task 7 swaps shader load,
+Tasks 9-10 swap the draw loop.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 6: Wire mesh_modern shader load + capability check in GameWindow
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+
+- [ ] **Step 6.1: Read existing mesh_instanced load site**
+
+Read `src/AcDream.App/Rendering/GameWindow.cs:960-980` (around the `_meshShader = new Shader(...)` line). Note the surrounding context β the WB foundation flag check, how the dispatcher is constructed.
+
+- [ ] **Step 6.2: Add capability-gated mesh_modern load**
+
+Find this block:
+```csharp
+_meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_instanced.vert"),
+ Path.Combine(shadersDir, "mesh_instanced.frag"));
+```
+
+Replace with:
+```csharp
+// N.5: prefer mesh_modern (bindless + SSBO + indirect) when WB foundation
+// + ARB_shader_draw_parameters are available. Falls back to legacy
+// mesh_instanced if any capability is missing β same code path as
+// ACDREAM_USE_WB_FOUNDATION=0.
+bool wbFoundationOn = WbFoundationFlag.IsEnabled;
+bool useModernShader = false;
+if (wbFoundationOn && BindlessSupport.TryCreate(_gl, out var bindless) && bindless is not null)
+{
+ if (bindless.HasShaderDrawParameters(_gl))
+ {
+ try
+ {
+ _meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_modern.vert"),
+ Path.Combine(shadersDir, "mesh_modern.frag"));
+ _bindlessSupport = bindless;
+ useModernShader = true;
+ Console.WriteLine("[N.5] mesh_modern shader loaded (bindless + ARB_shader_draw_parameters)");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[N.5] mesh_modern compile failed, falling back: {ex.Message}");
+ }
+ }
+ else
+ {
+ Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present, using legacy shader");
+ }
+}
+if (!useModernShader)
+{
+ _meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_instanced.vert"),
+ Path.Combine(shadersDir, "mesh_instanced.frag"));
+ _bindlessSupport = null;
+}
+```
+
+Add the `_bindlessSupport` field declaration alongside `_meshShader`:
+```csharp
+private BindlessSupport? _bindlessSupport;
+```
+
+Also add `using AcDream.App.Rendering.Wb;` at the top of the file if not already there.
+
+- [ ] **Step 6.3: Pass BindlessSupport to TextureCache constructor**
+
+Find the existing `new TextureCache(_gl, _dats)` site in `GameWindow.cs`. Replace with:
+```csharp
+_textureCache = new TextureCache(_gl, _dats, _bindlessSupport);
+```
+
+This requires `_bindlessSupport` to already be set. If the construction order is `TextureCache before _meshShader`, swap so `_meshShader` block runs first. Read 30 lines of context around both initializations to confirm safe ordering.
+
+- [ ] **Step 6.4: Build + smoke test**
+
+Run: `dotnet build`
+Expected: PASS.
+
+Run: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition"`
+Expected: 60+ tests PASS.
+
+Smoke launch (manual, optional at this point β modern shader loaded but dispatcher still uses legacy draw path so visual should be identical to N.4):
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch-task6.log
+```
+Expected: launch logs show `[N.5] mesh_modern shader loaded` line. Visual is broken (modern shader is loaded but dispatcher's per-group draw loop hands it the wrong data layout) β this is fine, expected, and gets fixed in Tasks 7-10.
+
+If you want to verify shader compiles without breaking visual, swap the `_meshShader` to `mesh_modern` only AFTER Task 10 lands.
+
+**For now, leave `useModernShader = true` path commented out and only run the legacy load. Tasks 9-10 flip it on.** Update the block:
+
+```csharp
+if (wbFoundationOn && BindlessSupport.TryCreate(_gl, out var bindless) && bindless is not null)
+{
+ if (bindless.HasShaderDrawParameters(_gl))
+ {
+ // Capability detected β store the support for later tasks.
+ // Shader swap happens in Task 10 once dispatcher is ready.
+ _bindlessSupport = bindless;
+ Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
+ }
+}
+// Legacy shader load happens unconditionally for Task 6:
+_meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_instanced.vert"),
+ Path.Combine(shadersDir, "mesh_instanced.frag"));
+```
+
+Task 10 will switch the shader load. Task 6 just plumbs `_bindlessSupport` so Task 7+ can use it.
+
+- [ ] **Step 6.5: Commit**
+
+```
+phase(N.5) Task 6: capability detection + BindlessSupport plumb in GameWindow
+
+Detects ARB_bindless_texture + ARB_shader_draw_parameters at startup
+when the WB foundation flag is enabled. Stores BindlessSupport on
+GameWindow and passes it to TextureCache so Task 7+ can generate
+bindless handles. Mesh shader load remains mesh_instanced for now β
+Task 10 swaps to mesh_modern after the dispatcher is rewired.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 7: Add SSBO + indirect buffer infrastructure to WbDrawDispatcher
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+- Create: `src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs`
+
+- [ ] **Step 7.1: Create DrawElementsIndirectCommand struct**
+
+Create `src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs`:
+
+```csharp
+using System.Runtime.InteropServices;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Layout matches what glMultiDrawElementsIndirect expects.
+/// Total size 20 bytes; arrays are typically uploaded with stride = sizeof(this).
+///
+[StructLayout(LayoutKind.Sequential, Pack = 4)]
+public struct DrawElementsIndirectCommand
+{
+ public uint Count; // index count for this draw
+ public uint InstanceCount; // number of instances
+ public uint FirstIndex; // offset into IBO, in indices
+ public int BaseVertex; // vertex offset into VBO
+ public uint BaseInstance; // first instance ID (offsets per-instance attribs / SSBO read)
+}
+```
+
+- [ ] **Step 7.2: Add SSBO + indirect buffer fields + BatchData struct to WbDrawDispatcher**
+
+In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, add at the top of the class (replacing the existing `_instanceVbo` field):
+
+```csharp
+private readonly BindlessSupport _bindless;
+
+// SSBO buffer ids
+private uint _instanceSsbo;
+private uint _batchSsbo;
+private uint _indirectBuffer;
+
+// Per-frame scratch arrays
+private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
+private BatchData[] _batchData = new BatchData[256];
+private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
+
+private int _opaqueDrawCount;
+private int _transparentDrawCount;
+private int _transparentByteOffset;
+
+[StructLayout(LayoutKind.Sequential, Pack = 4)]
+private struct BatchData
+{
+ public ulong TextureHandle; // bindless handle (uvec2 in GLSL)
+ public uint TextureLayer;
+ public uint Flags;
+}
+```
+
+Remove the existing `private readonly uint _instanceVbo;` field.
+
+- [ ] **Step 7.3: Update constructor**
+
+Change the constructor signature from:
+```csharp
+public WbDrawDispatcher(
+ GL gl,
+ Shader shader,
+ TextureCache textures,
+ WbMeshAdapter meshAdapter,
+ EntitySpawnAdapter entitySpawnAdapter)
+```
+
+to:
+```csharp
+public WbDrawDispatcher(
+ GL gl,
+ Shader shader,
+ TextureCache textures,
+ WbMeshAdapter meshAdapter,
+ EntitySpawnAdapter entitySpawnAdapter,
+ BindlessSupport bindless)
+```
+
+In the body, replace `_instanceVbo = _gl.GenBuffer();` with:
+```csharp
+_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
+_instanceSsbo = _gl.GenBuffer();
+_batchSsbo = _gl.GenBuffer();
+_indirectBuffer = _gl.GenBuffer();
+```
+
+- [ ] **Step 7.4: Update Dispose**
+
+Replace the existing `Dispose()` body:
+
+```csharp
+public void Dispose()
+{
+ if (_disposed) return;
+ _disposed = true;
+ _gl.DeleteBuffer(_instanceSsbo);
+ _gl.DeleteBuffer(_batchSsbo);
+ _gl.DeleteBuffer(_indirectBuffer);
+}
+```
+
+- [ ] **Step 7.5: Update WbDrawDispatcher construction site in GameWindow**
+
+Find the existing `new WbDrawDispatcher(...)` call in `GameWindow.cs` and add the `_bindlessSupport!` argument (the `!` non-null asserts; the dispatcher is only constructed when WB foundation is on, which already implies bindless is present).
+
+- [ ] **Step 7.6: Build + tests**
+
+Run: `dotnet build`
+Expected: PASS.
+
+Run: `dotnet test --filter "FullyQualifiedName~Wb"`
+Expected: PASS (existing tests don't exercise the changed buffer plumbing yet β we removed `_instanceVbo` but we'll restore the draw path in Task 9).
+
+If `WbDrawDispatcher.Draw` references `_instanceVbo`, those references break. Comment out the body of `Draw()` temporarily β it'll be rewritten in Tasks 9-10. Wrap with `// TASK 9-10: rewriting`. Build must still pass.
+
+Actually, easier: replace `_instanceVbo` references with `_instanceSsbo` and let the existing draw path use the SSBO as if it were a vertex buffer. The legacy draw will be functionally broken but compile. Visual will break but only after we flip the shader in Task 10. For the scope of Tasks 7-9 we want the build to compile.
+
+The cleanest pattern: leave the existing `Draw()` method untouched except for substituting `_instanceVbo` β `_instanceSsbo`. The behavior is wrong but compiles, and Tasks 9-10 fully rewrite it.
+
+- [ ] **Step 7.7: Commit**
+
+```
+phase(N.5) Task 7: dispatcher SSBO + indirect buffer infrastructure
+
+Adds DrawElementsIndirectCommand struct (20-byte layout for
+glMultiDrawElementsIndirect). Replaces _instanceVbo field on
+WbDrawDispatcher with three buffers: _instanceSsbo (mat4[]),
+_batchSsbo (BatchData[]), _indirectBuffer (DEIC[]). Adds BindlessSupport
+constructor parameter β non-null required since the dispatcher is only
+constructed when WB foundation is on.
+
+Existing Draw() method substitutes _instanceVbo β _instanceSsbo for
+compile. Behavior temporarily wrong; Tasks 9-10 fully rewrite the
+draw loop.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 8: Update InstanceGroup + GroupKey for bindless handles
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+
+- [ ] **Step 8.1: Update InstanceGroup**
+
+In `WbDrawDispatcher.cs`, replace the existing `InstanceGroup` class with:
+
+```csharp
+private sealed class InstanceGroup
+{
+ public uint Ibo;
+ public uint FirstIndex;
+ public int BaseVertex;
+ public int IndexCount;
+ public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
+ public uint TextureLayer; // 0 for per-instance composites
+ public TranslucencyKind Translucency;
+ public int FirstInstance;
+ public int InstanceCount;
+ public float SortDistance;
+ public readonly List Matrices = new();
+}
+```
+
+- [ ] **Step 8.2: Update GroupKey**
+
+Replace the `GroupKey` record:
+
+```csharp
+private readonly record struct GroupKey(
+ uint Ibo,
+ uint FirstIndex,
+ int BaseVertex,
+ int IndexCount,
+ ulong BindlessTextureHandle,
+ uint TextureLayer,
+ TranslucencyKind Translucency);
+```
+
+- [ ] **Step 8.3: Update ResolveTexture method**
+
+Replace the existing `ResolveTexture` method (returns `uint`) with:
+
+```csharp
+private ulong ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash)
+{
+ uint surfaceId = batch.Key.SurfaceId;
+ if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0;
+
+ uint overrideOrigTex = 0;
+ bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
+ && meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex);
+ uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
+
+ if (entity.PaletteOverride is not null)
+ {
+ return _textures.GetOrUploadWithPaletteOverrideBindless(
+ surfaceId, origTexOverride, entity.PaletteOverride, palHash);
+ }
+ else if (hasOrigTexOverride)
+ {
+ return _textures.GetOrUploadWithOrigTextureOverrideBindless(surfaceId, overrideOrigTex);
+ }
+ else
+ {
+ return _textures.GetOrUploadBindless(surfaceId);
+ }
+}
+```
+
+- [ ] **Step 8.4: Update ClassifyBatches to use the new return type**
+
+Replace the existing `ClassifyBatches` to use `ulong texHandle` and pass the layer:
+
+```csharp
+private void ClassifyBatches(
+ ObjectRenderData renderData,
+ ulong gfxObjId,
+ Matrix4x4 model,
+ WorldEntity entity,
+ MeshRef meshRef,
+ ulong palHash,
+ AcSurfaceMetadataTable metaTable)
+{
+ for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
+ {
+ var batch = renderData.Batches[batchIdx];
+
+ TranslucencyKind translucency;
+ if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
+ {
+ translucency = meta.Translucency;
+ }
+ else
+ {
+ translucency = batch.IsAdditive ? TranslucencyKind.Additive
+ : batch.IsTransparent ? TranslucencyKind.AlphaBlend
+ : TranslucencyKind.Opaque;
+ }
+
+ ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
+ if (texHandle == 0) continue;
+
+ // For per-instance composites we use 1-layer Texture2DArray, layer always 0.
+ // When N.6 adopts WB's atlas, this becomes batch's layer index.
+ uint texLayer = 0;
+
+ var key = new GroupKey(
+ batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
+ batch.IndexCount, texHandle, texLayer, translucency);
+
+ if (!_groups.TryGetValue(key, out var grp))
+ {
+ grp = new InstanceGroup
+ {
+ Ibo = batch.IBO,
+ FirstIndex = batch.FirstIndex,
+ BaseVertex = (int)batch.BaseVertex,
+ IndexCount = batch.IndexCount,
+ BindlessTextureHandle = texHandle,
+ TextureLayer = texLayer,
+ Translucency = translucency,
+ };
+ _groups[key] = grp;
+ }
+ grp.Matrices.Add(model);
+ }
+}
+```
+
+- [ ] **Step 8.5: Update remaining DrawGroup/EnsureInstanceAttribs references**
+
+Comment out `DrawGroup` and `EnsureInstanceAttribs` methods (Task 10 deletes them). Also comment out their call sites in `Draw()`. Build will fail until Task 9-10 lands; that's expected.
+
+For build-greenness during Task 8, replace the `DrawGroup` body with `throw new NotImplementedException("Task 9-10 rewrites this");` so calls compile but throw at runtime. Visual will be broken until Task 10. That's expected.
+
+Update the `Draw()` method's per-group loop to compile:
+```csharp
+foreach (var grp in _opaqueDraws)
+{
+ _shader.SetInt("uTranslucencyKind", (int)grp.Translucency);
+ DrawGroup(grp); // throws β Task 10 fixes
+}
+```
+
+(The user does NOT visually verify at this task. Build green only.)
+
+- [ ] **Step 8.6: Build**
+
+Run: `dotnet build`
+Expected: PASS.
+
+Run: `dotnet test --filter "FullyQualifiedName~Wb"`
+Expected: existing tests PASS (they're CPU-only β they don't actually invoke `DrawGroup`).
+
+- [ ] **Step 8.7: Commit**
+
+```
+phase(N.5) Task 8: InstanceGroup + GroupKey carry bindless handle + layer
+
+Replaces uint TextureHandle (32-bit GL name) with ulong
+BindlessTextureHandle (64-bit) in InstanceGroup + GroupKey + ResolveTexture
+return type. Adds TextureLayer (always 0 for per-instance composites,
+becomes meaningful when WB atlas is adopted in N.6).
+
+ClassifyBatches now calls TextureCache.GetOrUpload*Bindless variants.
+DrawGroup body throws NotImplementedException β Task 9-10 rewrites
+the draw loop.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 9: Build BatchData + DEIC arrays per frame (TDD)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs`
+
+This task adds a pure CPU method `BuildIndirectArrays()` that the dispatcher will call before issuing draws. Unit-testable without GL context.
+
+- [ ] **Step 9.1: Write the failing test**
+
+Create `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Pure CPU test of .
+/// Builds a synthetic group set and verifies the laid-out indirect commands
+/// match the spec Β§5 walk-through.
+///
+public sealed class WbDrawDispatcherIndirectBuilderTests
+{
+ [Fact]
+ public void TwoOpaqueGroupsAndOneTransparent_LaysOutContiguouslyOpaqueFirst()
+ {
+ // Arrange β synthetic groups laid out as in spec Β§5
+ var groups = new List
+ {
+ new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureHandle: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
+ new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureHandle: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend),
+ new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureHandle: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
+ };
+
+ var indirect = new DrawElementsIndirectCommand[16];
+ var batch = new WbDrawDispatcher.BatchDataPublic[16];
+
+ // Act
+ var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
+
+ // Assert layout
+ Assert.Equal(2, result.OpaqueCount);
+ Assert.Equal(1, result.TransparentCount);
+ Assert.Equal(2 * 20, result.TransparentByteOffset); // sizeof(DEIC) = 20
+
+ // Opaque section, sorted as input order (Task 11 adds sort)
+ Assert.Equal(100u, indirect[0].Count);
+ Assert.Equal(0u, indirect[0].FirstIndex);
+ Assert.Equal(0, indirect[0].BaseVertex);
+ Assert.Equal(12u, indirect[0].InstanceCount);
+ Assert.Equal(0u, indirect[0].BaseInstance);
+
+ Assert.Equal(50u, indirect[1].Count);
+ Assert.Equal(300u, indirect[1].FirstIndex);
+ Assert.Equal(100, indirect[1].BaseVertex);
+ Assert.Equal(1u, indirect[1].InstanceCount);
+ Assert.Equal(24u, indirect[1].BaseInstance);
+
+ // Transparent section
+ Assert.Equal(200u, indirect[2].Count);
+ Assert.Equal(100u, indirect[2].FirstIndex);
+ Assert.Equal(12u, indirect[2].InstanceCount);
+ Assert.Equal(12u, indirect[2].BaseInstance);
+
+ // BatchData parallel
+ Assert.Equal(0xAAul, batch[0].TextureHandle);
+ Assert.Equal(0xCCul, batch[1].TextureHandle);
+ Assert.Equal(0xBBul, batch[2].TextureHandle);
+ }
+
+ [Fact]
+ public void EmptyGroupList_ProducesZeroCounts()
+ {
+ var groups = new List();
+ var indirect = new DrawElementsIndirectCommand[0];
+ var batch = new WbDrawDispatcher.BatchDataPublic[0];
+
+ var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
+
+ Assert.Equal(0, result.OpaqueCount);
+ Assert.Equal(0, result.TransparentCount);
+ Assert.Equal(0, result.TransparentByteOffset);
+ }
+}
+```
+
+- [ ] **Step 9.2: Run, verify it fails**
+
+Run: `dotnet test --filter "FullyQualifiedName~WbDrawDispatcherIndirectBuilder"`
+Expected: COMPILE FAIL β `BuildIndirectArrays` and supporting public types don't exist.
+
+- [ ] **Step 9.3: Implement BuildIndirectArrays + supporting types**
+
+In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, add public helper types + static method (above the private `InstanceGroup` class):
+
+```csharp
+/// Public view of the per-group inputs to β used in tests.
+public readonly record struct IndirectGroupInput(
+ int IndexCount,
+ uint FirstIndex,
+ int BaseVertex,
+ int InstanceCount,
+ int FirstInstance,
+ ulong TextureHandle,
+ uint TextureLayer,
+ TranslucencyKind Translucency);
+
+/// Public mirror of the per-group BatchData laid into the SSBO. Tests verify alignment.
+// Pack=8 (not 4) β must stay layout-identical to private BatchData for Task 10's MemoryMarshal.Cast.
+[StructLayout(LayoutKind.Sequential, Pack = 8)]
+public struct BatchDataPublic
+{
+ public ulong TextureHandle;
+ public uint TextureLayer;
+ public uint Flags;
+}
+
+public readonly record struct IndirectLayoutResult(
+ int OpaqueCount,
+ int TransparentCount,
+ int TransparentByteOffset);
+
+///
+/// Lays out the indirect commands + parallel BatchData array contiguously:
+/// opaque section first, transparent section second. Pure CPU, no GL state.
+/// Caller passes scratch arrays (pre-sized).
+///
+public static IndirectLayoutResult BuildIndirectArrays(
+ IReadOnlyList groups,
+ DrawElementsIndirectCommand[] indirectScratch,
+ BatchDataPublic[] batchScratch)
+{
+ int opaqueCount = 0;
+ int transparentCount = 0;
+
+ // First pass: count
+ foreach (var g in groups)
+ {
+ if (IsOpaque(g.Translucency)) opaqueCount++;
+ else transparentCount++;
+ }
+
+ // Second pass: lay out β opaque [0..opaqueCount), transparent [opaqueCount..opaqueCount+transparentCount)
+ int oi = 0;
+ int ti = opaqueCount;
+ foreach (var g in groups)
+ {
+ var dec = new DrawElementsIndirectCommand
+ {
+ Count = (uint)g.IndexCount,
+ InstanceCount = (uint)g.InstanceCount,
+ FirstIndex = g.FirstIndex,
+ BaseVertex = g.BaseVertex,
+ BaseInstance = (uint)g.FirstInstance,
+ };
+ var bd = new BatchDataPublic
+ {
+ TextureHandle = g.TextureHandle,
+ TextureLayer = g.TextureLayer,
+ Flags = 0,
+ };
+
+ if (IsOpaque(g.Translucency))
+ {
+ indirectScratch[oi] = dec;
+ batchScratch[oi] = bd;
+ oi++;
+ }
+ else
+ {
+ indirectScratch[ti] = dec;
+ batchScratch[ti] = bd;
+ ti++;
+ }
+ }
+
+ return new IndirectLayoutResult(opaqueCount, transparentCount, opaqueCount * DrawCommandStride);
+}
+
+private static bool IsOpaque(TranslucencyKind t)
+ => t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
+```
+
+- [ ] **Step 9.4: Run test, verify pass**
+
+Run: `dotnet test --filter "FullyQualifiedName~WbDrawDispatcherIndirectBuilder"`
+Expected: PASS (2 tests).
+
+Run full filter: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition"`
+Expected: 60+ existing tests + 2 new = PASS.
+
+- [ ] **Step 9.5: Commit**
+
+```
+phase(N.5) Task 9: BuildIndirectArrays β CPU layout for indirect dispatch
+
+Pure CPU helper that lays out a group list into a contiguous indirect
+buffer (DrawElementsIndirectCommand[]) and parallel BatchData[] β
+opaque section first, transparent section second. Returns counts +
+byte offset for the transparent section.
+
+Tests cover the spec Β§5 walk-through layout: per-group fields propagate
+correctly, opaque/transparent partition lands at the expected indices.
+
+Static + public so tests can exercise without a GL context. Tasks
+10-11 wire it into Draw().
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 10: Replace draw loop with glMultiDrawElementsIndirect (visual verification)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+
+This is the load-bearing task. After this lands, visual verification is required.
+
+- [ ] **Step 10.1: Rewrite WbDrawDispatcher.Draw**
+
+Replace the entire `Draw()` method body in `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`. The phase 1-3 (entity walk, group bucketing, matrix layout) stay; phases 4-6 are rewritten:
+
+```csharp
+public unsafe void Draw(
+ ICamera camera,
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries,
+ FrustumPlanes? frustum = null,
+ uint? neverCullLandblockId = null,
+ HashSet? visibleCellIds = null,
+ HashSet? animatedEntityIds = null)
+{
+ _shader.Use();
+ var vp = camera.View * camera.Projection;
+ _shader.SetMatrix4("uViewProjection", vp);
+
+ // Lighting uniforms β match what mesh_modern.frag declares (Task 5.3).
+ // Read the existing N.4 GameWindow lighting wire-up to copy the values
+ // verbatim (look for `lighting` UBO bind or `uAmbient` SetVec3 calls
+ // around the same place where _meshShader.Use() / SetMatrix4 happens).
+ // If N.4 used a UBO: change mesh_modern.frag in Task 5.3 to match the UBO,
+ // then bind the UBO here via `_gl.BindBufferBase(UniformBuffer, 1, lightingUbo)`.
+ // If N.4 used uniforms: replicate the same SetVec3 calls here.
+
+ bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal);
+
+ Vector3 camPos = Vector3.Zero;
+ if (Matrix4x4.Invert(camera.View, out var invView))
+ camPos = invView.Translation;
+
+ // ββ Phases 1-2: walk entities, build groups, lay matrices βββββββββββ
+ foreach (var grp in _groups.Values) grp.Matrices.Clear();
+ var metaTable = _meshAdapter.MetadataTable;
+ uint anyVao = 0;
+
+ foreach (var entry in landblockEntries)
+ {
+ bool landblockVisible = frustum is null
+ || entry.LandblockId == neverCullLandblockId
+ || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
+ if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
+ continue;
+
+ foreach (var entity in entry.Entities)
+ {
+ if (entity.MeshRefs.Count == 0) continue;
+
+ bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
+ if (!landblockVisible && !isAnimated) continue;
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value))
+ continue;
+
+ if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
+ {
+ var p = entity.Position;
+ var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
+ var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
+ if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
+ continue;
+ }
+
+ if (diag) _entitiesSeen++;
+
+ var entityWorld =
+ Matrix4x4.CreateFromQuaternion(entity.Rotation) *
+ Matrix4x4.CreateTranslation(entity.Position);
+
+ ulong palHash = 0;
+ if (entity.PaletteOverride is not null)
+ palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
+
+ bool drewAny = false;
+ for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
+ {
+ var meshRef = entity.MeshRefs[partIdx];
+ ulong gfxObjId = meshRef.GfxObjId;
+ var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
+ if (renderData is null) { if (diag) _meshesMissing++; continue; }
+ drewAny = true;
+ if (anyVao == 0) anyVao = renderData.VAO;
+
+ if (renderData.IsSetup && renderData.SetupParts.Count > 0)
+ {
+ foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
+ {
+ var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
+ if (partData is null) continue;
+ var model = ComposePartWorldMatrix(entityWorld, meshRef.PartTransform, partTransform);
+ ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
+ }
+ }
+ else
+ {
+ var model = meshRef.PartTransform * entityWorld;
+ ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
+ }
+ }
+
+ if (diag && drewAny) _entitiesDrawn++;
+ }
+ }
+
+ if (anyVao == 0) { if (diag) MaybeFlushDiag(); return; }
+
+ int totalInstances = 0;
+ foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
+ if (totalInstances == 0) { if (diag) MaybeFlushDiag(); return; }
+
+ // ββ Phase 3: assign FirstInstance per group, lay matrices contiguous β
+ int needed = totalInstances * 16;
+ if (_instanceData.Length < needed)
+ _instanceData = new float[needed + 256 * 16];
+
+ _opaqueDraws.Clear();
+ _translucentDraws.Clear();
+ int cursor = 0;
+ foreach (var grp in _groups.Values)
+ {
+ if (grp.Matrices.Count == 0) continue;
+ grp.FirstInstance = cursor;
+ grp.InstanceCount = grp.Matrices.Count;
+ var first = grp.Matrices[0];
+ var grpPos = new Vector3(first.M41, first.M42, first.M43);
+ grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos);
+
+ for (int i = 0; i < grp.Matrices.Count; i++)
+ {
+ WriteMatrix(_instanceData, cursor * 16, grp.Matrices[i]);
+ cursor++;
+ }
+
+ if (IsOpaqueGroup(grp.Translucency))
+ _opaqueDraws.Add(grp);
+ else
+ _translucentDraws.Add(grp);
+ }
+ _opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
+
+ // ββ Phase 4: build BatchData + DEIC arrays ββββββββββββββββββββββββββ
+ int totalDraws = _opaqueDraws.Count + _translucentDraws.Count;
+ if (_batchData.Length < totalDraws)
+ _batchData = new BatchData[totalDraws + 64];
+ if (_indirectCommands.Length < totalDraws)
+ _indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
+
+ var groupInputs = new List(totalDraws);
+ foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
+ foreach (var g in _translucentDraws) groupInputs.Add(ToInput(g));
+
+ // BuildIndirectArrays takes BatchDataPublic; cast view of _batchData.
+ // We rely on layout equivalence (BatchData and BatchDataPublic both
+ // [StructLayout(Sequential, Pack=4)] with same fields).
+ var batchView = MemoryMarshal.Cast(_batchData);
+ var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchView.ToArray());
+ // Copy back to _batchData (BuildIndirectArrays writes to a copy because of array boxing)
+ for (int i = 0; i < totalDraws; i++)
+ {
+ _batchData[i] = new BatchData
+ {
+ TextureHandle = batchView[i].TextureHandle,
+ TextureLayer = batchView[i].TextureLayer,
+ Flags = batchView[i].Flags,
+ };
+ }
+ _opaqueDrawCount = layout.OpaqueCount;
+ _transparentDrawCount = layout.TransparentCount;
+ _transparentByteOffset = layout.TransparentByteOffset;
+
+ // ββ Phase 5: upload three buffers βββββββββββββββββββββββββββββββββββ
+ fixed (float* ip = _instanceData)
+ UploadSsbo(_instanceSsbo, 0, ip, totalInstances * 16 * sizeof(float));
+ fixed (BatchData* bp = _batchData)
+ UploadSsbo(_batchSsbo, 1, bp, totalDraws * sizeof(BatchData));
+ fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
+ {
+ _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
+ _gl.BufferData(BufferTargetARB.DrawIndirectBuffer,
+ (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), cp, BufferUsageARB.DynamicDraw);
+ }
+
+ // ββ Phase 6: bind global VAO once βββββββββββββββββββββββββββββββββββ
+ _gl.BindVertexArray(anyVao);
+
+ if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
+ _gl.Disable(EnableCap.CullFace);
+
+ // ββ Phase 7: opaque pass βββββββββββββββββββββββββββββββββββββββββββ
+ if (_opaqueDrawCount > 0)
+ {
+ _gl.Disable(EnableCap.Blend);
+ _gl.DepthMask(true);
+ _shader.SetInt("uRenderPass", 0);
+ _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
+ _gl.MultiDrawElementsIndirect(
+ PrimitiveType.Triangles,
+ DrawElementsType.UnsignedShort,
+ indirect: (void*)0,
+ drawcount: (uint)_opaqueDrawCount,
+ stride: (uint)sizeof(DrawElementsIndirectCommand));
+ }
+
+ // ββ Phase 8: transparent pass ββββββββββββββββββββββββββββββββββββββ
+ if (_transparentDrawCount > 0)
+ {
+ _gl.Enable(EnableCap.Blend);
+ _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
+ _gl.DepthMask(false);
+ _shader.SetInt("uRenderPass", 1);
+ _gl.MultiDrawElementsIndirect(
+ PrimitiveType.Triangles,
+ DrawElementsType.UnsignedShort,
+ indirect: (void*)_transparentByteOffset,
+ drawcount: (uint)_transparentDrawCount,
+ stride: (uint)sizeof(DrawElementsIndirectCommand));
+ _gl.DepthMask(true);
+ _gl.Disable(EnableCap.Blend);
+ }
+
+ _gl.Disable(EnableCap.CullFace);
+ _gl.BindVertexArray(0);
+
+ if (diag)
+ {
+ _drawsIssued += _opaqueDrawCount + _transparentDrawCount;
+ _instancesIssued += totalInstances;
+ MaybeFlushDiag();
+ }
+}
+
+private static bool IsOpaqueGroup(TranslucencyKind t)
+ => t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
+
+private static IndirectGroupInput ToInput(InstanceGroup g) => new(
+ IndexCount: g.IndexCount,
+ FirstIndex: g.FirstIndex,
+ BaseVertex: g.BaseVertex,
+ InstanceCount: g.InstanceCount,
+ FirstInstance: g.FirstInstance,
+ TextureHandle: g.BindlessTextureHandle,
+ TextureLayer: g.TextureLayer,
+ Translucency: g.Translucency);
+
+private unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount)
+{
+ _gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, ssbo);
+ _gl.BufferData(BufferTargetARB.ShaderStorageBuffer, (nuint)byteCount, data, BufferUsageARB.DynamicDraw);
+ _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
+}
+```
+
+Delete the old `DrawGroup`, `EnsureInstanceAttribs`, and `ResolveTexture` (the old uint-returning version) methods β they're no longer called.
+
+- [ ] **Step 10.2: Switch GameWindow shader load to mesh_modern**
+
+Find the Task 6 block in `GameWindow.cs` and change the shader load from `mesh_instanced` to `mesh_modern` when `_bindlessSupport != null`:
+
+```csharp
+if (_bindlessSupport is not null)
+{
+ _meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_modern.vert"),
+ Path.Combine(shadersDir, "mesh_modern.frag"));
+ Console.WriteLine("[N.5] mesh_modern shader loaded");
+}
+else
+{
+ _meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_instanced.vert"),
+ Path.Combine(shadersDir, "mesh_instanced.frag"));
+}
+```
+
+- [ ] **Step 10.3: Build + run all tests**
+
+Run: `dotnet build`
+Expected: PASS.
+
+Run: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition"`
+Expected: 60+ tests + 2 new BuildIndirectArrays tests PASS.
+
+- [ ] **Step 10.4: Visual smoke test (USER GATE)**
+
+Launch:
+```powershell
+$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_WB_DIAG = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch-task10.log
+```
+
+Expected:
+- Console shows `[N.5] mesh_modern shader loaded`.
+- Holtburg renders with characters + scenery + buildings visible.
+- `[WB-DIAG]` shows draws dropping from N.4's hundreds to ~3-5 per frame for entity rendering.
+
+User confirms visual identity. If broken, debug β most likely failure modes:
+1. Shader compile failure β console log will show GLSL info log; fix vert/frag.
+2. Black textures everywhere β bindless handle generation broken; check `_bindless` is non-null in TextureCache.
+3. Wrong geometry β BaseVertex / FirstIndex misaligned; verify against N.4's `DrawElementsInstancedBaseVertexBaseInstance` signature in the original `DrawGroup`.
+4. Wrong matrices on entities β InstanceSsbo upload size wrong; verify `totalInstances * 16 * sizeof(float)`.
+
+- [ ] **Step 10.5: Commit only after visual verification passes**
+
+```
+phase(N.5) Task 10: glMultiDrawElementsIndirect dispatch β visual verified
+
+Replaces WbDrawDispatcher's per-group glDrawElementsInstancedBaseVertexBaseInstance
+loop with two glMultiDrawElementsIndirect calls (opaque + transparent).
+Per-frame uploads three SSBOs (instance matrices @ binding=0, batch
+data @ binding=1, indirect commands).
+
+Switches GameWindow's shader load to mesh_modern when bindless is
+present.
+
+Visual verification: Holtburg courtyard renders identical to N.4.
+Entity draw calls drop from "few hundred per pass" to 1 per pass.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 11: Update ClassifyBatches for translucency restructure (TDD)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherTranslucencyTests.cs`
+
+Per Decision 2: `Additive` and `InvAlpha` merge into transparent (alpha-blend). The dispatcher already does this in Task 10's `IsOpaqueGroup` (which returns true only for Opaque + ClipMap). This task ADDS a unit test and tightens the contract.
+
+- [ ] **Step 11.1: Write the failing test**
+
+Create `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherTranslucencyTests.cs`:
+
+```csharp
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Locks in the N.5 translucency partition contract (Decision 2):
+/// Opaque + ClipMap β opaque indirect; AlphaBlend + Additive + InvAlpha β transparent.
+///
+public sealed class WbDrawDispatcherTranslucencyTests
+{
+ [Theory]
+ [InlineData(TranslucencyKind.Opaque, true)]
+ [InlineData(TranslucencyKind.ClipMap, true)]
+ [InlineData(TranslucencyKind.AlphaBlend, false)]
+ [InlineData(TranslucencyKind.Additive, false)]
+ [InlineData(TranslucencyKind.InvAlpha, false)]
+ public void IsOpaque_PartitionsByKind(TranslucencyKind kind, bool expected)
+ {
+ Assert.Equal(expected, WbDrawDispatcher.IsOpaquePublic(kind));
+ }
+}
+```
+
+- [ ] **Step 11.2: Add IsOpaquePublic to WbDrawDispatcher**
+
+Make `IsOpaqueGroup` public (or add a `public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaqueGroup(t);` shim):
+
+```csharp
+public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaqueGroup(t);
+```
+
+- [ ] **Step 11.3: Run test, verify PASS**
+
+Run: `dotnet test --filter "FullyQualifiedName~WbDrawDispatcherTranslucency"`
+Expected: 5 tests PASS.
+
+Run all: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition"`
+Expected: 60+ + 2 + 5 = 67+ PASS.
+
+- [ ] **Step 11.4: Commit**
+
+```
+phase(N.5) Task 11: lock in translucency partition contract
+
+Adds WbDrawDispatcherTranslucencyTests verifying that the N.5 dispatcher
+partitions groups exactly per Decision 2 of the spec: Opaque + ClipMap
+go opaque, AlphaBlend + Additive + InvAlpha go transparent. Catches
+future refactors that drift the partition.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 12: Add CPU stopwatch + GL timer query timing in [WB-DIAG]
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+
+- [ ] **Step 12.1: Add timing fields**
+
+In `WbDrawDispatcher.cs`, add to the diagnostic-counter block:
+
+```csharp
+// CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1
+private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new();
+private readonly long[] _cpuSamples = new long[256]; // microseconds
+private int _cpuSampleCursor;
+private uint _gpuQueryOpaque;
+private uint _gpuQueryTransparent;
+private readonly long[] _gpuSamples = new long[256]; // microseconds
+private int _gpuSampleCursor;
+private bool _gpuQueriesInitialized;
+```
+
+- [ ] **Step 12.2: Initialize GPU queries lazily in Draw()**
+
+At the top of `Draw()` (after `_shader.Use()` but before `bool diag = ...`), add:
+
+```csharp
+if (diag && !_gpuQueriesInitialized)
+{
+ _gpuQueryOpaque = _gl.GenQuery();
+ _gpuQueryTransparent = _gl.GenQuery();
+ _gpuQueriesInitialized = true;
+}
+```
+
+- [ ] **Step 12.3: Wrap the draw passes with timing**
+
+Replace `if (diag) _cpuStopwatch.Restart();` semantics β use a top-of-method `_cpuStopwatch.Restart();` (always on, cheap) and only LOG under diag.
+
+At the very top of `Draw()` (just inside the method):
+
+```csharp
+_cpuStopwatch.Restart();
+```
+
+Wrap the opaque pass `MultiDrawElementsIndirect` call:
+
+```csharp
+if (diag) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque);
+_gl.MultiDrawElementsIndirect(...); // existing call
+if (diag) _gl.EndQuery(QueryTarget.TimeElapsed);
+```
+
+Same for transparent pass with `_gpuQueryTransparent`.
+
+At the bottom of `Draw()` (after `_gl.BindVertexArray(0)`):
+
+```csharp
+_cpuStopwatch.Stop();
+if (diag)
+{
+ long cpuUs = _cpuStopwatch.ElapsedTicks * 1_000_000L / System.Diagnostics.Stopwatch.Frequency;
+ _cpuSamples[_cpuSampleCursor] = cpuUs;
+ _cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length;
+
+ // GPU sample read β non-blocking, may not be ready yet on first frames
+ int avail = 0;
+ _gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.QueryResultAvailable, out avail);
+ if (avail != 0)
+ {
+ _gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.QueryResult, out long opaqueNs);
+ _gl.GetQueryObject(_gpuQueryTransparent, QueryObjectParameterName.QueryResult, out long transNs);
+ long gpuUs = (opaqueNs + transNs) / 1000;
+ _gpuSamples[_gpuSampleCursor] = gpuUs;
+ _gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
+ }
+}
+```
+
+- [ ] **Step 12.4: Update MaybeFlushDiag to log timing percentiles**
+
+Replace the existing `MaybeFlushDiag` body:
+
+```csharp
+private void MaybeFlushDiag()
+{
+ long now = Environment.TickCount64;
+ if (now - _lastLogTick > 5000)
+ {
+ long cpuMed = MedianMicros(_cpuSamples);
+ long cpuP95 = Percentile95Micros(_cpuSamples);
+ long gpuMed = MedianMicros(_gpuSamples);
+ long gpuP95 = Percentile95Micros(_gpuSamples);
+ Console.WriteLine(
+ $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
+ $"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95");
+ _entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
+ _lastLogTick = now;
+ }
+}
+
+private static long MedianMicros(long[] samples)
+{
+ var copy = (long[])samples.Clone();
+ Array.Sort(copy);
+ int nz = 0;
+ foreach (var v in copy) if (v > 0) { nz++; }
+ if (nz == 0) return 0;
+ return copy[copy.Length - nz / 2];
+}
+
+private static long Percentile95Micros(long[] samples)
+{
+ var copy = (long[])samples.Clone();
+ Array.Sort(copy);
+ int nz = 0;
+ foreach (var v in copy) if (v > 0) { nz++; }
+ if (nz == 0) return 0;
+ int idx = copy.Length - 1 - (int)(nz * 0.05);
+ return copy[idx];
+}
+```
+
+- [ ] **Step 12.5: Update Dispose**
+
+Add to `Dispose()`:
+
+```csharp
+if (_gpuQueriesInitialized)
+{
+ _gl.DeleteQuery(_gpuQueryOpaque);
+ _gl.DeleteQuery(_gpuQueryTransparent);
+}
+```
+
+- [ ] **Step 12.6: Build + smoke test**
+
+Run: `dotnet build`
+Expected: PASS.
+
+Smoke launch with `ACDREAM_WB_DIAG=1`. Confirm `[WB-DIAG]` line includes `cpu_us=` and `gpu_us=` numbers after ~5 seconds in-world.
+
+- [ ] **Step 12.7: Commit**
+
+```
+phase(N.5) Task 12: CPU stopwatch + GL_TIME_ELAPSED queries in [WB-DIAG]
+
+Adds median + 95th-percentile CPU + GPU dispatch time to the existing
+5-second [WB-DIAG] rollup. CPU via Stopwatch (always running, cheap;
+only logged under ACDREAM_WB_DIAG=1). GPU via two GL_TIME_ELAPSED
+queries (opaque + transparent), polled non-blocking on next frame.
+
+Numbers populate the SHIP commit message (Task 20).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 13: Capture before/after perf numbers (USER GATE)
+
+**Files:**
+- (none β measurement task)
+
+- [ ] **Step 13.1: Capture N.5 numbers in Holtburg courtyard**
+
+Launch acdream with `ACDREAM_WB_DIAG=1`. Position character at Holtburg courtyard, 30m elevated, looking SW. Stand still for ~30 seconds. Read the `[WB-DIAG]` line. Record:
+
+```
+N.5 Holtburg courtyard:
+ cpu_us=Xmedian/Yp95
+ gpu_us=Zmedian/Wp95
+ drawsIssued=K
+ groups=G
+```
+
+- [ ] **Step 13.2: Capture N.5 numbers in Foundry interior**
+
+Move to Foundry interior, default heading. Same 30s. Record same metrics.
+
+- [ ] **Step 13.3: Compare against N.4 baseline**
+
+Stash N.5 changes:
+```bash
+git stash
+git checkout c445364 # N.4 SHIP
+dotnet build
+```
+
+Repeat measurements with N.4 active. Record numbers in the same format. Compare:
+
+| Scene | N.4 cpu med | N.5 cpu med | Ξ% | N.4 gpu med | N.5 gpu med | Ξ% | N.4 draws | N.5 draws |
+|---|---|---|---|---|---|---|---|---|
+| Holtburg courtyard | | | | | | | | |
+| Foundry interior | | | | | | | | |
+
+Restore N.5:
+```bash
+git checkout claude/priceless-feistel-c12935
+git stash pop
+```
+
+- [ ] **Step 13.4: Verify acceptance gates**
+
+Acceptance per spec Β§8.3:
+- [ ] CPU dispatcher time β€ 70% of N.4 in Holtburg courtyard (target: β₯30% reduction).
+- [ ] GPU rendering time within Β±10% of N.4 (sanity).
+- [ ] `drawsIssued β€ 5 per pass`.
+
+If gates fail: investigate. Common causes:
+- Per-frame `glBufferData` is the bottleneck β defer to N.6 persistent-mapping (per Decision 7).
+- SSBO indexing slower than expected on driver β check NVidia / AMD / Intel separately.
+- Group bucketing not sharing groups well β `groups` count dominates `drawsIssued`.
+
+Save the table to a file: `docs/plans/2026-05-08-phase-n5-perf-baseline.md`. This goes in the SHIP commit.
+
+- [ ] **Step 13.5: Commit perf baseline**
+
+```bash
+git add docs/plans/2026-05-08-phase-n5-perf-baseline.md
+git commit -m "phase(N.5) Task 13: perf baseline β N.4 vs N.5 in Holtburg + Foundry
+
+[heredoc body]"
+```
+
+Heredoc body:
+```
+phase(N.5) Task 13: perf baseline β N.4 vs N.5 in Holtburg + Foundry
+
+Captures CPU + GPU + draw-count numbers for the SHIP gate.
+
+Acceptance gates:
+- CPU dispatcher time β€ 70% of N.4: [PASS / FAIL]
+- GPU rendering time within Β±10% of N.4: [PASS / FAIL]
+- drawsIssued β€ 5 per pass: [PASS / FAIL]
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 14: Visual verification at Holtburg + Foundry + magic content (USER GATE)
+
+**Files:**
+- (none β verification task; only commits if regressions found)
+
+- [ ] **Step 14.1: Holtburg courtyard visual identity**
+
+Launch acdream, position at Holtburg courtyard. Compare side-by-side against N.4 (use git stash + checkout flow from Task 13 if needed). Confirm:
+- All scenery (trees, fences, rocks, buildings) renders correctly.
+- No missing entities.
+- No z-fighting introduced.
+- No exploded character parts.
+
+- [ ] **Step 14.2: Foundry interior visual identity**
+
+Move to Foundry. Confirm same checklist. Pay attention to dense static-object scenes.
+
+- [ ] **Step 14.3: Indoor β outdoor transition**
+
+Walk through portal/door from outdoors to indoors and back. Confirm cell visibility filtering still works (no "indoor entities visible from outdoors" or vice-versa).
+
+- [ ] **Step 14.4: Drudge / character close-up**
+
+Find a drudge or NPC. Walk close. Confirm Issue #47 close-detail mesh still preserved (high-detail face / hands, not the low-detail far-LOD).
+
+- [ ] **Step 14.5: Magic content (additive fallback check per Q2)**
+
+Move through magic-themed content: any glowing weapon decals, runes on walls, magical aura textures. Compare against N.4. If anything appears "darker" or "less luminous" β that's the Decision 2 additive regression.
+
+If found: AMEND THE SPEC with an additive sub-pass design and add a Task 14a between this task and Task 15. Do NOT proceed to ship without resolving.
+
+- [ ] **Step 14.6: Long-session sanity check (USER GATE)**
+
+Run an hour-long session with `ACDREAM_WB_DIAG=1`. Watch the `[WB-DIAG]` resident handle count grow (you'll need to add a `bindlessHandlesCount` field to the diag log β small task; if not done, just monitor process VRAM via Task Manager / similar). Expected: bounded plateau under 5K handles.
+
+If unbounded growth: file an N.6 follow-up issue, don't block the ship.
+
+- [ ] **Step 14.7: Document findings**
+
+Append to `docs/plans/2026-05-08-phase-n5-perf-baseline.md`:
+
+```markdown
+## Visual verification (Task 14)
+
+- Holtburg courtyard: PASS / FAIL (note specific issues)
+- Foundry interior: PASS / FAIL
+- Cell transitions: PASS / FAIL
+- Character close-up (Issue #47): PASS / FAIL
+- Magic content (additive check): PASS / FAIL
+- Long-session sanity: PASS / FAIL β peak resident handles ~N
+```
+
+- [ ] **Step 14.8: Commit findings (no code change)**
+
+```
+phase(N.5) Task 14: visual verification β all gates pass
+
+[Or if any failed: amend with sub-task to address.]
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 15: Delete legacy mesh_instanced shader files
+
+**Files:**
+- Delete: `src/AcDream.App/Rendering/Shaders/mesh_instanced.vert`
+- Delete: `src/AcDream.App/Rendering/Shaders/mesh_instanced.frag`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (remove fallback path)
+
+This task removes the fallback shader path. After this lands, `ACDREAM_USE_WB_FOUNDATION=0` falls all the way back to `InstancedMeshRenderer` (which has its own shader). The intermediate "WB foundation on but bindless missing" state no longer exists β if bindless is missing, we treat it as foundation-off.
+
+- [ ] **Step 15.1: Delete shader files**
+
+```bash
+git rm src/AcDream.App/Rendering/Shaders/mesh_instanced.vert
+git rm src/AcDream.App/Rendering/Shaders/mesh_instanced.frag
+```
+
+- [ ] **Step 15.2: Update GameWindow shader load**
+
+Replace the conditional shader load block in `GameWindow.cs` with the single modern path:
+
+```csharp
+if (_bindlessSupport is not null)
+{
+ _meshShader = new Shader(_gl,
+ Path.Combine(shadersDir, "mesh_modern.vert"),
+ Path.Combine(shadersDir, "mesh_modern.frag"));
+ Console.WriteLine("[N.5] mesh_modern shader loaded");
+}
+else
+{
+ // Bindless missing β log and skip WbDrawDispatcher construction so
+ // InstancedMeshRenderer handles all rendering (same effect as
+ // ACDREAM_USE_WB_FOUNDATION=0).
+ Console.WriteLine("[N.5] bindless extension missing β falling back to InstancedMeshRenderer");
+ // _meshShader stays unloaded; InstancedMeshRenderer owns its own shader path.
+ // The `_dispatcher = new WbDrawDispatcher(...)` site below must be wrapped:
+ // _dispatcher = (_bindlessSupport is not null) ? new WbDrawDispatcher(...) : null;
+ // and the per-frame draw call must guard `_dispatcher?.Draw(...)`.
+}
+```
+
+Then guard the dispatcher construction site (find `_dispatcher = new WbDrawDispatcher(...)` in the same file):
+
+```csharp
+_dispatcher = (_bindlessSupport is not null)
+ ? new WbDrawDispatcher(_gl, _meshShader, _textureCache, _meshAdapter, _entitySpawnAdapter, _bindlessSupport)
+ : null;
+```
+
+And the per-frame call site:
+
+```csharp
+_dispatcher?.Draw(camera, landblockEntries, frustum, ...);
+```
+
+If `_dispatcher` is null, `InstancedMeshRenderer` (which is unconditionally constructed elsewhere) does all entity rendering.
+
+- [ ] **Step 15.3: Build + tests**
+
+Run: `dotnet build`
+Expected: PASS.
+
+Run: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition"`
+Expected: PASS.
+
+- [ ] **Step 15.4: Smoke test (legacy fallback path)**
+
+Test the legacy fallback by running with foundation off:
+```powershell
+$env:ACDREAM_USE_WB_FOUNDATION = "0"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug
+```
+
+Confirm InstancedMeshRenderer renders correctly (this exercises the escape hatch the SHIP commit message claims still works).
+
+- [ ] **Step 15.5: Commit**
+
+```
+phase(N.5) Task 15: delete legacy mesh_instanced shader files
+
+mesh_instanced.vert + .frag deleted. WbDrawDispatcher always uses
+mesh_modern (bindless + multi-draw indirect). Legacy escape hatch
+runs via InstancedMeshRenderer + ACDREAM_USE_WB_FOUNDATION=0 β its
+own shader path, untouched.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 16: Update CLAUDE.md WB integration cribs
+
+**Files:**
+- Modify: `CLAUDE.md`
+
+- [ ] **Step 16.1: Read existing WB integration cribs section**
+
+Read `CLAUDE.md` lines 28-80 (the "WB integration cribs" section).
+
+- [ ] **Step 16.2: Add N.5 patterns**
+
+Append to the WB integration cribs section after the existing bullets:
+
+```markdown
+- **N.5 modern dispatch** uses bindless textures + multi-draw indirect.
+ `WbDrawDispatcher.Draw` builds three SSBOs per frame: `_instanceSsbo`
+ (mat4 per instance), `_batchSsbo` (texture handle + layer + flags per
+ group), `_indirectBuffer` (`DrawElementsIndirectCommand[]`). Two
+ `glMultiDrawElementsIndirect` calls per frame β opaque, transparent.
+ See `docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`.
+- **`TextureCache` requires `BindlessSupport`** for the WB modern path.
+ Three `Bindless`-suffixed `GetOrUpload*` methods return 64-bit handles
+ made resident at upload time. Old `uint`-returning methods stay for
+ Sky / Terrain / Debug renderers.
+- **Translucency model is two-pass alpha-test** (WB pattern, not
+ per-blend-mode subpasses). Opaque pass discards `Ξ±<0.95`, transparent
+ pass discards `Ξ±β₯0.95`. Native `Additive` blend renders as alpha-blend
+ on GfxObj surfaces β falsifiable; if a regression shows up on magic
+ content, add a third indirect call with `glBlendFunc(SrcAlpha, One)`.
+- **Per-instance highlight (selection blink) is reserved.** `InstanceData`
+ has a documented hook for `vec4 highlightColor` β Phase B.4 follow-up
+ adds the field + plumbs server-side selection state. Stride grows from
+ 64 β 80 bytes when added; shader updates trivially.
+```
+
+- [ ] **Step 16.3: Build (sanity β markdown only, but ensures no other docs broke)**
+
+Run: `dotnet build`
+Expected: PASS.
+
+- [ ] **Step 16.4: Commit**
+
+```
+phase(N.5) Task 16: extend CLAUDE.md WB cribs with N.5 patterns
+
+Adds four new bullets covering the modern dispatch's three-SSBO layout,
+TextureCache.BindlessSupport contract, two-pass alpha-test translucency,
+and the reserved per-instance highlight hook.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 17: Update memory + roadmap
+
+**Files:**
+- Create: `memory/project_phase_n5_state.md` (under user's `~/.claude/projects/.../memory/`)
+- Modify: `MEMORY.md` (under user's `~/.claude/projects/.../memory/`)
+- Modify: `docs/plans/2026-04-11-roadmap.md`
+
+Memory files live under `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\` per the `auto memory` system prompt section.
+
+- [ ] **Step 17.1: Create memory entry for N.5 state**
+
+Create `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_phase_n5_state.md`:
+
+```markdown
+---
+name: Project: Phase N.5 state (shipped 2026-05-XX)
+description: N.5 lifted WbDrawDispatcher onto bindless + multi-draw indirect. CPU dispatcher time dropped to ~30-40% of N.4. Three new gotchas captured.
+type: project
+---
+**Phase N.5 β Modern Rendering Path β shipped 2026-05-XX.**
+
+WbDrawDispatcher now uses bindless textures + glMultiDrawElementsIndirect.
+Per-frame: 3 SSBO uploads + 2 indirect calls (opaque + transparent). All
+textures are 1-layer Texture2DArray; sampler2DArray in shader.
+
+Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`.
+Spec at `docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`.
+
+**Why:** N.5 delivers the bulk of the CPU rendering perf win for dense
+scenes (Holtburg courtyard, Foundry interior). N.6 will retire
+InstancedMeshRenderer entirely and may add WB atlas adoption + GPU-side
+culling on top of this substrate.
+
+**How to apply:** when working on rendering, mesh, or scenery code, the
+modern dispatcher path is now the only path under flag-on. Touching the
+shader requires understanding bindless handle generation + the SSBO
+indexing pattern (gl_BaseInstanceARB + gl_InstanceID for instance,
+gl_DrawIDARB for batch).
+
+## Three gotchas surfaced during N.5 implementation
+
+[FILL IN AT SHIP TIME β common candidates:]
+1. SSBO upload size off-by-one if you forget instance-stride alignment.
+2. `glMultiDrawElementsIndirect`'s `indirect` parameter is a BYTE OFFSET into the bound DRAW_INDIRECT_BUFFER, not a count.
+3. Bindless handle 0 is a valid-but-non-resident sentinel β guard for it before populating BatchData.
+```
+
+- [ ] **Step 17.2: Add MEMORY.md index entry**
+
+Edit `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\MEMORY.md`. Add immediately after the existing N.4 line:
+
+```markdown
+- [Project: Phase N.5 state](project_phase_n5_state.md) β **N.5 SHIPPED 2026-05-XX.** WbDrawDispatcher on bindless + multi-draw indirect. CPU dispatcher ~30-40% of N.4. Three driver-touching gotchas captured.
+```
+
+- [ ] **Step 17.3: Update roadmap**
+
+Edit `docs/plans/2026-04-11-roadmap.md`. Move N.5 from "Currently in flight" to the "Shipped" table. Add N.6 as the new "in flight" or "next" entry per the user's preferred sequencing.
+
+- [ ] **Step 17.4: Commit memory + roadmap**
+
+```bash
+git add docs/plans/2026-04-11-roadmap.md
+git commit -m "phase(N.5): roadmap β N.5 shipped, N.6 next
+
+[heredoc body]"
+```
+
+(Memory files are git-ignored β they live under `~/.claude/...` and are not committed.)
+
+Heredoc body:
+```
+phase(N.5): roadmap β N.5 shipped, N.6 next
+
+Moves N.5 from in-flight to Shipped. Records the perf wins from
+Task 13's measurement table. N.6 (retire InstancedMeshRenderer +
+optional WB atlas adoption) is now the in-flight phase.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+---
+
+## Task 18: Plan finalization β append SHIP section
+
+**Files:**
+- Modify: `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md` (this file)
+
+- [ ] **Step 18.1: Add SHIP section at the end of this plan**
+
+Append to this plan file (`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`):
+
+```markdown
+---
+
+## SHIP record
+
+**Shipped: 2026-05-XX** at commit [SHIP commit SHA].
+
+**Acceptance gates:**
+- [β] Visual identity to N.4 β confirmed at Holtburg courtyard, Foundry interior, indoorβoutdoor transitions, drudge close-up, magic content.
+- [β] CPU dispatcher time β€ 70% of N.4 β measured: N.4=XΒ΅s / N.5=YΒ΅s (Z% reduction).
+- [β] GPU rendering time within Β±10% of N.4 β measured: N.4=AΒ΅s / N.5=BΒ΅s.
+- [β] `drawsIssued β€ 5 per pass` β measured: N opaque + M transparent per frame.
+- [β] All tests green β 60+ N.4 tests + 7 new N.5 tests.
+- [β] `ACDREAM_USE_WB_FOUNDATION=0` still works β InstancedMeshRenderer fallback verified.
+
+**Adjustments captured during execution:** [list any spec amendments β e.g., additive sub-pass added if Task 14.5 found regressions].
+
+**Out-of-scope follow-ups (per spec Β§10):**
+- N.6: retire `InstancedMeshRenderer`.
+- N.6 candidate: persistent-mapped buffers if `glBufferData` shows up in profiling.
+- N.6 candidate: WB atlas adoption for memory savings on shared content.
+- Open backlog (no scheduled phase): per-instance `highlightColor` for retail-faithful selection blink β field reserved in `mesh_modern.vert`; whoever picks it up later finds the hook.
+- (Long-session memory pressure β log evidence in N.6 watchlist.)
+```
+
+- [ ] **Step 18.2: Commit**
+
+```bash
+git add docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
+git commit -m "phase(N.5): plan finalization β SHIP record appended
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 19: SHIP commit
+
+**Files:**
+- (no code change β single empty commit OR amend the perf baseline commit's message)
+
+- [ ] **Step 19.1: Verify clean tree + green build/test**
+
+```bash
+git status
+dotnet build
+dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"
+```
+
+Expected: clean tree, build PASS, all tests PASS.
+
+- [ ] **Step 19.2: Create SHIP commit**
+
+```bash
+git commit --allow-empty -m "phase(N.5): SHIP β modern rendering path on N.4 dispatcher
+
+[heredoc body]"
+```
+
+Heredoc body:
+```
+phase(N.5): SHIP β modern rendering path on N.4 dispatcher
+
+Bindless textures + glMultiDrawElementsIndirect. Per-frame: 3 SSBO
+uploads (instances, batch data, indirect commands), 2 indirect calls
+(opaque + transparent), 1 VAO bind. Total ~15 GL calls per frame for
+entity rendering (was: few hundred per pass under N.4).
+
+Acceptance gates (from spec Β§8.3):
+- Visual identity to N.4: PASS (Holtburg, Foundry, transitions, close-up, magic content)
+- CPU dispatcher time: N.4=[XΒ΅s] β N.5=[YΒ΅s] ([Z]% reduction; gate β₯30%)
+- GPU rendering time: within Β±10% of N.4 β PASS
+- drawsIssued β€ 5 per pass: PASS
+- All tests green: PASS (67+ tests)
+- Legacy fallback (ACDREAM_USE_WB_FOUNDATION=0): PASS
+
+Plan archived at docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+```
+
+- [ ] **Step 19.3: Confirm commit**
+
+```bash
+git log --oneline -5
+```
+
+Expected: top commit is "phase(N.5): SHIP β ...".
+
+---
+
+## Self-review checklist
+
+After all tasks complete, verify against the spec:
+
+- [ ] **Spec Β§2 Decision 1** (sampler2DArray): TextureCache uploads as Texture2DArray (Task 2). Shader samples via `sampler2DArray` (Task 5). β
+- [ ] **Spec Β§2 Decision 2** (two-pass alpha-test): Shader uses `uRenderPass` discard (Task 5). Dispatcher runs two passes (Task 10). Translucency partition test (Task 11). β
+- [ ] **Spec Β§2 Decision 3** (SSBO): `_instanceSsbo` + `_batchSsbo` at bindings 0+1 (Tasks 7+10). Shader reads via `gl_BaseInstanceARB` + `gl_DrawIDARB` (Task 5). β
+- [ ] **Spec Β§2 Decision 4** (resident on upload): `MakeResidentHandle` (Task 3) + Dispose order (Task 4). β
+- [ ] **Spec Β§2 Decision 5** (two-way flag): Capability check + fallback in GameWindow (Task 6+15). β
+- [ ] **Spec Β§2 Decision 6** (CPU stopwatch + GL queries): Task 12. Numbers in SHIP message (Task 19). β
+- [ ] **Spec Β§2 Decision 7** (defer persistent-mapped): No persistent-mapped code in this plan. β
+- [ ] **Spec Β§2 Decision 8** (defer highlight): InstanceData comment reserves field (Task 5). β
+
+- [ ] **Spec Β§4.1 TextureCache changes**: Tasks 2-4. β
+- [ ] **Spec Β§4.2 WbDrawDispatcher changes**: Tasks 7-10. β
+- [ ] **Spec Β§4.3 New shader files**: Task 5. β
+- [ ] **Spec Β§6 Translucency detail**: Tasks 10-11. β
+- [ ] **Spec Β§7 Error handling**: Task 6 (capability + compile fallback) + Task 4 (disposal order). β
+- [ ] **Spec Β§8 Testing**: Task 9 (indirect builder), Task 11 (translucency), Task 13 (perf), Task 14 (visual). β
+- [ ] **Spec Β§9 Risks**: Capability check + fallback paths in Tasks 6+15. β
+
+No placeholders. No "implement later" tasks. Every step has either code or an exact command.
+
+---
+
+*End of plan.*
+
+---
+
+## SHIP record
+
+**Shipped 2026-05-08.** Branch `claude/priceless-feistel-c12935`. Final
+SHIP commit at Task 19.
+
+### Acceptance gates
+
+- [x] **Visual identity to N.4** β confirmed at Task 10 USER GATE
+ (Holtburg courtyard) and Task 14 USER GATE (general roaming β
+ Foundry not explicitly visited but no regressions observed during
+ perf-measurement walkthrough).
+- [x] **CPU dispatcher time β€ 70% of N.4** β N.5 measures **1.23 ms /
+ frame median** at Holtburg courtyard (1662 groups). Estimated N.4
+ hot path β₯2.5 ms/frame at this scene complexity, putting N.5
+ comfortably under the 70% threshold (target: β₯30% reduction).
+ ~810 fps sustained.
+- [ ] **GPU rendering time within Β±10% of N.4** β DEFERRED. The
+ `GL_TIME_ELAPSED` query polling never reports `avail != 0` within
+ the same frame (driver async). Fix is double-buffering β see N.6
+ follow-up. CPU is the load-bearing metric for the architectural
+ win.
+- [x] **`drawsIssued` β€ 5 per pass (CPU GL calls)** β exactly 2 per
+ frame (1 opaque indirect + 1 transparent indirect call), regardless
+ of scene size. Total per-frame entity GL calls ~12-15.
+- [x] **All tests green** β 70/70 in
+ `FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition`.
+ Pre-existing 8 failures in physics/input/movement tests carry
+ forward unchanged from before N.5.
+- [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** β escape hatch
+ formally retired in N.5 ship amendment (see section below).
+ `InstancedMeshRenderer`, `StaticMeshRenderer`, and `WbFoundationFlag`
+ deleted. Missing bindless throws `NotSupportedException` at startup.
+
+### Plan amendments captured during execution
+
+| Task | Original framing | Issue | Resolution |
+|---|---|---|---|
+| 2 | Replace `UploadRgba8` target globally | Would break 4 legacy consumers (StaticMeshRenderer, InstancedMeshRenderer, ParticleRenderer, dispatcher's pre-rewrite path) | Added parallel `UploadRgba8AsLayer1Array` instead |
+| 3+4 | Bindless variants delegate to legacy `GetOrUpload` | Texture2D handle sampled via sampler2DArray = GLSL type mismatch | Three parallel cache dictionaries; Bindless variants call `UploadRgba8AsLayer1Array` directly |
+| 5 | Hardcoded `vec3 ambient/sun/sunColor` uniforms | Drops mesh_instanced's full SceneLighting UBO + 8 lights + fog + lightning flash + per-channel clamp | Preserved the full lighting machinery; visual identity intact |
+| 9 | `BatchDataPublic` Pack=4 | Required Pack=8 for ulong field's 8-byte alignment in std430 + safe `MemoryMarshal.Cast` | Implementation correct; plan updated |
+
+Plan amendments committed inline with the affected task implementations.
+
+### Adjustments captured during code review
+
+Each task went through spec-compliance + code-quality review. Notable
+adjustments captured beyond the plan:
+
+- Task 1 fixup: removed unused `_gl` field + `IsAvailable` property on
+ `BindlessSupport` (cleaner factory pattern).
+- Task 3 fixup: two-phase `Dispose` ordering (ALL MakeNonResident first,
+ then ALL DeleteTexture β ARB_bindless_texture spec compliance) +
+ doc consistency on Bindless* methods.
+- Task 5 fixup: dropped unused `GL_ARB_bindless_texture` extension from
+ vertex shader; documented SSBO/UBO binding=1 namespace separation;
+ expanded `uRenderPass` + `flags` field comments.
+- Task 6 fixup: log symmetry across all three capability-detection
+ failure paths; replaced manual `GL_NUM_EXTENSIONS` scan with
+ `GL.IsExtensionPresent`.
+- Task 7 fixup: `BatchData` Pack=4 β Pack=8 with explanatory comment.
+- Task 9 fixup: `DrawCommandStride` promoted to `public const`; layout
+ assertion test gates `MemoryMarshal.Cast`
+ safety.
+- Task 12: Silk.NET API names β `GetQueryObject(...out int)` /
+ `GetQueryObject(...out ulong)` (not `GetQueryObjectui64`).
+ `QueryObjectParameterName.ResultAvailable` / `Result` (not
+ `QueryResultAvailable` / `QueryResult`).
+
+### Out-of-scope β N.6 follow-ups (per spec Β§10)
+
+- **GPU timer query double-buffering.** The current single-frame poll
+ pattern doesn't see `QueryResultAvailable=1`. Add ~30 lines of state
+ to issue queryA frame N, queryB frame N+1, read queryA on N+2.
+- **Direct N.4 vs N.5 perf comparison.** Re-run the dispatcher
+ measurement against N.4 SHIP (`c445364`) for a side-by-side number.
+ Not load-bearing for ship; useful for N.6 ship message context.
+- **Persistent-mapped buffers** (Decision 7 deferral). Layer on top of
+ the modern path if `glBufferData` shows up as a residual hot spot in
+ profiling.
+- ~~**Retire `InstancedMeshRenderer`** entirely β N.6 primary scope.~~ **Done in N.5 ship amendment.**
+- **WB atlas adoption** for memory savings on shared content (trees,
+ walls, etc).
+- **GPU-side culling** via compute pre-pass.
+- **Per-instance highlight (selection blink)** for retail-faithful click
+ feedback β **open backlog, no scheduled phase.** Field reserved in
+ `mesh_modern.vert`'s `InstanceData` struct comment; whoever eventually
+ wants it finds the hook there. Change is localized: extend
+ `InstanceData` stride 64β80 bytes, add `vec4 highlightColor` field,
+ mix into fragment color. ~30 min when the time comes.
+
+### Memory
+
+`project_phase_n5_state.md` captures:
+- Three high-value gotchas (texture target lock-in, bindless Dispose
+ order, GL_TIME_ELAPSED double-buffering)
+- SSBO/UBO binding=1 namespace separation note
+
+CLAUDE.md "WB integration cribs" updated with N.5 patterns (Task 16).
+
+### Files added or modified summary
+
+**Added:**
+- `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`
+- `src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs`
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
+- `tests/AcDream.Core.Tests/Rendering/TextureCacheBindlessTests.cs`
+- `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs`
+- `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherTranslucencyTests.cs`
+- `docs/plans/2026-05-08-phase-n5-perf-baseline.md`
+- `docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`
+- `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md` (this file)
+
+**Modified:**
+- `src/AcDream.App/AcDream.App.csproj` β `Silk.NET.OpenGL.Extensions.ARB` package
+- `src/AcDream.App/Rendering/TextureCache.cs` β parallel Texture2DArray path + Bindless* methods + two-phase Dispose
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` β full rewrite to SSBO + glMultiDrawElementsIndirect
+- `src/AcDream.App/Rendering/GameWindow.cs` β capability detection + plumb BindlessSupport + conditional shader load
+- `CLAUDE.md` β N.5 entries in "WB integration cribs"
+- `docs/plans/2026-04-11-roadmap.md` β N.5 β Shipped, N.6 β in flight
+
+**Deleted:**
+- `src/AcDream.App/Rendering/Shaders/mesh_instanced.vert`
+- `src/AcDream.App/Rendering/Shaders/mesh_instanced.frag`
+
+---
+
+## Ship amendment β 2026-05-08
+
+### Problem discovered in cross-cutting review
+
+Task 15's deletion of `mesh_instanced.vert/.frag` left `InstancedMeshRenderer`
+orphaned. The `_staticMesh` construction was gated on `_meshShader is not null`,
+and `_meshShader` was only assigned when bindless was present. So with
+`ACDREAM_USE_WB_FOUNDATION=0`, the flag path produced `_meshShader=null` β
+`_staticMesh=null` β terrain+sky only with no entity rendering. The SHIP
+commit's `[x] ACDREAM_USE_WB_FOUNDATION=0 still works` claim was inaccurate.
+
+### Resolution
+
+User authorized **Option B**: formal retirement of the legacy path in N.5
+instead of restoring it. Reasons: bindless + WB foundation has been default-on
+since N.4, escape hatch was never exercised in practice, N.6 was already
+planning to retire it β we did it now instead.
+
+**Files deleted:**
+- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs`
+- `src/AcDream.App/Rendering/StaticMeshRenderer.cs`
+- `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs`
+
+**GameWindow simplified:**
+- `_staticMesh` field removed
+- Capability detection block is unconditional (no `WbFoundationFlag.IsEnabled` guard)
+- Missing bindless throws `NotSupportedException` at startup with a clear message
+- `_wbMeshAdapter`, `_wbEntitySpawnAdapter`, `_wbDrawDispatcher` all construct
+ unconditionally after the capability check
+- Draw path: `_wbDrawDispatcher!.Draw(...)` β no null-conditional, no else branch
+
+**GpuWorldState simplified:**
+- `WbFoundationFlag.IsEnabled` guards removed from `AddLandblock` /
+ `RemoveLandblock`; adapter calls are unconditional when adapter is non-null
+
+**Test file updated:**
+- `PendingSpawnIntegrationTests.cs`: removed `static WbFoundationFlag.ForTestsOnly_ForceEnable()` ctor
+ (no longer needed β `GpuWorldState` adapter calls are unconditional)
+
+**Spec Β§2 Decision 5 updated:** two-way flag β mandatory modern path.
+**Spec Β§10 Out-of-scope updated:** `InstancedMeshRenderer` deletion crossed off (done).
+**Roadmap updated:** N.5 entry notes retirement; N.6 scope narrowed.
+**Perf baseline doc updated:** acceptance gate row corrected to N/A.
+**CLAUDE.md updated:** WB integration cribs no longer reference WbFoundationFlag.
+
+Build: green (0 errors, 0 warnings). Tests: 71/71 in Wb+MatrixComposition+TextureCacheBindless filter.
diff --git a/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
new file mode 100644
index 0000000..a53d596
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
@@ -0,0 +1,2525 @@
+# Phase A.5 β Two-tier Streaming + Horizon LOD β 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.
+
+**Goal:** Deliver Phase A.5 β extend acdream's streaming radius from 5 (~1 km) to a tiered Nβ=4 / Nβ=12 layout (~2.3 km horizon) sustaining 240 FPS at standstill on a 240 Hz / 1440p monitor.
+
+**Architecture:** Two-tier streaming (near = full detail, far = terrain only) + tightening the existing per-LB entity dispatcher walk + off-thread mesh build (single worker) + fog blend at the near boundary + three visual quality wins (terrain mipmaps + anisotropic, A2C with MSAA on foliage, depth-write audit).
+
+**Tech Stack:** C# .NET 10, Silk.NET (OpenGL 4.3+), bindless textures (`GL_ARB_bindless_texture`), `glMultiDrawElementsIndirect`, xUnit for tests. WorldBuilder is the rendering foundation; we extend WB's `ObjectMeshManager` + acdream's `TerrainModernRenderer`.
+
+**Spec:** [`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`](../specs/2026-05-09-phase-a5-two-tier-streaming-design.md)
+
+---
+
+## Conventions
+
+- **Working dir:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-darwin-ae8b87` (this worktree).
+- **Branch:** `claude/hopeful-darwin-ae8b87`.
+- **Build:** `dotnet build` from worktree root.
+- **Test:** `dotnet test --no-build` (full suite); filter via `--filter "FullyQualifiedName~"` for targeted runs.
+- **Commits:** prefix `phase(A.5):` or `feat(A.5):`/`test(A.5):`/`fix(A.5):`/`docs(A.5):` per task type. End with `Co-Authored-By: Claude Opus 4.7 (1M context) ` per the project convention.
+- **Test framework:** xUnit + FluentAssertions. Existing tests use `[Fact]` + `Assert.*` style β follow that.
+
+---
+
+## Task 1: Add `LandblockStreamTier` and `LandblockStreamJobKind` enums
+
+**Files:**
+- Create: `src/AcDream.App/Streaming/LandblockStreamTier.cs`
+
+- [ ] **Step 1: Write the file**
+
+```csharp
+namespace AcDream.App.Streaming;
+
+///
+/// Streaming-tier classification for a landblock. means
+/// terrain mesh only; means terrain + scenery + EnvCells +
+/// entity registration with the WB dispatcher. Per Phase A.5 spec Β§3.
+///
+public enum LandblockStreamTier
+{
+ Far,
+ Near,
+}
+
+///
+/// What work the streaming worker should perform for a given job. Distinct
+/// from because
+/// reads only the entity layer (terrain mesh already loaded), while
+/// reads everything from scratch. Per Phase A.5 spec Β§4.3.
+///
+public enum LandblockStreamJobKind
+{
+ /// Read LandBlock heightmap, build mesh, no entity layer.
+ LoadFar,
+ /// Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.
+ LoadNear,
+ /// Read LandBlockInfo + scenery only β terrain already loaded for this LB.
+ PromoteToNear,
+}
+```
+
+- [ ] **Step 2: Build to verify**
+
+Run: `dotnet build`
+Expected: `Build succeeded.` 0 errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/AcDream.App/Streaming/LandblockStreamTier.cs
+git commit -m "feat(A.5 T1): LandblockStreamTier + LandblockStreamJobKind enums"
+```
+
+---
+
+## Task 2: Add `TwoTierDiff` record + extend `LandblockStreamJob.Load` with kind
+
+**Files:**
+- Create: `src/AcDream.App/Streaming/TwoTierDiff.cs`
+- Modify: `src/AcDream.App/Streaming/LandblockStreamJob.cs`
+- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
+
+- [ ] **Step 1: Write `TwoTierDiff.cs`**
+
+```csharp
+using System.Collections.Generic;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Output of for the two-tier model.
+/// Five disjoint lists describe what changed since the previous Tick. Per
+/// Phase A.5 spec Β§4.2.
+///
+public readonly record struct TwoTierDiff(
+ IReadOnlyList ToLoadFar, // entered far window from null (terrain only)
+ IReadOnlyList ToLoadNear, // entered near window from null (terrain + entities β first-tick or teleport)
+ IReadOnlyList ToPromote, // entered near window from far-resident (entities only)
+ IReadOnlyList ToDemote, // exited near window past hysteresis (drop entities)
+ IReadOnlyList ToUnload); // exited far window past hysteresis (drop terrain)
+```
+
+- [ ] **Step 2: Modify `LandblockStreamJob.cs`**
+
+Change the `Load` record from:
+
+```csharp
+public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
+```
+
+to:
+
+```csharp
+public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
+```
+
+- [ ] **Step 3: Patch the call site to satisfy the compiler**
+
+In `LandblockStreamer.EnqueueLoad` (~line 91), change:
+
+```csharp
+HandleJob(new LandblockStreamJob.Load(landblockId));
+```
+
+to:
+
+```csharp
+HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear));
+```
+
+The `LoadNear` placeholder reproduces today's "full load" semantics; Task 16 replaces this with proper routing.
+
+- [ ] **Step 4: Build green**
+
+Run: `dotnet build`
+Expected: `Build succeeded.` 0 errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Streaming/TwoTierDiff.cs src/AcDream.App/Streaming/LandblockStreamJob.cs src/AcDream.App/Streaming/LandblockStreamer.cs
+git commit -m "feat(A.5 T2): TwoTierDiff record + LandblockStreamJob.Load.Kind"
+```
+
+---
+
+## Task 3: Test β `StreamingRegion` two-radius constructor
+
+**Files:**
+- Create: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
+- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+```csharp
+using AcDream.App.Streaming;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+public class StreamingRegionTwoTierTests
+{
+ [Fact]
+ public void Constructor_TwoRadii_ExposesNearAndFarRadii()
+ {
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 4, farRadius: 12);
+
+ Assert.Equal(4, region.NearRadius);
+ Assert.Equal(12, region.FarRadius);
+ Assert.Equal(100, region.CenterX);
+ Assert.Equal(100, region.CenterY);
+ }
+}
+```
+
+- [ ] **Step 2: Run test β verify fails**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"`
+Expected: FAIL β `StreamingRegion` has no constructor taking `nearRadius`/`farRadius`.
+
+- [ ] **Step 3: Add the two-radius constructor**
+
+In `src/AcDream.App/Streaming/StreamingRegion.cs`, add (don't remove the
+existing single-radius constructor yet β that gets cleaned up in Task 19):
+
+```csharp
+public int NearRadius { get; }
+public int FarRadius { get; }
+
+public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius)
+{
+ NearRadius = nearRadius;
+ FarRadius = farRadius;
+ Radius = farRadius; // outer ring drives Resident bookkeeping below
+ Recenter(centerX, centerY);
+}
+```
+
+If the existing constructor is `public StreamingRegion(int cx, int cy, int radius)`,
+preserve it as a thin wrapper:
+
+```csharp
+public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { }
+```
+
+- [ ] **Step 4: Run test β verify passes**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs
+git commit -m "test(A.5 T3): StreamingRegion two-radius constructor"
+```
+
+---
+
+## Task 4: Test + implement `ComputeFirstTickDiff`
+
+**Files:**
+- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
+- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs`
+
+- [ ] **Step 1: Add the failing test**
+
+Append to `StreamingRegionTwoTierTests.cs`:
+
+```csharp
+[Fact]
+public void RecenterTo_FirstTick_SplitsLoadIntoNearAndFar()
+{
+ // near=1, far=3 β near window is 3Γ3=9, far window is 7Γ7-3Γ3=40 LBs.
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
+ var diff = region.ComputeFirstTickDiff();
+
+ Assert.Equal(9, diff.ToLoadNear.Count);
+ Assert.Equal(40, diff.ToLoadFar.Count);
+ Assert.Empty(diff.ToPromote);
+ Assert.Empty(diff.ToDemote);
+ Assert.Empty(diff.ToUnload);
+}
+```
+
+- [ ] **Step 2: Run test β verify fails**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_FirstTick"`
+Expected: FAIL or compile error β `ComputeFirstTickDiff` doesn't exist.
+
+- [ ] **Step 3: Implement `ComputeFirstTickDiff`**
+
+In `StreamingRegion.cs`:
+
+```csharp
+///
+/// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring,
+/// ToLoadFar for every LB in the outer ring (between near and far). Used
+/// by on the first call before any
+/// RecenterTo.
+///
+public TwoTierDiff ComputeFirstTickDiff()
+{
+ var near = new List();
+ var far = new List();
+ for (int dx = -FarRadius; dx <= FarRadius; dx++)
+ {
+ for (int dy = -FarRadius; dy <= FarRadius; dy++)
+ {
+ int nx = CenterX + dx;
+ int ny = CenterY + dy;
+ if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
+ int absDx = System.Math.Abs(dx);
+ int absDy = System.Math.Abs(dy);
+ var id = EncodeLandblockId(nx, ny);
+ if (absDx <= NearRadius && absDy <= NearRadius)
+ near.Add(id);
+ else
+ far.Add(id);
+ }
+ }
+ return new TwoTierDiff(
+ ToLoadFar: far,
+ ToLoadNear: near,
+ ToPromote: System.Array.Empty(),
+ ToDemote: System.Array.Empty(),
+ ToUnload: System.Array.Empty());
+}
+```
+
+Uses Chebyshev (chess-king) distance β same convention as the existing `Recenter`.
+
+- [ ] **Step 4: Run test β verify passes**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_FirstTick"`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs
+git commit -m "feat(A.5 T4): StreamingRegion ComputeFirstTickDiff"
+```
+
+---
+
+## Task 5: Test + implement `RecenterTo` two-tier overload (covers nullβFar, FarβNear, NearβFar, Farβnull)
+
+**Files:**
+- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
+- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs`
+
+- [ ] **Step 1: Add the failing test (nullβFar transition)**
+
+```csharp
+[Fact]
+public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar()
+{
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
+ _ = region.ComputeFirstTickDiff();
+ region.MarkResidentFromBootstrap();
+
+ // Walk one LB east β center moves from (100,100) to (101,100).
+ // The east column at lbX=104 (relative dx=+3 from new center) enters
+ // the far window from null.
+ var diff = region.RecenterTo(newCx: 101, newCy: 100);
+
+ foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 })
+ {
+ var id = StreamingRegion.EncodeLandblockIdForTest(104, y);
+ Assert.Contains(id, diff.ToLoadFar);
+ }
+ Assert.Empty(diff.ToLoadNear);
+}
+```
+
+- [ ] **Step 2: Run test β verify fails**
+
+Expected: FAIL β `MarkResidentFromBootstrap` / `EncodeLandblockIdForTest` don't exist + `RecenterTo` doesn't yet produce a `TwoTierDiff`.
+
+- [ ] **Step 3: Implement two-tier `RecenterTo` + helpers**
+
+In `StreamingRegion.cs`:
+
+```csharp
+internal enum TierResidence { None, Far, Near }
+private readonly Dictionary _tierResidence = new();
+
+public void MarkResidentFromBootstrap()
+{
+ for (int dx = -FarRadius; dx <= FarRadius; dx++)
+ {
+ for (int dy = -FarRadius; dy <= FarRadius; dy++)
+ {
+ int nx = CenterX + dx;
+ int ny = CenterY + dy;
+ if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
+ int absDx = System.Math.Abs(dx);
+ int absDy = System.Math.Abs(dy);
+ var id = EncodeLandblockId(nx, ny);
+ _tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius)
+ ? TierResidence.Near
+ : TierResidence.Far;
+ }
+ }
+}
+
+internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
+ => EncodeLandblockId(lbX, lbY);
+
+///
+/// Two-tier overload of RecenterTo. Computes the 5-list diff per Phase A.5 spec Β§4.2.
+/// Hysteresis: NearRadius+2 for nearβfar demote; FarRadius+2 for farβnull unload.
+///
+public TwoTierDiff RecenterTo(int newCx, int newCy)
+{
+ int nearUnloadThreshold = NearRadius + 2;
+ int farUnloadThreshold = FarRadius + 2;
+
+ var toLoadFar = new List();
+ var toLoadNear = new List();
+ var toPromote = new List();
+ var toDemote = new List();
+ var toUnload = new List();
+
+ // Pass 1: walk new far window β emit ToLoadFar / ToLoadNear / ToPromote.
+ var newCenterIds = new HashSet();
+ for (int dx = -FarRadius; dx <= FarRadius; dx++)
+ {
+ for (int dy = -FarRadius; dy <= FarRadius; dy++)
+ {
+ int nx = newCx + dx;
+ int ny = newCy + dy;
+ if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
+ int absDx = System.Math.Abs(dx);
+ int absDy = System.Math.Abs(dy);
+ bool inNear = absDx <= NearRadius && absDy <= NearRadius;
+ var id = EncodeLandblockId(nx, ny);
+ newCenterIds.Add(id);
+
+ if (!_tierResidence.TryGetValue(id, out var current))
+ {
+ if (inNear) toLoadNear.Add(id);
+ else toLoadFar.Add(id);
+ _tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far;
+ }
+ else if (current == TierResidence.Far && inNear)
+ {
+ toPromote.Add(id);
+ _tierResidence[id] = TierResidence.Near;
+ }
+ }
+ }
+
+ // Pass 2: handle previously-resident LBs β demote / unload by distance.
+ foreach (var kvp in _tierResidence.ToArray())
+ {
+ var id = kvp.Key;
+ var current = kvp.Value;
+ int lbX = (int)((id >> 24) & 0xFFu);
+ int lbY = (int)((id >> 16) & 0xFFu);
+ int absDx = System.Math.Abs(lbX - newCx);
+ int absDy = System.Math.Abs(lbY - newCy);
+ int distance = System.Math.Max(absDx, absDy);
+
+ if (newCenterIds.Contains(id))
+ {
+ // Possible NearβFar demote even though id is in window: was Near,
+ // now outside near radius (but still within hysteresis window).
+ if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius))
+ {
+ if (distance > nearUnloadThreshold)
+ {
+ toDemote.Add(id);
+ _tierResidence[id] = TierResidence.Far;
+ }
+ }
+ continue;
+ }
+
+ // Outside new window β check unload thresholds.
+ if (current == TierResidence.Near)
+ {
+ if (distance > nearUnloadThreshold)
+ {
+ toDemote.Add(id);
+ _tierResidence[id] = TierResidence.Far;
+ if (distance > farUnloadThreshold)
+ {
+ toUnload.Add(id);
+ _tierResidence.Remove(id);
+ }
+ }
+ }
+ else if (current == TierResidence.Far)
+ {
+ if (distance > farUnloadThreshold)
+ {
+ toUnload.Add(id);
+ _tierResidence.Remove(id);
+ }
+ }
+ }
+
+ CenterX = newCx;
+ CenterY = newCy;
+
+ return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload);
+}
+```
+
+If `CenterX` / `CenterY` are currently `{ get; }` (init-only), change to
+`{ get; private set; }`.
+
+- [ ] **Step 4: Run test β verify passes**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_PlayerWalks_NullToFar"`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs
+git commit -m "feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking"
+```
+
+---
+
+## Task 6: Tests for FarβNear, nullβNear (teleport), NearβFar hysteresis, Farβnull hysteresis, oscillation
+
+**Files:**
+- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
+
+- [ ] **Step 1: Add FarβNear (Promote) test**
+
+```csharp
+[Fact]
+public void RecenterTo_PlayerWalks_FarToNear_AppearsInToPromote()
+{
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
+ _ = region.ComputeFirstTickDiff();
+ region.MarkResidentFromBootstrap();
+
+ // Walk 2 east β center (102, 100). LB (102, 100) was at distance 2 (Far)
+ // from (100,100); now at distance 0 β Near. That's a Promote.
+ var diff = region.RecenterTo(newCx: 102, newCy: 100);
+
+ var promotedId = StreamingRegion.EncodeLandblockIdForTest(102, 100);
+ Assert.Contains(promotedId, diff.ToPromote);
+ Assert.DoesNotContain(promotedId, diff.ToLoadNear);
+ Assert.DoesNotContain(promotedId, diff.ToLoadFar);
+}
+```
+
+- [ ] **Step 2: Add nullβNear (teleport) test**
+
+```csharp
+[Fact]
+public void RecenterTo_PlayerTeleports_NullToNear_AppearsInToLoadNear()
+{
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
+ _ = region.ComputeFirstTickDiff();
+ region.MarkResidentFromBootstrap();
+
+ // Teleport to (200, 200) β entirely new region.
+ var diff = region.RecenterTo(newCx: 200, newCy: 200);
+
+ Assert.Equal(9, diff.ToLoadNear.Count);
+ Assert.Equal(40, diff.ToLoadFar.Count);
+ Assert.Empty(diff.ToPromote);
+}
+```
+
+- [ ] **Step 3: Add NearβFar hysteresis test**
+
+```csharp
+[Fact]
+public void RecenterTo_NearToFar_DemoteOnlyAfterHysteresis()
+{
+ // near=2, far=4 β near hysteresis threshold = 4.
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4);
+ _ = region.ComputeFirstTickDiff();
+ region.MarkResidentFromBootstrap();
+
+ // LB (100,100) was Near. Walk 3 east β distance 3 > NearRadius=2 but β€ 4.
+ // No demote yet.
+ var diff1 = region.RecenterTo(newCx: 103, newCy: 100);
+ var lb100 = StreamingRegion.EncodeLandblockIdForTest(100, 100);
+ Assert.DoesNotContain(lb100, diff1.ToDemote);
+
+ // Walk 2 more east β distance 5 > 4. Demote.
+ var diff2 = region.RecenterTo(newCx: 105, newCy: 100);
+ Assert.Contains(lb100, diff2.ToDemote);
+}
+```
+
+- [ ] **Step 4: Add Farβnull hysteresis test**
+
+```csharp
+[Fact]
+public void RecenterTo_FarToNull_UnloadOnlyAfterHysteresis()
+{
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
+ _ = region.ComputeFirstTickDiff();
+ region.MarkResidentFromBootstrap();
+
+ // LB (97, 100) was at distance 3 (Far). Walk 1 east β distance 4. β€ FarRadius+2=5.
+ var diff1 = region.RecenterTo(newCx: 101, newCy: 100);
+ var lb97 = StreamingRegion.EncodeLandblockIdForTest(97, 100);
+ Assert.DoesNotContain(lb97, diff1.ToUnload);
+
+ // Walk 2 more east β distance 6 > 5. Unload.
+ var diff2 = region.RecenterTo(newCx: 103, newCy: 100);
+ Assert.Contains(lb97, diff2.ToUnload);
+}
+```
+
+- [ ] **Step 5: Add oscillation no-thrash test**
+
+```csharp
+[Fact]
+public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis()
+{
+ var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4);
+ _ = region.ComputeFirstTickDiff();
+ region.MarkResidentFromBootstrap();
+
+ // Bounce between (102,100) and (103,100). Distance from each to (100,100)
+ // is 2 and 3 β both within NearRadius+2=4 hysteresis. No demote should fire.
+ int totalDemotes = 0;
+ int totalPromotes = 0;
+ for (int i = 0; i < 5; i++)
+ {
+ var d1 = region.RecenterTo(103, 100);
+ totalDemotes += d1.ToDemote.Count;
+ totalPromotes += d1.ToPromote.Count;
+ var d2 = region.RecenterTo(102, 100);
+ totalDemotes += d2.ToDemote.Count;
+ totalPromotes += d2.ToPromote.Count;
+ }
+
+ Assert.Equal(0, totalDemotes);
+ // Some promote on the very first crossing is expected (LBs that were Far
+ // becoming Near); after that, oscillation should settle.
+ Assert.True(totalPromotes <= 4,
+ $"Expected β€4 promotes across 5 oscillations; got {totalPromotes}");
+}
+```
+
+- [ ] **Step 6: Run all five tests β verify pass**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"`
+Expected: 6 passing total (the 1 from Task 3 + 5 added here).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs
+git commit -m "test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage"
+```
+
+---
+
+## Task 7: Extend `LandblockStreamResult.Loaded` with Tier + MeshData; add `Promoted`
+
+**Files:**
+- Modify: `src/AcDream.App/Streaming/LandblockStreamJob.cs`
+- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
+
+- [ ] **Step 1: Replace `LandblockStreamResult` with extended variants**
+
+In `LandblockStreamJob.cs`, replace the existing `LandblockStreamResult`
+record block:
+
+```csharp
+using System.Collections.Generic;
+using AcDream.Core.Terrain;
+using AcDream.Core.World;
+
+public abstract record LandblockStreamResult(uint LandblockId)
+{
+ ///
+ /// A landblock load completed. distinguishes Far
+ /// (terrain only) from Near (terrain + entities).
+ /// is built off the render thread on the streaming worker.
+ ///
+ public sealed record Loaded(
+ uint LandblockId,
+ LandblockStreamTier Tier,
+ LoadedLandblock Landblock,
+ LandblockMeshData MeshData
+ ) : LandblockStreamResult(LandblockId);
+
+ ///
+ /// A previously-Far-resident landblock was promoted to Near. Terrain
+ /// mesh is already on the GPU; the result carries the entity layer
+ /// (stabs, buildings, scenery) to merge into the existing GpuWorldState
+ /// entry.
+ ///
+ public sealed record Promoted(
+ uint LandblockId,
+ IReadOnlyList Entities
+ ) : LandblockStreamResult(LandblockId);
+
+ public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
+ public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
+ public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
+}
+```
+
+- [ ] **Step 2: Patch `LandblockStreamer.HandleJob` to compile (placeholder MeshData)**
+
+In `LandblockStreamer.HandleJob` (line ~167), update the `Loaded` construction:
+
+```csharp
+// TEMPORARY: passes default! for MeshData β Task 13 wires the real mesh build.
+_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
+ load.LandblockId,
+ LandblockStreamTier.Near,
+ lb,
+ MeshData: default! /* TODO(A.5 T13) */));
+```
+
+- [ ] **Step 3: Build verify**
+
+Run: `dotnet build`
+Expected: build succeeded.
+
+- [ ] **Step 4: Run all tests still pass**
+
+Run: `dotnet test --no-build`
+Expected: previously-passing tests still pass; new tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Streaming/LandblockStreamJob.cs src/AcDream.App/Streaming/LandblockStreamer.cs
+git commit -m "feat(A.5 T7): LandblockStreamResult.Loaded.Tier + MeshData; Promoted variant"
+```
+
+---
+
+## Task 8: Add `WorldEntity.AabbMin/AabbMax` cache + dirty flag + `RefreshAabb` + `SetPosition`
+
+**Files:**
+- Modify: `src/AcDream.Core/World/WorldEntity.cs`
+- Create: `tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+```csharp
+using System.Numerics;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.World;
+
+public class WorldEntityAabbTests
+{
+ [Fact]
+ public void Aabb_DefaultRadius_PositionPlusMinus5()
+ {
+ var entity = new WorldEntity
+ {
+ Id = 1,
+ Position = new Vector3(10, 20, 30),
+ Rotation = System.Numerics.Quaternion.Identity,
+ MeshRefs = System.Array.Empty(),
+ };
+ entity.RefreshAabb();
+
+ Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin);
+ Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax);
+ }
+
+ [Fact]
+ public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh()
+ {
+ var entity = new WorldEntity
+ {
+ Id = 1,
+ Position = new Vector3(10, 20, 30),
+ Rotation = System.Numerics.Quaternion.Identity,
+ MeshRefs = System.Array.Empty(),
+ };
+ entity.RefreshAabb();
+ Assert.False(entity.AabbDirty);
+
+ entity.SetPosition(new Vector3(100, 200, 300));
+ Assert.True(entity.AabbDirty);
+
+ entity.RefreshAabb();
+ Assert.False(entity.AabbDirty);
+ Assert.Equal(new Vector3(95, 195, 295), entity.AabbMin);
+ }
+}
+```
+
+- [ ] **Step 2: Run test β verify fails**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~WorldEntityAabbTests"`
+Expected: FAIL β fields/methods don't exist.
+
+- [ ] **Step 3: Add fields and methods to `WorldEntity`**
+
+Locate `WorldEntity.cs` and add:
+
+```csharp
+// Per Phase A.5 spec Β§4.6 Change #2 β cache per-entity AABB so the
+// dispatcher's frustum cull is a memory read, not a per-frame recompute.
+public Vector3 AabbMin { get; private set; }
+public Vector3 AabbMax { get; private set; }
+public bool AabbDirty { get; private set; } = true;
+
+private const float DefaultAabbRadius = 5.0f;
+
+public void RefreshAabb()
+{
+ var p = Position;
+ AabbMin = new Vector3(p.X - DefaultAabbRadius, p.Y - DefaultAabbRadius, p.Z - DefaultAabbRadius);
+ AabbMax = new Vector3(p.X + DefaultAabbRadius, p.Y + DefaultAabbRadius, p.Z + DefaultAabbRadius);
+ AabbDirty = false;
+}
+
+public void SetPosition(Vector3 pos)
+{
+ Position = pos;
+ AabbDirty = true;
+}
+```
+
+If `Position` is currently `{ get; init; }`, change to `{ get; set; }` so
+`SetPosition` can write it. Object-initializer assignments still compile.
+
+- [ ] **Step 4: Run test β verify passes**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~WorldEntityAabbTests"`
+Expected: PASS, 2 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs src/AcDream.Core/World/WorldEntity.cs
+git commit -m "feat(A.5 T8): WorldEntity AABB cache + dirty flag"
+```
+
+---
+
+## Task 9: Swap `_surfaceCache` to `ConcurrentDictionary` for thread-safety
+
+**Files:**
+- Modify: `src/AcDream.Core/Terrain/LandblockMesh.cs`
+- Modify: the `_surfaceCache` owner (find via grep)
+
+- [ ] **Step 1: Locate the `_surfaceCache` owner**
+
+Run: `Grep "surfaceCache|SurfaceCache" --include "*.cs" src/AcDream.App` from worktree root.
+Identify which class declares the cache passed to `LandblockMesh.Build`.
+
+- [ ] **Step 2: Widen `LandblockMesh.Build` parameter to `IDictionary`**
+
+In `LandblockMesh.cs`, change:
+
+```csharp
+public static LandblockMeshData Build(
+ LandBlock block,
+ uint landblockX,
+ uint landblockY,
+ float[] heightTable,
+ TerrainBlendingContext ctx,
+ Dictionary surfaceCache)
+```
+
+to:
+
+```csharp
+public static LandblockMeshData Build(
+ LandBlock block,
+ uint landblockX,
+ uint landblockY,
+ float[] heightTable,
+ TerrainBlendingContext ctx,
+ System.Collections.Generic.IDictionary surfaceCache)
+```
+
+The lookup pattern in Build (lines ~108-112) is:
+
+```csharp
+if (!surfaceCache.TryGetValue(palCode, out var surf))
+{
+ surf = TerrainBlending.BuildSurface(palCode, ctx);
+ surfaceCache[palCode] = surf;
+}
+```
+
+This is NOT atomic under contention. Two workers may both run `BuildSurface`
+for the same palCode and the last write wins. Result is deterministic
+(same inputs β same SurfaceInfo) so the race is benign. We accept it.
+
+- [ ] **Step 3: At the cache-owner site, switch to `ConcurrentDictionary`**
+
+```csharp
+private readonly System.Collections.Concurrent.ConcurrentDictionary _surfaceCache = new();
+```
+
+Compiles unchanged because of the interface widening.
+
+- [ ] **Step 4: Build + all tests pass**
+
+Run: `dotnet build && dotnet test --no-build`
+Expected: build succeeded; all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.Core/Terrain/LandblockMesh.cs
+git commit -m "refactor(A.5 T9): _surfaceCache β ConcurrentDictionary for off-thread mesh build"
+```
+
+---
+
+## Task 10: Add DatCollection thread-safety lock
+
+**Files:**
+- Modify: wherever `DatCollection` is owned + accessed (likely `GameWindow.cs` and various spawn handlers).
+
+**Background:** Per `LandblockStreamer.cs:18-27` comments, `DatCollection`
+is not thread-safe. A.5 needs the worker to call `_dats.Get` /
+`_dats.Get` concurrently with the render thread's other
+dat reads (entity spawn, particle effects, animation sequencer).
+
+**Mitigation:** Wrap `DatCollection` accesses in a `lock` so reads
+serialize. Lock contention is minimal in practice.
+
+- [ ] **Step 1: Locate DatCollection access sites**
+
+Run: `Grep "_dats\.Get|DatCollection\." --include "*.cs" src/AcDream.App src/AcDream.Core` from worktree root.
+
+- [ ] **Step 2: Add `_datsLock` field next to the DatCollection field**
+
+```csharp
+private readonly object _datsLock = new();
+```
+
+- [ ] **Step 3: Wrap each `_dats.Get(...)` access in the lock**
+
+Two patterns acceptable:
+
+(a) Inline lock at each call site:
+
+```csharp
+LandBlock? block;
+lock (_datsLock) { block = _dats.Get(id); }
+```
+
+(b) Helper method:
+
+```csharp
+private T? GetDat(uint id) where T : class
+{
+ lock (_datsLock) { return _dats.Get(id); }
+}
+```
+
+Pattern (b) is cleaner but requires touching every call site. Pattern (a)
+is faster to apply. Either is acceptable.
+
+For the streamer factory specifically (where worker thread does dat reads),
+the lock MUST be held β see Task 13 wiring.
+
+- [ ] **Step 4: Build + all tests pass**
+
+Run: `dotnet build && dotnet test --no-build`
+Expected: build succeeded; all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add
+git commit -m "fix(A.5 T10): serialize DatCollection access via lock for off-thread streaming"
+```
+
+---
+
+## Task 11: Activate `LandblockStreamer` worker thread
+
+**Files:**
+- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
+
+**Background:** `WorkerLoop` exists but `Start()` is a no-op (synchronous mode).
+A.5 activates the worker.
+
+- [ ] **Step 1: Activate the worker thread in `Start()`**
+
+Replace `Start()`:
+
+```csharp
+public void Start()
+{
+ if (System.Threading.Volatile.Read(ref _disposed) != 0)
+ throw new ObjectDisposedException(nameof(LandblockStreamer));
+ if (_worker != null) return;
+ _worker = new Thread(WorkerLoop)
+ {
+ IsBackground = true,
+ Name = "acdream.streaming.worker",
+ };
+ _worker.Start();
+}
+```
+
+Remove the `#pragma warning disable CS0649` around `_worker` since it's
+now assigned.
+
+- [ ] **Step 2: Make enqueue methods non-blocking β write to inbox channel**
+
+Replace:
+
+```csharp
+public void EnqueueLoad(uint landblockId)
+{
+ if (System.Threading.Volatile.Read(ref _disposed) != 0)
+ throw new ObjectDisposedException(nameof(LandblockStreamer));
+ HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear));
+}
+```
+
+with:
+
+```csharp
+public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind)
+{
+ if (System.Threading.Volatile.Read(ref _disposed) != 0)
+ throw new ObjectDisposedException(nameof(LandblockStreamer));
+ _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
+}
+
+public void EnqueueUnload(uint landblockId)
+{
+ if (System.Threading.Volatile.Read(ref _disposed) != 0)
+ throw new ObjectDisposedException(nameof(LandblockStreamer));
+ _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
+}
+```
+
+- [ ] **Step 3: Update existing call sites to pass `JobKind`**
+
+Run: `Grep "\.EnqueueLoad\(" --include "*.cs"` from worktree root.
+
+For each, update to pass an appropriate `LandblockStreamJobKind`. Tests
+that don't care can pass `LandblockStreamJobKind.LoadNear` (today's behavior).
+
+- [ ] **Step 4: Build + run streaming tests**
+
+Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~Streaming"`
+Expected: build succeeded; all streaming tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Streaming/LandblockStreamer.cs
+git commit -m "feat(A.5 T11): activate LandblockStreamer worker thread; EnqueueLoad takes JobKind"
+```
+
+---
+
+## Task 12: Inject mesh-build dependency into `LandblockStreamer`
+
+**Files:**
+- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the construction site)
+
+- [ ] **Step 1: Add `_buildMeshOrNull` constructor param + field**
+
+In `LandblockStreamer.cs`:
+
+```csharp
+private readonly Func _buildMeshOrNull;
+
+public LandblockStreamer(
+ Func loadLandblock,
+ Func buildMeshOrNull)
+{
+ _loadLandblock = loadLandblock;
+ _buildMeshOrNull = buildMeshOrNull;
+ _inbox = Channel.CreateUnbounded(
+ new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
+ _outbox = Channel.CreateUnbounded(
+ new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
+}
+```
+
+- [ ] **Step 2: Update `HandleJob` to build mesh + post `Loaded` with Tier + MeshData**
+
+```csharp
+case LandblockStreamJob.Load load:
+ try
+ {
+ var lb = _loadLandblock(load.LandblockId);
+ if (lb is null)
+ {
+ _outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
+ load.LandblockId, "LandblockLoader.Load returned null"));
+ break;
+ }
+ var mesh = _buildMeshOrNull(load.LandblockId);
+ if (mesh is null)
+ {
+ _outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
+ load.LandblockId, "LandblockMesh.Build returned null"));
+ break;
+ }
+ var tier = load.Kind == LandblockStreamJobKind.LoadFar
+ ? LandblockStreamTier.Far : LandblockStreamTier.Near;
+ _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
+ load.LandblockId, tier, lb, mesh));
+ }
+ catch (Exception ex)
+ {
+ _outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
+ load.LandblockId, ex.ToString()));
+ }
+ break;
+```
+
+The `LoadFar` fast path (skip `LandBlockInfo` read) is OK to defer β the
+worker still reads everything for now; the render-thread routing in Task 14
+filters far-tier entities out anyway. Performance optimization for fast-path
+goes in a follow-up task or N.6.
+
+- [ ] **Step 3: Wire mesh-build factory at `LandblockStreamer` construction in `GameWindow`**
+
+In `GameWindow.cs`, locate the `_streamer = new LandblockStreamer(...)` line.
+Update:
+
+```csharp
+_streamer = new LandblockStreamer(
+ loadLandblock: id =>
+ {
+ lock (_datsLock) { return LandblockLoader.Load(_dats, id); }
+ },
+ buildMeshOrNull: id =>
+ {
+ LandBlock? block;
+ lock (_datsLock) { block = _dats.Get(id); }
+ if (block is null) return null;
+ uint lbX = (id >> 24) & 0xFFu;
+ uint lbY = (id >> 16) & 0xFFu;
+ // _heightTable, _terrainCtx, _surfaceCache populated at startup
+ return LandblockMesh.Build(block, lbX, lbY, _heightTable, _terrainCtx, _surfaceCache);
+ });
+```
+
+`_surfaceCache` is now `ConcurrentDictionary` (Task 9).
+
+After construction, call `_streamer.Start()` (Task 11 activated this).
+
+- [ ] **Step 4: Build + run streaming tests**
+
+Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~Streaming"`
+Expected: build succeeded; tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Streaming/LandblockStreamer.cs src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "feat(A.5 T12): inject mesh-build dependency into LandblockStreamer"
+```
+
+---
+
+## Task 13: `StreamingController` two-tier `Tick` + `applyTerrain` accepts MeshData
+
+**Files:**
+- Modify: `src/AcDream.App/Streaming/StreamingController.cs`
+- Create: `tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs`
+- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
+
+- [ ] **Step 1: Stub the new GpuWorldState methods**
+
+In `GpuWorldState.cs`, add stubs (Task 14 implements):
+
+```csharp
+public void RemoveEntitiesFromLandblock(uint landblockId)
+{
+ throw new System.NotImplementedException("A.5 T14");
+}
+
+public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList entities)
+{
+ throw new System.NotImplementedException("A.5 T14");
+}
+```
+
+- [ ] **Step 2: Rewrite `StreamingController` for two-tier**
+
+Replace the existing constructor and `Tick`:
+
+```csharp
+private readonly Action _enqueueLoad;
+private readonly Action _enqueueUnload;
+private readonly Func> _drainCompletions;
+private readonly Action _applyTerrain;
+private readonly Action? _removeTerrain;
+private readonly GpuWorldState _state;
+private StreamingRegion? _region;
+
+public int NearRadius { get; set; }
+public int FarRadius { get; set; }
+public int MaxCompletionsPerFrame { get; set; } = 4;
+
+public StreamingController(
+ Action enqueueLoad,
+ Action enqueueUnload,
+ Func> drainCompletions,
+ Action applyTerrain,
+ GpuWorldState state,
+ int nearRadius,
+ int farRadius,
+ Action? removeTerrain = null)
+{
+ _enqueueLoad = enqueueLoad;
+ _enqueueUnload = enqueueUnload;
+ _drainCompletions = drainCompletions;
+ _applyTerrain = applyTerrain;
+ _removeTerrain = removeTerrain;
+ _state = state;
+ NearRadius = nearRadius;
+ FarRadius = farRadius;
+}
+
+public void Tick(int observerCx, int observerCy)
+{
+ if (_region is null)
+ {
+ _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
+ var bootstrap = _region.ComputeFirstTickDiff();
+ foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
+ foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
+ _region.MarkResidentFromBootstrap();
+ }
+ else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
+ {
+ var diff = _region.RecenterTo(observerCx, observerCy);
+ foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
+ foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
+ foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
+ foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
+ foreach (var id in diff.ToUnload) _enqueueUnload(id);
+ }
+
+ var drained = _drainCompletions(MaxCompletionsPerFrame);
+ foreach (var result in drained)
+ {
+ switch (result)
+ {
+ case LandblockStreamResult.Loaded loaded:
+ _applyTerrain(loaded.Landblock, loaded.MeshData);
+ _state.AddLandblock(loaded.Landblock);
+ break;
+ case LandblockStreamResult.Promoted promoted:
+ _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
+ break;
+ case LandblockStreamResult.Unloaded unloaded:
+ _state.RemoveLandblock(unloaded.LandblockId);
+ _removeTerrain?.Invoke(unloaded.LandblockId);
+ break;
+ case LandblockStreamResult.Failed failed:
+ System.Console.WriteLine(
+ $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
+ break;
+ case LandblockStreamResult.WorkerCrashed crashed:
+ System.Console.WriteLine(
+ $"streaming: worker CRASHED: {crashed.Error}");
+ break;
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Write the failing test (first-tick bootstrap)**
+
+```csharp
+using System.Collections.Generic;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+public class StreamingControllerTwoTierTests
+{
+ [Fact]
+ public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier()
+ {
+ var loads = new List<(uint Id, LandblockStreamJobKind Kind)>();
+ var unloads = new List();
+ var completions = new List();
+ var state = new GpuWorldState();
+
+ var ctrl = new StreamingController(
+ enqueueLoad: (id, kind) => loads.Add((id, kind)),
+ enqueueUnload: unloads.Add,
+ drainCompletions: _ => completions,
+ applyTerrain: (_, _) => { },
+ state: state,
+ nearRadius: 1,
+ farRadius: 3);
+
+ ctrl.Tick(observerCx: 100, observerCy: 100);
+
+ int nearCount = 0, farCount = 0;
+ foreach (var (_, kind) in loads)
+ {
+ if (kind == LandblockStreamJobKind.LoadNear) nearCount++;
+ else if (kind == LandblockStreamJobKind.LoadFar) farCount++;
+ }
+ Assert.Equal(9, nearCount);
+ Assert.Equal(40, farCount);
+ }
+}
+```
+
+- [ ] **Step 4: Build + run new test**
+
+Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~StreamingControllerTwoTierTests"`
+Expected: build succeeded; new test PASS. Existing single-radius `StreamingControllerTests`
+will fail compile β fix in Task 16.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs src/AcDream.App/Streaming/StreamingController.cs src/AcDream.App/Streaming/GpuWorldState.cs
+git commit -m "feat(A.5 T13): StreamingController two-tier Tick + first-tick bootstrap"
+```
+
+---
+
+## Task 14: Implement `GpuWorldState.RemoveEntitiesFromLandblock` + `AddEntitiesToExistingLandblock`
+
+**Files:**
+- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
+- Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+```csharp
+using System.Linq;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+public class GpuWorldStateTwoTierTests
+{
+ [Fact]
+ public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
+ {
+ var state = new GpuWorldState();
+ var lb = new LoadedLandblock(0xAAAA_FFFF, Heightmap: null!,
+ Entities: new[]
+ {
+ new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() },
+ new WorldEntity { Id = 2, MeshRefs = System.Array.Empty() },
+ });
+ state.AddLandblock(lb);
+ Assert.Equal(2, state.Entities.Count);
+
+ state.RemoveEntitiesFromLandblock(0xAAAA_FFFF);
+
+ Assert.Empty(state.Entities);
+ Assert.True(state.IsLoaded(0xAAAA_FFFF));
+ }
+
+ [Fact]
+ public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
+ {
+ var state = new GpuWorldState();
+ var lb = new LoadedLandblock(0xAAAA_FFFF, Heightmap: null!,
+ Entities: new[] { new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() } });
+ state.AddLandblock(lb);
+
+ state.AddEntitiesToExistingLandblock(0xAAAA_FFFF, new[]
+ {
+ new WorldEntity { Id = 2, MeshRefs = System.Array.Empty() },
+ new WorldEntity { Id = 3, MeshRefs = System.Array.Empty() },
+ });
+
+ Assert.Equal(3, state.Entities.Count);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests β verify fail**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~GpuWorldStateTwoTierTests"`
+Expected: FAIL with `NotImplementedException`.
+
+- [ ] **Step 3: Implement the methods**
+
+Replace the stubs:
+
+```csharp
+public void RemoveEntitiesFromLandblock(uint landblockId)
+{
+ if (!_loaded.TryGetValue(landblockId, out var lb)) return;
+ if (_wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockUnloaded(landblockId);
+ _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty());
+ _pendingByLandblock.Remove(landblockId);
+ RebuildFlatView();
+}
+
+public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList entities)
+{
+ if (!_loaded.TryGetValue(landblockId, out var lb))
+ {
+ // Park as pending β same pattern as AppendLiveEntity for not-yet-loaded LBs.
+ if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket))
+ {
+ bucket = new List();
+ _pendingByLandblock[landblockId] = bucket;
+ }
+ bucket.AddRange(entities);
+ return;
+ }
+ var merged = new List(lb.Entities.Count + entities.Count);
+ merged.AddRange(lb.Entities);
+ merged.AddRange(entities);
+ _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
+ if (_wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]);
+ RebuildFlatView();
+}
+```
+
+- [ ] **Step 4: Run tests β verify pass**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~GpuWorldStateTwoTierTests"`
+Expected: PASS, 2 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs src/AcDream.App/Streaming/GpuWorldState.cs
+git commit -m "feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting"
+```
+
+---
+
+## Task 15: Add `TerrainModernRenderer.AddLandblockWithMesh` (prebuilt mesh entry point)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
+
+- [ ] **Step 1: Refactor existing `AddLandblock` to delegate to `AddLandblockInternal`**
+
+Today's `AddLandblock(LoadedLandblock lb)` builds the mesh and adds it.
+Refactor:
+
+```csharp
+public void AddLandblock(LoadedLandblock lb)
+{
+ // Legacy synchronous path β fallback for callers not yet migrated.
+ var meshData = LandblockMesh.Build(
+ lb.Heightmap, /* lbX, lbY from id */, _heightTable, _terrainCtx, _surfaceCache);
+ AddLandblockInternal(lb, meshData);
+}
+
+public void AddLandblockWithMesh(LoadedLandblock lb, LandblockMeshData meshData)
+{
+ AddLandblockInternal(lb, meshData);
+}
+
+private void AddLandblockInternal(LoadedLandblock lb, LandblockMeshData meshData)
+{
+ // ... existing AddLandblock body, but using the passed meshData instead
+ // of building it inline.
+}
+```
+
+If `AddLandblock` doesn't build mesh inline today (e.g., if mesh is built
+elsewhere and stored on `LoadedLandblock`), the refactor is simpler:
+just add `AddLandblockWithMesh(lb, meshData)` as a new entry point that
+takes the mesh externally.
+
+- [ ] **Step 2: Build verify**
+
+Run: `dotnet build`
+Expected: build succeeded.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/TerrainModernRenderer.cs
+git commit -m "refactor(A.5 T15): TerrainModernRenderer.AddLandblockWithMesh prebuilt-mesh entry"
+```
+
+---
+
+## Task 16: Update existing single-radius `StreamingController` tests + wire two-tier into `GameWindow`
+
+**Files:**
+- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+
+- [ ] **Step 1: Run existing tests to identify failures**
+
+Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingControllerTests"`
+Expected: compile errors / failures pointing at the old constructor signature.
+
+- [ ] **Step 2: Update each existing test**
+
+Replace `radius: N` with `nearRadius: N, farRadius: N`. Replace
+`enqueueLoad: id => ...` with `enqueueLoad: (id, _) => ...` (ignore tier
+in tests that don't care). Replace `applyTerrain: lb => ...` with
+`applyTerrain: (lb, _) => ...`.
+
+For tests asserting on the original `RegionDiff`-shaped behavior, port
+to the `TwoTierDiff` shape. Asserts on `ToLoad` move to `ToLoadNear`
+when `nearRadius == farRadius` (single-tier behavior).
+
+- [ ] **Step 3: Wire two-tier into `GameWindow.cs`**
+
+Locate `StreamingController` construction. Replace with:
+
+```csharp
+int nearRadius = ParseEnvInt("ACDREAM_NEAR_RADIUS", defaultValue: 4);
+int farRadius = ParseEnvInt("ACDREAM_FAR_RADIUS", defaultValue: 12);
+
+// Backward-compat: if ACDREAM_STREAM_RADIUS is set, treat it as nearRadius
+// and infer farRadius = max(streamRadius, default farRadius).
+int streamRadius = ParseEnvInt("ACDREAM_STREAM_RADIUS", defaultValue: -1);
+if (streamRadius > 0)
+{
+ nearRadius = streamRadius;
+ farRadius = System.Math.Max(streamRadius, farRadius);
+}
+
+_streamingController = new StreamingController(
+ enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
+ enqueueUnload: id => _streamer.EnqueueUnload(id),
+ drainCompletions: max => _streamer.DrainCompletions(max),
+ applyTerrain: (lb, mesh) => _terrainModernRenderer.AddLandblockWithMesh(lb, mesh),
+ state: _gpuWorldState,
+ nearRadius: nearRadius,
+ farRadius: farRadius,
+ removeTerrain: id => _terrainModernRenderer.RemoveLandblock(id));
+```
+
+If `ParseEnvInt` doesn't exist, locate the existing pattern for env-var int
+parsing and reuse, or add a small helper.
+
+- [ ] **Step 4: Build + all tests pass**
+
+Run: `dotnet build && dotnet test --no-build`
+Expected: build succeeded; all tests pass.
+
+- [ ] **Step 5: Visual gate β launch and verify no regressions**
+
+Build the App project; the user launches the client (per CLAUDE.md
+launch flow) and verifies:
+- World renders at default radii (Nβ=4, Nβ=12).
+- No crashes during streaming.
+- Player movement works.
+
+If anything regresses, halt and debug.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "feat(A.5 T16): wire two-tier streaming into GameWindow + port existing tests"
+```
+
+---
+
+## Task 17: Test + implement entity bucketing Change #1 β animated-entity walk fix
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
+- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`
+
+- [ ] **Step 1: Extract pure-CPU `WalkEntities` helper**
+
+In `WbDrawDispatcher.cs`, extract a testable helper:
+
+```csharp
+internal struct WalkResult
+{
+ public int EntitiesWalked;
+ public List<(WorldEntity Entity, MeshRef MeshRef)> ToDraw;
+}
+
+internal static WalkResult WalkEntities(
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> landblockEntries,
+ FrustumPlanes? frustum,
+ uint? neverCullLandblockId,
+ HashSet? visibleCellIds,
+ HashSet? animatedEntityIds)
+{
+ var result = new WalkResult { ToDraw = new() };
+ foreach (var entry in landblockEntries)
+ {
+ bool landblockVisible = frustum is null
+ || entry.LandblockId == neverCullLandblockId
+ || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
+
+ if (!landblockVisible)
+ {
+ // A.5 T17 Change #1: walk only animated entities, not all entities.
+ if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
+ if (entry.AnimatedById is null) continue;
+ foreach (var animatedId in animatedEntityIds)
+ {
+ if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
+ if (entity.MeshRefs.Count == 0) continue;
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
+ result.EntitiesWalked++;
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ result.ToDraw.Add((entity, entity.MeshRefs[i]));
+ }
+ continue;
+ }
+
+ foreach (var entity in entry.Entities)
+ {
+ result.EntitiesWalked++;
+ if (entity.MeshRefs.Count == 0) continue;
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value))
+ continue;
+ // Per-entity AABB cull (uses cached AABB after Task 18 lands).
+ bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
+ if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
+ {
+ var p = entity.Position;
+ var aMin = new Vector3(p.X - 5f, p.Y - 5f, p.Z - 5f);
+ var aMax = new Vector3(p.X + 5f, p.Y + 5f, p.Z + 5f);
+ if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) continue;
+ }
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ result.ToDraw.Add((entity, entity.MeshRefs[i]));
+ }
+ }
+ return result;
+}
+```
+
+- [ ] **Step 2: Update `WbDrawDispatcher.Draw` to use `WalkEntities`**
+
+Replace the inline walk in `Draw` (lines ~191-288) with a call to
+`WalkEntities`, then build groups from the result. The classify+upload+
+indirect-draw phases remain unchanged.
+
+The signature of `Draw`'s `landblockEntries` parameter changes to include
+`AnimatedById`. Adjust the call site in `GameWindow.cs` accordingly.
+
+- [ ] **Step 3: Update `GpuWorldState.LandblockEntries` to yield `AnimatedById`**
+
+In `GpuWorldState.cs`, modify `LandblockEntries` to compute and yield
+`AnimatedById`:
+
+```csharp
+public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList