From 98977b8f667f4aa6d40af7280a3d69da95321e53 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 13:17:08 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20roadmap=20+=20ISSUES.md=20=E2=80=94=20P?= =?UTF-8?q?hase=202=20indoor=20cell=20rendering=20closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Roadmap shipped-table: two new rows for Phase 1 (diagnostics) + Phase 2 (fix). Header status block updated to 2026-05-19 with the Phase 2 cause + fix one-liner and pointer to the 9 surfaced issues. - ISSUES.md: filed nine new issues (#78-#86) covering the indoor bugs the user observed once the floor rendered. Grouped under an "Indoor walking issue cluster" header. Cross-references the Phase 1 + Phase 2 work that surfaced them. Hypotheses + suspected root causes documented for each. The 9 issues split into two probable shared-cause groups: - Cell BSP / portal cull (#78, #84, #85, #86) — likely fixable in one phase. - Indoor lighting plumbing (#79, #80, #81, #82) — needs separate investigation per-symptom. Plus #83 (stairs) which probably needs its own physics phase work. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 276 ++++++++++++++++++++++++++++++- docs/plans/2026-04-11-roadmap.md | 4 +- 2 files changed, 278 insertions(+), 2 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bce9fec..91fe17b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,7 +46,281 @@ Copy this block when adding a new issue: # Active issues -## #76 — [DONE 2026-05-16 · `0b25df5`] LiveSessionController extraction (Step 2) regresses interaction + chat outbound +--- + +## 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 — Walking up stairs broken + +**Status:** OPEN +**Severity:** HIGH (blocks vertical indoor traversal) +**Filed:** 2026-05-19 +**Component:** physics, movement + +**Description:** When the player tries to walk up stairs inside a +building, movement is broken — gets stuck, gets bounced, or fails to +ascend. + +**Root cause / status:** The retail physics has explicit step-up logic +(`CPhysicsObj::step_up` etc.) ported into `PhysicsEngine` for outdoor +terrain ramps. For indoor stairs (EnvCell CellStruct geometry composed +of polygons), the step-up resolver may not be examining cell BSP +correctly, OR cell BSP and cell mesh disagree on stair Z values. + +**Files:** +- `src/AcDream.Core/Physics/PhysicsEngine.cs` +- `src/AcDream.Core/Physics/TransitionTypes.cs` (cell BSP query path). + +**Acceptance:** Walking forward at the base of an inn stairwell ascends +to the second floor without getting stuck. + +--- + +## #84 — Blocked by air indoors + +**Status:** OPEN +**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. + +--- + +## #85 — Pass through walls from outside→in + +**Status:** OPEN +**Severity:** HIGH (gameplay-breaking) +**Filed:** 2026-05-19 +**Component:** physics, collision + +**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. + +**Root cause / status:** Cell BSP polygons likely have one-sided +normals (front-facing only). Approach from the inside hits the front; +approach from the outside hits the back which BSP traversal treats as +"behind the plane" → no collision. Retail handles this via two-sided +collision polys or per-poly back-face handling. + +**Files:** +- `src/AcDream.Core/Physics/BSPQuery.cs` +- `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindObjCollisions` cell + branch). + +**Acceptance:** Walking into an inn wall from outside collides; player +must enter via the door portal. + +--- + +## #86 — Click selection penetrates walls + +**Status:** OPEN +**Severity:** MEDIUM +**Filed:** 2026-05-19 +**Component:** input, interaction + +**Description:** Clicking through a wall from the outside selects NPCs +and objects inside the building. The `WorldPicker` raycast doesn't +intersect cell BSP geometry. + +**Root cause / status:** `WorldPicker.BuildRay + Pick` (introduced in +Phase B.4) tests against entity AABBs and scenery BSPs but probably +not cell BSP. Outdoor NPCs are pickable because their entity AABB is +the test target; indoor NPCs are pickable from outside because the +wall isn't in the ray's intersection set. + +**Files:** +- `src/AcDream.App/Rendering/WorldPicker.cs` (or equivalent — check + Phase B.4b reference). + +**Acceptance:** Clicking on a wall doesn't select NPCs behind it. + +--- + + **Status:** DONE **Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c2e403c..ba54f0c 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-12. **Between phases.** **Since the last header update:** 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). **Earlier this week:** 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). +**Status:** Living document. Updated 2026-05-19. **Between phases.** **Since the last header update:** 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. --- @@ -68,6 +68,8 @@ | 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 ✓ | Plus polish that doesn't get its own phase number: