# Phase A8 re-plan — entity taxonomy investigation **Date:** 2026-05-26 **Phase:** A8 — Indoor-cell visibility culling RE-PLAN **Predecessor handoff:** [docs/research/2026-05-26-a8-revert-handoff.md](2026-05-26-a8-revert-handoff.md) **Status:** Report-only. Awaiting user approval of recommended fix-shape before Phase 2 (plan writing). **Empirical context (added during investigation):** the bug exists on `main` too — verified by side-by-side launch of `main` vs `HEAD = fef6c61`. Both branches show outdoor buildings/terrain visible through the walls of a cottage when standing inside. The bug is **fundamental**, not a regression in this worktree's 149-commit divergence. The A8 framing in the predecessor handoff stands. --- ## TL;DR The retail data model, WorldBuilder's data model, and the comment at `GameWindow.cs:5175-5178` all agree on a single architectural fact: **building shells are tagged distinctly from outdoor scenery at the data layer.** acdream's `LandblockLoader` reads both `LandBlockInfo.Objects` (scenery) and `LandBlockInfo.Buildings` (shells) into the same `WorldEntity` pool with no tag, destroying the distinction. The fix is to add `WorldEntity.IsBuildingShell: bool` at the loader, propagate it through hydration, and use it in the `WbDrawDispatcher.EntitySet` partition. This is **retail-faithful** (matches `BuildInfo` array) and **WB-faithful** (matches `SceneryInstance.IsBuilding`). GL state order from the A8 Round 3 learning (MarkAndPunch BEFORE indoor draw) is confirmed correct by reading WorldBuilder's `VisibilityManager.RenderInsideOut`. Far-side-portal (WB "Step 5", 3-stencil-bit) is deferred. First-ship approximation: only stencil-mark the **camera's own cell's** portals, not BFS-extended `VisibleCellIds`. --- ## The seven entity classes in acdream's runtime | # | Class | `ParentCellId` | `Id` prefix | `ServerGuid` | Source field | |---|---|---|---|---|---| | 1 | Cell mesh | set | `0x40xxxxxx` | 0 | `EnvCell.EnvironmentId` | | 2 | Cell static object | set | `0x40xxxxxx` | 0 | `EnvCell.StaticObjects` | | 3 | **Building shell stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Buildings`** | | 4 | **Outdoor scenery stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Objects`** | | 5 | Procedural scenery | null | `0x80xxxxxx` | 0 | `SceneryGenerator` (terrain table) | | 6a | Live animated | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet | | 6b | Live static | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet | **Classes 3 and 4 are indistinguishable at runtime today** (identical field shape after hydration). This is the load-bearing wrong assumption from the A8 attempt. ### Code anchors (acdream) - `src/AcDream.Core/World/LandblockLoader.cs:62-71` — Objects (Class 4) loop - `src/AcDream.Core/World/LandblockLoader.cs:74-87` — Buildings (Class 3) loop, **same `nextId++` counter, same WorldEntity shape** - `src/AcDream.App/Rendering/GameWindow.cs:5129-5137` — hydration pass-through, no distinction preserved - `src/AcDream.App/Rendering/GameWindow.cs:5175-5178` — the comment that proves the distinction is intentional in dat: > *"Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are static scenery placeholders themselves (rocks, tree clusters) that retail does NOT use to suppress scenery generation."* --- ## How retail tags buildings (cross-reference 1) `CLandBlock::init_buildings` (`acclient_2013_pseudo_c.txt:313854-313920`) reads `CLandBlockInfo::buildings[]` — a **separate `BuildInfo**` array**, NOT a flag bit or ID-range scheme. - `CLandBlockInfo.num_buildings` + `buildings[]` array (`acclient.h:31893-31905`) - `BuildInfo` struct: `building_id`, `building_frame`, `num_portals`, `CBldPortal** portals` (`acclient.h:32035-32042`) - Buildings hydrate via `CBuildingObj::makeBuilding()` (line 313879) and register into the landblock's `stablist[]` (per-landblock visible-cell set, line 313910) - Visibility uses **stablist (portal PVS)**, NOT AABB-encloses-camera. `CEnvCell::grab_visible` walks `stab_list[i]` directly. Conclusion: **retail explicitly distinguishes the two via separate dat arrays.** This is the data-model truth we should match. ## How WorldBuilder tags buildings (cross-reference 2) WB uses **two manager classes** sharing one mesh pool: - `StaticObjectRenderManager` — handles BOTH `LandBlockInfo.Objects` and `LandBlockInfo.Buildings`, tagging each `SceneryInstance.IsBuilding` (`StaticObjectRenderManager.cs:334-400`). - `SceneryRenderManager` — handles ONLY procedural terrain-derived scenery (different class entirely, doesn't share the dat path). Tagging happens at **hydration time** in `GenerateForLandblockAsync` (lines 315-427). The instance is then split into separate `StaticPartGroups` vs `BuildingPartGroups` for draw dispatch. `BuildingPortalGPU` (`PortalRenderManager.cs:687-701`) holds `EnvCellIds: HashSet` populated at landblock generation (line 549) — the "this building contains these EnvCells" association. The set is **never re-computed at render time**. WB's `RenderInsideOut` GL state order (`VisibilityManager.cs:73-239`): 1. Stencil bit 1 ← portal polygons (color/depth masks off) 2. `gl_FragDepth = 1.0` ← portal polygons (depth mask on, depth-func = Always) 3. **Interior EnvCells render WITHOUT stencil restriction** ← key step 4. Stencil-restricted (`Equal, 1`): terrain + scenery + buildings render only at portal silhouettes 5. (Step 5) 3-stencil-bit pipeline for cross-building visibility — DEFER **WB's order = MarkAndPunch (Step 1 + 2) FIRST, then indoor cells (Step 3).** This matches A8 Round 3's correction. The handoff's GL-state-order conclusion stands. --- ## Recommended fix-shape (synthesized) ### Stage 1: Tag at hydration (`IsBuildingShell` flag) Add `WorldEntity.IsBuildingShell: bool` (default false). In `LandblockLoader.cs`: - Objects loop (line 62): `IsBuildingShell = false` - Buildings loop (line 74): `IsBuildingShell = true` In `GameWindow.cs:5129-5137` (hydration): copy `IsBuildingShell` from `e` to the hydrated entity. One-line change. ### Stage 2: Refine `WbDrawDispatcher.EntitySet` partition Replace today's binary `IndoorOnly`/`OutdoorOnly` with: - `IndoorPass` — `ParentCellId.HasValue || IsBuildingShell` (Classes 1, 2, 3) - `OutdoorScenery` — `!ParentCellId.HasValue && !IsBuildingShell && (ServerGuid == 0)` (Classes 4, 5) - `LiveDynamic` — `ServerGuid != 0` (Classes 6a, 6b) `WalkEntitiesInto` updates one branch (the partition predicate). 26 dispatcher tests will need their fixture entities tagged correctly; otherwise behavior is the same. ### Stage 3: Re-wire render frame with WB's order When camera is inside a cell: 1. Draw terrain (color in framebuffer) 2. **MarkAndPunch** (stencil = 1 + depth = 1.0 at portal silhouettes) 3. `WbDrawDispatcher.Draw(set: IndoorPass)` — cell mesh + cell statics + building shells. Stencil disabled, depth test normal. These write depth ON TOP of the 1.0 punch, correctly occluding the next stencil-gated pass. 4. Re-draw terrain (color writes only) with `StencilFunc(Equal, 1)` — terrain visible only at portal silhouettes. 5. `WbDrawDispatcher.Draw(set: OutdoorScenery)` with `StencilFunc(Equal, 1)` — outdoor scenery visible only at portal silhouettes. 6. `WbDrawDispatcher.Draw(set: LiveDynamic)` — stencil disabled, depth test on. Live entities draw freely; depth occludes them by walls and cell meshes already in the depth buffer. When camera is outside: stencil work skipped entirely. Today's all-entities single draw stands (or substitute the three EntitySet calls with stencil disabled — depth still sorts them correctly). ### Stage 4: Far-side-portal approximation (defer Step 5) Stencil-mark **only the camera's own cell's portals** in Step 2, not the BFS-extended `VisibleCellIds`. This trades cross-cell-portal visibility (rare visually) for correctness in the common case (no "see-through-wall on the other side of the room"). Track as a known limitation; revisit if visual gate flags it. --- ## Reasons for confidence 1. **Triple-cited**: retail (`BuildInfo` array), WB (`IsBuilding` flag), acdream's own code comment (5175-5178) all agree on the distinction. 2. **Tagging cost is microscopic** — one bool on `WorldEntity`, one branch in `LandblockLoader`. No new types, no new managers, no field migration. 3. **`EntitySet` enum is already in place** (dormant from Tasks 1-6). Refactor is reshaping its semantics, not introducing it. 4. **GL state order is validated** by both Round 3 of the A8 attempt and WB's reference. No remaining ambiguity. 5. **Live-dynamic separation handles the Round 1 character-disappears bug** (handoff §Round 1). They draw last, stencil disabled, depth-tested against everything else. ## Open questions for user approval 1. Use `IsBuildingShell` flag (recommended) vs separate `0xC1xxxxxx` ID-namespace? Flag is more explicit, retail-faithful, and trivially greppable. ID-namespace is one less field but invisible at the call site. 2. Defer Step 5 (far-side portals) and stencil-mark only camera's own cell? Recommendation: yes — ship simple, file follow-up. 3. Live-dynamic entities (Class 6b: dropped items) — draw in `LivePass` or accept "invisible from inside" until a richer flag exists? Recommendation: `LivePass`. They're rare visually, and the player benefits from seeing dropped items through the floor (gameplay nicety, not retail violation). 4. Cellar-stairs grass overlay from OUTSIDE: NOT A8 scope (no stencil runs when camera is outside). Open question for a future "deep-cell terrain occlusion" phase. Confirm we file this separately, not bundled. --- ## Reference anchors (still valid from predecessor handoff) - WB stencil: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` - WB building-cell association: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551` - Retail building init: `docs/research/named-retail/acclient_2013_pseudo_c.txt:313854-313920` - Retail building struct: `docs/research/named-retail/acclient.h:31893-31905`, `:32035-32042`, `:32094-32103` - acdream LandblockLoader: `src/AcDream.Core/World/LandblockLoader.cs:62-87` - acdream hydration: `src/AcDream.App/Rendering/GameWindow.cs:5093-5148`, `:5175-5178`