acdream/docs/research/2026-05-26-a8-entity-taxonomy.md
Erik 5dc4140c11 feat(render): Phase A8 — indoor visibility + streaming fixes batch
Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:14:50 +02:00

10 KiB

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 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<uint> 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.


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:

  • IndoorPassParentCellId.HasValue || IsBuildingShell (Classes 1, 2, 3)
  • OutdoorScenery!ParentCellId.HasValue && !IsBuildingShell && (ServerGuid == 0) (Classes 4, 5)
  • LiveDynamicServerGuid != 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