Compare commits

..

No commits in common. "f0d37d89557323cd75d9ae28d29751d50384e4b5" and "7034be929493adaa68ae66e890aaefb29d03b9c1" have entirely different histories.

419 changed files with 1145 additions and 1682702 deletions

1
.gitattributes vendored
View file

@ -1 +0,0 @@
.github/workflows/*.lock.yml linguist-generated=true merge=ours

View file

@ -1,236 +0,0 @@
---
description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing
disable-model-invocation: true
---
# GitHub Agentic Workflows Agent
This agent helps you work with **GitHub Agentic Workflows (gh-aw)**, a CLI extension for creating AI-powered workflows in natural language using markdown files.
## What This Agent Does
This is a **dispatcher agent** that routes your request to the appropriate specialized prompt based on your task:
- **Creating new workflows**: Routes to `create` prompt
- **Updating existing workflows**: Routes to `update` prompt
- **Debugging workflows**: Routes to `debug` prompt
- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt
- **Creating report-generating workflows**: Routes to `report` prompt — consult this whenever the workflow posts status updates, audits, analyses, or any structured output as issues, discussions, or comments
- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt
- **Fixing Dependabot PRs**: Routes to `dependabot` prompt — use this when Dependabot opens PRs that modify generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`). Never merge those PRs directly; instead update the source `.md` files and rerun `gh aw compile --dependabot` to bundle all fixes
- **Analyzing test coverage**: Routes to `test-coverage` prompt — consult this whenever the workflow reads, analyzes, or reports on test coverage data from PRs or CI runs
- **Rendering ASCII charts in markdown**: Routes to `asciicharts` guide — consult this whenever the workflow needs compact charts that render reliably in GitHub issues, comments, or discussions
- **CLI commands and triggering workflows**: Routes to `cli-commands` guide — consult this whenever the user asks how to run, compile, debug, or manage workflows from the command line, or when they need the MCP tool equivalent of a `gh aw` command
- **Reducing token consumption / cost optimization**: Routes to `token-optimization` guide — consult this whenever the user asks how to reduce token usage, lower costs, speed up workflows, or measure the impact of prompt changes with experiments
- **Choosing workflow architectures and design patterns**: Routes to `patterns` guide — consult this whenever the user asks for strategy, architecture, operating models, or pattern selection for agentic workflows
> [!IMPORTANT]
> For architecture/pattern-selection requests, load `https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/patterns.md` first.
Workflows may optionally include:
- **Project tracking / monitoring** (GitHub Projects updates, status reporting)
- **Orchestration / coordination** (one workflow assigning agents or dispatching and coordinating other workflows)
## Files This Applies To
- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md`
- Workflow lock files: `.github/workflows/*.lock.yml`
- Shared components: `.github/workflows/shared/*.md`
- Configuration: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/github-agentic-workflows.md
## Problems This Solves
- **Workflow Creation**: Design secure, validated agentic workflows with proper triggers, tools, and permissions
- **Workflow Debugging**: Analyze logs, identify missing tools, investigate failures, and fix configuration issues
- **Version Upgrades**: Migrate workflows to new gh-aw versions, apply codemods, fix breaking changes
- **Component Design**: Create reusable shared workflow components that wrap MCP servers
## How to Use
When you interact with this agent, it will:
1. **Understand your intent** - Determine what kind of task you're trying to accomplish
2. **Route to the right prompt** - Load the specialized prompt file for your task
3. **Execute the task** - Follow the detailed instructions in the loaded prompt
## Available Prompts
### Create New Workflow
**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/create-agentic-workflow.md
**Use cases**:
- "Create a workflow that triages issues"
- "I need a workflow to label pull requests"
- "Design a weekly research automation"
### Update Existing Workflow
**Load when**: User wants to modify, improve, or refactor an existing workflow
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/update-agentic-workflow.md
**Use cases**:
- "Add web-fetch tool to the issue-classifier workflow"
- "Update the PR reviewer to use discussions instead of issues"
- "Improve the prompt for the weekly-research workflow"
### Debug Workflow
**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/debug-agentic-workflow.md
**Use cases**:
- "Why is this workflow failing?"
- "Analyze the logs for workflow X"
- "Investigate missing tool calls in run #12345"
### Upgrade Agentic Workflows
**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/upgrade-agentic-workflows.md
**Use cases**:
- "Upgrade all workflows to the latest version"
- "Fix deprecated fields in workflows"
- "Apply breaking changes from the new release"
### Create a Report-Generating Workflow
**Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/report.md
**Use cases**:
- "Create a weekly CI health report"
- "Post a daily security audit to Discussions"
- "Add a status update comment to open PRs"
### Create Shared Agentic Workflow
**Load when**: User wants to create a reusable workflow component or wrap an MCP server
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/create-shared-agentic-workflow.md
**Use cases**:
- "Create a shared component for Notion integration"
- "Wrap the Slack MCP server as a reusable component"
- "Design a shared workflow for database queries"
### Fix Dependabot PRs
**Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`)
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/dependabot.md
**Use cases**:
- "Fix the open Dependabot PRs for npm dependencies"
- "Bundle and close the Dependabot PRs for workflow dependencies"
- "Update @playwright/test to fix the Dependabot PR"
### Analyze Test Coverage
**Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy.
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/test-coverage.md
**Use cases**:
- "Create a workflow that comments coverage on PRs"
- "Analyze coverage trends over time"
- "Add a coverage gate that blocks PRs below a threshold"
### Render ASCII Charts in Markdown
**Load when**: The workflow needs in-markdown charts (sparklines, bars, table+trend views) that must align cleanly and render reliably across GitHub surfaces, including mobile.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/asciicharts.md
**Use cases**:
- "Show a compact trend chart in an issue comment"
- "Render a dashboard table with sparkline trends"
- "Generate aligned ASCII bars for service metrics"
### CLI Commands Reference
**Load when**: The user asks how to run, compile, debug, or manage workflows from the command line; needs the MCP tool equivalent of a `gh aw` command; or is in a restricted environment (e.g., Copilot Cloud) without direct CLI access.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/cli-commands.md
**Use cases**:
- "How do I trigger workflow X on the main branch?"
- "What's the MCP equivalent of `gh aw logs`?"
- "I'm in Copilot Cloud — how do I compile a workflow?"
- "Show me all available gh aw commands"
### Token Consumption Optimization
**Load when**: The user asks how to reduce token usage, lower workflow costs, make a workflow faster or cheaper, or measure the impact of prompt or configuration changes.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/token-optimization.md
**Use cases**:
- "How do I reduce the token cost of this workflow?"
- "My workflow is too expensive — how do I optimize it?"
- "How do I compare token usage between two runs?"
- "Should I use gh-proxy or the MCP server?"
- "How do I use sub-agents to reduce costs?"
- "How do I measure the impact of a prompt change?"
### Workflow Pattern Selection
**Load when**: The user asks for architecture, strategy, operating model selection, or pattern recommendations for building agentic workflows.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/patterns.md
**Use cases**:
- "Which pattern should I use for multi-repo rollout?"
- "How should I structure this workflow architecture?"
- "What pattern fits slash-command triage?"
- "Should this be DispatchOps or DailyOps?"
## Instructions
When a user interacts with you:
1. **Identify the task type** from the user's request
2. **Load the appropriate prompt** from the GitHub repository URLs listed above
3. **Follow the loaded prompt's instructions** exactly
4. **If uncertain**, ask clarifying questions to determine the right prompt
## Quick Reference
```bash
# Initialize repository for agentic workflows
gh aw init
# Generate the lock file for a workflow
gh aw compile [workflow-name]
# Trigger a workflow on demand (preferred over gh workflow run)
gh aw run <workflow-name> # interactive input collection
gh aw run <workflow-name> --ref main # run on a specific branch
# Debug workflow runs
gh aw logs [workflow-name]
gh aw audit <run-id>
# Upgrade workflows
gh aw fix --write
gh aw compile --validate
```
## Key Features of gh-aw
- **Natural Language Workflows**: Write workflows in markdown with YAML frontmatter
- **AI Engine Support**: Copilot, Claude, Codex, or custom engines
- **MCP Server Integration**: Connect to Model Context Protocol servers for tools
- **Safe Outputs**: Structured communication between AI and GitHub API
- **Strict Mode**: Security-first validation and sandboxing
- **Shared Components**: Reusable workflow building blocks
- **Repo Memory**: Persistent git-backed storage for agents
- **Sandboxed Execution**: All workflows run in the Agent Workflow Firewall (AWF) sandbox, enabling full `bash` and `edit` tools by default
## Important Notes
- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/github-agentic-workflows.md for complete documentation
- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud
- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions
- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF
- Follow security best practices: minimal permissions, explicit network access, no template injection
- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns.
- **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself.
- **Triggering runs**: Always use `gh aw run <workflow-name>` to trigger a workflow on demand — not `gh workflow run <file>.lock.yml`. `gh aw run` handles workflow resolution by short name, input parsing and validation, and correct run-tracking for agentic workflows. Use `--ref <branch>` to run on a specific branch.
- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/cli-commands.md

11
.github/mcp.json vendored
View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"github-agentic-workflows": {
"command": "gh",
"args": [
"aw",
"mcp-server"
]
}
}
}

View file

@ -1,3 +0,0 @@
{
"ghes": false
}

View file

@ -1,26 +0,0 @@
name: "Copilot Setup Steps"
# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent
copilot-setup-steps:
runs-on: ubuntu-latest
# Set minimal permissions for setup steps
# Copilot Agent receives its own token with appropriate permissions
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
version: v0.74.8

File diff suppressed because it is too large Load diff

View file

@ -1,146 +0,0 @@
---
description: Daily hygiene assessment of acdream's main branch — flag workarounds,
ungrounded code, Phase/roadmap drift, and architecture violations.
on:
schedule: daily
workflow_dispatch: {}
permissions: read-all
network:
allowed:
- defaults
- dotnet
tools:
github:
toolsets: [default]
safe-outputs:
create-issue:
max: 1
close-older-issues: true
labels:
- ai
- hygiene
engine:
id: copilot
model: gpt-5.3-codex
---
# acdream Hygiene Assessment
You are **DereLint**, a focused AI auditor for the acdream Asheron's Call client.
Your job: scan `main` once a day and produce a single rolling report on hygiene
drift. Engineer-grade tone. No persona slang. The audience is a senior C# /
systems engineer who already operates under a strict retail-faithfulness rule.
## Mission
acdream's core rule (from `CLAUDE.md`): **"The code is modern. The behavior is
retail."** Every AC-specific algorithm must be ported from
`docs/research/named-retail/` (the Sept 2013 EoR PDB) and never guessed. The
roadmap drives one phase at a time. Workarounds are forbidden unless the user
has explicitly approved them. Drift from any of that is what you flag.
Before you start your analysis: `git fetch origin main && git checkout main`.
Then read these to ground yourself:
- `CLAUDE.md` — the project's operating instructions (most important)
- `docs/plans/2026-04-11-roadmap.md` — current phase, agreed order
- `docs/plans/2026-05-12-milestones.md` — current milestone
- `docs/ISSUES.md` — open issues you must NOT re-file
- `docs/architecture/acdream-architecture.md` — architecture source of truth
## What to look for
Five categories. For each finding, cite `file:line`.
### 1. Workaround patterns (CLAUDE.md forbids these unless user-approved)
- `// WORKAROUND` / `// HACK` / `// FIXME` / `// XXX` comments
- Guard early-returns at symptom sites (`if (badState) return;`) that look like
band-aids rather than root-cause fixes
- `try/catch` blocks swallowing exceptions silently
- "grace period" timers / "settle delay" sleeps
- Flags named like `_suppressXDuringY` that mask wire-level mistakes
### 2. Ungrounded retail-port code
- AC-specific algorithm code (collision, animation, motion, dat-decode,
rendering math) that has **no decomp citation** in comments. Every
retail-faithful port should reference a symbol from
`docs/research/named-retail/symbols.json` or a function address from
`docs/research/decompiled/`.
- Magic numbers in physics / motion / wire-format paths that aren't cited
against a retail source.
### 3. Roadmap drift
- Phase markers in code (`// Phase L.5:`, `// Phase N.4:`) that reference
phases no longer matching the roadmap.
- Sections of `docs/plans/2026-04-11-roadmap.md` flagged "ahead" / "active"
that don't match what the last 20 commits actually touched.
- The "Currently working toward" line in `CLAUDE.md` vs. what the last 20
commit subjects actually touched. If they disagree, flag it.
### 4. Test / build hygiene
- `dotnet build` warnings (the project should build with zero warnings).
- Tests in failing state (`dotnet test`).
- Test count regression below the baseline documented in `CLAUDE.md`.
- Build / launch needing `--no-build` workarounds anywhere.
### 5. Architecture drift
- `using WorldBuilder.*` outside `src/AcDream.App/Rendering/Wb/` and
`src/AcDream.Core/Rendering/Wb/` (Phase O extracted WB code into those
directories — references outside are a regression).
- `Environment.GetEnvironmentVariable("ACDREAM_*")` calls outside diagnostic
owner classes (per `CLAUDE.md` "Code Structure Rules" item 5).
- `IDatReaderWriter` consumers that should be using `DatCollection`
(post-Phase O: `DatCollection` is the only dat reader).
- Code in `AcDream.Core` that references `AcDream.App` or GL types directly
(layer separation violation per `CLAUDE.md` Code Structure Rules item 2).
## Accepted exceptions
If `docs/ISSUES.md` already has an OPEN entry for a finding, **don't re-file
it**. Mention it under "Known accepted exceptions" instead. Same for items
explicitly listed as deferred in the roadmap.
## Output
Create one GitHub Issue titled `acdream Hygiene Report YYYY-MM-DD`. The
framework will close any prior `ai+hygiene`-labeled issues automatically.
Body structure:
### Executive Summary
Two sentences on overall hygiene. Concrete; no fluff.
### Findings
For each: **Location** (file:line, linked to the source), **Category** (1-5),
**Problem** (one sentence), **Recommendation** (one sentence),
**Decomp/Doc reference** (where applicable — cite the named symbol or doc).
### Roadmap reality check
Currently-working-toward line vs. recent commit subjects. State whether they
match or where they diverge.
### Known accepted exceptions
Issues already filed in `docs/ISSUES.md` that you observed during the scan.
Name them by ID, don't re-file.
### Suggested next step
ONE concrete action the team should take. If everything is clean, call the
`noop` safe-output with "All clear — no hygiene drift found." instead of
creating an issue.
## Style
- Engineer tone. No slang.
- Be specific. "Workaround in PhysicsEngine.cs:142" beats "physics has issues."
- Be conservative. If you're unsure something is a workaround vs. an
intentional retail-faithful port, say so — don't assert.
- Keep the report under 1500 words. The team wants signal, not a wall of text.

12
.gitignore vendored
View file

@ -31,16 +31,6 @@ launch-*.log
launch.utf8.log
n4-verify*.log
# A6.P5 (2026-05-25) — door-stuck reproduction captures (multi-MB);
# the 3-record fixture extracted from these lives at
# tests/AcDream.Core.Tests/Fixtures/door-bug/over-penetration-capture.jsonl
door-stuck-capture.jsonl
door-stuck-*.launch.log
door-stuck-*.launch.utf8.log
door-fix-*.launch.log
door-fix-*.jsonl
door-walkthrough.*
# ImGui auto-saved window/docking state (per-user, not source)
imgui.ini
@ -61,8 +51,6 @@ tmp/
# The committed reference workflow lives in CLAUDE.md "Retail debugger toolchain";
# session-specific traces should not pollute the repo.
*.cdb
# tools/cdb/ holds committed reference scripts — exempt them from the blanket rule above.
!tools/cdb/*.cdb
launch_*.log
launch_*.err
launch_*.ps1

View file

@ -1,5 +0,0 @@
{
"github.copilot.enable": {
"markdown": true
}
}

490
CLAUDE.md
View file

@ -25,62 +25,49 @@ 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.
**WorldBuilder code lives in our tree as of Phase O (shipped 2026-05-21).**
Phase N.4 (2026-05-08) adopted WB's rendering + dat-handling base as a
project reference. Phase O (2026-05-21) extracted the ~33 files / ~7.7K LOC
we actually use into our own namespaces and dropped the two external project
references. `DatCollection` is now the **only** dat reader in process —
`DefaultDatReaderWriter` is gone. `references/WorldBuilder/` remains in-tree
as a read-reference (MIT-licensed; grep it freely), but nothing in
`src/AcDream.*` references it as a project dependency.
**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".
**Where the extracted code lives (post-Phase O):**
- `src/AcDream.Core/Rendering/Wb/` — pure dat/mesh helpers (5 files, ~782 LOC):
`TerrainUtils`, `TerrainEntry`, `RegionInfo`, `SceneryHelpers`,
`TextureHelpers`. No GL dependency; safe to use from Core.
- `src/AcDream.App/Rendering/Wb/` — GL infrastructure + mesh pipeline (~27 files,
~7K LOC): `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`,
`LandblockSpawnAdapter`, `EntitySpawnAdapter`, `TextureCache`,
`GlobalMeshBuffer`, shader infrastructure, and the EnvCell/portal/scenery/
terrain-blending pipeline classes.
Before re-implementing any AC-specific rendering or dat-handling algorithm,
**read `docs/architecture/worldbuilder-inventory.md` FIRST**. The inventory
describes what we extracted (now in our tree) and what we still write ourselves.
Re-porting from retail decomp when we already have 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.
**WB rendering cribs (all paths now in `src/AcDream.App/Rendering/Wb/`):**
- `WbMeshAdapter.cs` — single seam over `ObjectMeshManager`. Owns the mesh
pipeline, drains its staged-upload queue per frame via `Tick()`, populates
`AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog
metadata. Consumes `DatCollection` via `DatCollectionAdapter` (O-D7 fallback
path; `ObjectMeshManager` has 26 internal `_dats.X` call sites that exceed
the inline-swap threshold — the adapter bridges our `IDatCollection` to the
`IDatReaderWriter` interface WB's internals expect).
- `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.
- `LandblockSpawnAdapter.cs` / `EntitySpawnAdapter.cs` — bridge spawn lifecycle
to ref-counts. Atlas tier (procedural) goes via Landblock; per-instance tier
**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.
- **The modern rendering path** (GL 4.3 + bindless) packs every mesh
- **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.)
- **`ObjectRenderBatch.SurfaceId` is unset** — the actual surface
- **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
@ -96,14 +83,14 @@ audio, chat — see the inventory doc's 🔴 list.
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 modern path.
- **`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 original WB), not
- **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
@ -116,8 +103,8 @@ audio, chat — see the inventory doc's 🔴 list.
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 the original WB `TerrainRenderManager`
pattern (single global VBO/EBO + slot allocator + `glMultiDrawElementsIndirect`)
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
@ -202,16 +189,14 @@ pursuing live in [`docs/architecture/code-structure.md`](docs/architecture/code-
as part of the change.
2. **`AcDream.Core` must not depend on the window / GL / backend
projects, except via documented interop seams.** As of Phase O
(2026-05-21), the only allowed seams are the extracted helpers in
`src/AcDream.Core/Rendering/Wb/` (`TerrainUtils`, `TerrainEntry`,
`RegionInfo`, `SceneryHelpers`, `TextureHelpers` — stateless, no GL).
The former `WorldBuilder.Shared` and `Chorizite.OpenGLSDLBackend.Lib`
project references are gone; their code now lives in our tree at those
paths. 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
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
@ -713,339 +698,14 @@ 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`.
**Phase O — DatPath Unification — SHIPPED 2026-05-21.** ONE thing
touches the DATs. ~33 WB files (~7.7K LOC) extracted into
`src/AcDream.{Core,App}/Rendering/Wb/`. Project references to
`WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend` dropped.
`DefaultDatReaderWriter` eliminated; `DatCollection` is the only dat
reader. `WbMeshAdapter` consumes it via `DatCollectionAdapter`
(O-D7 fallback; 26 `_dats.*` call sites exceeded the inline-swap
threshold). `references/WorldBuilder/` stays in-tree as read-reference.
Visual side-by-side passed: Holtburg town, inn interior, dungeon all
render identically to pre-O. Spec:
[`docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md`](docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md).
**2026-05-30 — RENDER PIPELINE PIVOT (read this first).** The two-pipe
(inside / outside) render approach is **ABANDONED**. acdream inherited a
WorldBuilder-style split — a normal outdoor draw plus a separate flat
`RenderInsideOut` stencil pass toggled on `cameraInsideBuilding` — and that
split is the root cause of every indoor seam bug (the flap, missing/transparent
walls, terrain bleeding into interiors). Retail has no such split; it renders
through one portal-visibility traversal (`PView`) and is seamless by
construction. We are building **Phase U — a single unified retail-faithful
render pipeline**. This supersedes the A8/A8.F two-pipe arc (issue #103). The
camera-collision work (retail `SmartBox::update_viewer` spring arm) + a
physics viewer-cap fix **SHIPPED this session and are kept** (they're real and
retail-faithful, just not the seam fix). Full decision + scope + next-session
pickup prompt:
[`docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`](docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md).
The M1.5 narrative below is history retained for context.
**2026-05-31 — U.4c doorway FLAP FIXED** (`0ee328a`, visual-verified "flap gone").
Root cause (converged on a live `ACDREAM_PROBE_FLAP` capture, after disproving an
H2 `PortalSide` side-test fix and an H1 PVS-grounding hypothesis): indoor visibility
was rooted at the 3rd-person camera **eye**, which drifts out of the player's cell →
`FindCameraCell` returns a STALE cell for its grace frames → the doorway portal is
culled as behind-the-eye → exit cell + terrain + shells drop. Fix: root indoor
visibility (cell resolution + portal-side test) at the **player's cell**
(retail `CellManager::ChangePosition`; matches the existing lighting decision). Eye
still drives projection. **The flap is done; the indoor pipeline is NOT yet seamless**
the visual gate revealed three SEPARATE residuals: (1) **#78** outdoor terrain not gated
inside (now more visible since terrain draws again); (2) **camera collision** needed (the
chase eye is outside the player's cell ~79% of frames → the eye-projected clip
over-includes → transparent outer walls); (3) **U.5** outside-looking-in (deferred).
Camera collision (retail `SmartBox::update_viewer` keeping the eye in the cell) is the
highest-leverage next step. CANONICAL handoff (read first next session):
[`docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md`](docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md).
Apparatus `ACDREAM_PROBE_FLAP` + `tools/A8CellAudit` are committed + ready. Do NOT retry
H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven.
**Currently working toward: M1.5 — Indoor world feels right** (resumed
from 2026-05-20 baseline after Phase O ship). **A6.P1 + A6.P2 + A6.P3
slice 1 SHIPPED 2026-05-21.** **A6.P3 slice 2 v2 SHIPPED 2026-05-22**
(commit `f8d669b`): tried removing the L622 per-tick CP seed
(`892019b` v1) but it broke BSP step_up at the last step of stairs;
reverted + added a benign no-op-if-unchanged guard inside
`CollisionInfo.SetContactPlane`. Slice 2 outcome: **#96 partially
addressed — accepted as documented retail divergence** (the per-tick
seed is load-bearing for `AdjustOffset` slope-projection on sub-step 1
which BSP step_up depends on; matching retail would require deeper
refactor of AdjustOffset). Slice 2 verification surfaced a NEW
M1.5-blocking bug: **user cannot walk UP out of cottage cellar — stuck
at last step due to cell-resolver ping-pong (filed as issue #98,
Finding 3 family).** **A6.P3 slice 3 SHIPPED 2026-05-22** (commits `8898166` v1 +
`3e140cf` v2): cell-resolver stickiness added in `ResolveCellId`'s
indoor branch (point-in check against `fallbackCellId`'s CellBSP
before falling through to FindCellList). Data confirms ping-pong is
FULLY CLOSED — scen4 cellar capture shows 1 cell-transit (login
teleport) vs 20+ pre-fix. **#90 workaround now redundant — deferred
to A6.P4 removal. #98 APPARATUS COMPLETE 2026-05-23 evening**
(commits `35b37df` triage → `f62a873` cell-dump probe → `3f56915`
fixtures → `856aa78` replay harness → `6f666c1` cdb script →
`28c282a` divergence comparison doc). Four sessions of speculative
fixes (10+ variants) shipped the wrong diagnosis each time; this
session shipped the APPARATUS that turns evidence-driven analysis
into a 200ms test loop. Real divergence: retail's sphere is at
world Z ≈ 94.48 (resting on cottage floor) when find_walkable
accepts; acdream's failing-frame sphere is at world Z ≈ 92.01
(2.47m lower). Retail's ContactPlane writes during cellar-up are
ONLY flat floors (cellar floor or cottage floor), never the ramp.
Retail's find_crossed_edge fires once in 35K BPs; ours uses it
heavily. **Fix targets (priority): (1) Transition.AdjustOffset
slope projection / DoStepUp WalkInterp handling — ramp climb
doesn't gain Z; (2) cottage-cell candidacy using wrong sphere
reference; (3) find_crossed_edge over-use; (4) ramp polygon normal
divergence (low confidence).** Full divergence reading +
fix-plan pickup prompt at
[`docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md`](docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md).
Current A6 phase:
**A6.P3 — PAUSED 2026-05-23 (full day). Trajectory replay harness shipped
but BLOCKED on a new bug surfaced during commissioning.** Read
[`docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md`](docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md)
as the canonical pickup document — it has the chronological commit list,
the apparatus inventory, the exclusion list (do-not-retry), and three
concrete next-session options ranked by recommendation.
The session shipped further apparatus + first failed fix attempt + revert:
`8a232a3` (`[step-walk-adjust]` probe inside `Transition.AdjustOffset`
revealing branch tokens and per-call zGain), `8daf7e7` (findings note
at [`docs/research/2026-05-23-a6-stepwalkadjust-findings.md`](docs/research/2026-05-23-a6-stepwalkadjust-findings.md)
+ capture snapshot), `0cb4c59` (Shape 1 fix: gate `BSPQuery.AdjustSphereToPlane`'s
two `SetContactPlane` call sites by `Normal.Z >= 0.99`), `402ec10`
(revert — Shape 1 broke OnWalkable tracking, sphere went into falling
state on every sloped surface). **Refined diagnosis:** AdjustOffset is
CORRECT (145/146 calls take `into-plane` branch, +0.045 m mean zGain
per call when offset points into ramp); the climb CAPS at world Z ≈
92.80 because step-up's downward step-down probe finds no walkable
within 0.6 m below the proposed position (cottage floor is ABOVE).
Earlier "Fix targets 14" priority list is OBSOLETE — AdjustOffset
projection is not the problem. The actual bug is in the step-up
validation at the ramp top. **Honest next-session moves**: (1) build
deterministic trajectory replay harness so fix attempts iterate in
<500ms instead of 5-minute live-test cycles; (2) pivot to a less-
coupled M1.5 issue while #98 awaits the harness; (3) targeted decomp
research on `CEnvCell::find_env_collisions``BSPTREE::find_collisions`
indoor CP-setting chain (prior research worked on the outdoor
`CLandCell` path; indoor was never fully traced). Session-end ISSUES.md
entry has the full reading and pickup prompt. **NO further #98 fix
attempts until apparatus or research has converged — six+ failed
attempts in the saga is the signal.**
**Late-day extension (2026-05-23 PM):** trajectory replay harness shipped
(commits `4c9290c``5c6bdbe`). Mechanics work — runs 200 ticks in <100 ms.
Five tests pass. NEW finding: the cellar ramp polygon is in a GfxObj
(static building piece), not the cell's PhysicsPolygons. Harness now
includes `RegisterStairRampGfxObj` for synthetic stair construction
and `AttachSyntheticBsp` to wrap hydrated cells (which have BSP=null)
with a one-leaf BSP that exposes the indoor BSP collision path.
**NEW BLOCKER:** even with full apparatus, sphere goes airborne at
tick 1 with `hit=(0,1,0)` (a +Y wall normal matching no registered
geometry). 6 hypotheses tested via the harness, none isolated root cause.
Per systematic-debugging skill's "question architecture" rule, stop and
reflect. Next session: build a side-by-side comparison harness that
captures live PlayerMovementController state and diffs against the
test harness — evidence-first instead of speculation-first.
Findings doc:
[`docs/research/2026-05-21-a6-cdb-capture-findings.md`](docs/research/2026-05-21-a6-cdb-capture-findings.md).
**Evening extension v2 (2026-05-23 PM late) — apparatus shipped + root
cause identified.** Four commits (`fb5fba6``44614ab``0f2db62`
`f29c9d5`). The side-by-side comparison harness was built and exercised:
- `PhysicsResolveCapture` ships a JSON Lines writer for every player-side
`ResolveWithTransition` call. Off by default; turn on via
`ACDREAM_CAPTURE_RESOLVE=<path>`. Filtered to `IsPlayer` so NPC / remote
DR doesn't pollute.
- Two live captures from a cottage-cellar session (41K + 70K records).
- Three `LiveCompare_*` tests load 3 representative records (spawn,
on-ramp, first-cap). Spawn + on-ramp PASS bit-perfect; the first-cap
test originally FAILED with a clear divergence — and that divergence
pinpoints the root cause.
- **The cap is caused by `obj=0xA9B47900` — a landblock-baked cottage
GfxObj.** Cottage floor polygons live in this GfxObj's polygon table
(registered as a ShadowEntry), NOT in any cottage cell. The harness's
cell fixtures (0xA9B40143/146/147) don't include the cottage GfxObj,
so the harness fails to reproduce the live cn=(0,0,-1) cap.
- User's confirming observation: jumping in the cellar caps at the same
Z — purely vertical motion. This rules out every step-up / AdjustOffset
hypothesis from the prior 6-shape saga. The bug is the head sphere
hitting the cottage floor at Z=94.0 from below (math: foot Z=92.74
+ sphereHeight 1.20 = head center 93.94, head top 94.42, intersects
cottage floor Z=94.0).
- The first-cap test is now in documents-the-bug form (PASSES while
bug exists; FAILS when fix lands). Test baseline maintained at
1178 + 8 (serial run).
- 13 new cell fixtures cover the full 0xA9B4014X neighborhood (272 KB).
Findings doc (canonical pickup):
[`docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md`](docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md).
**Evening v2 follow-on — apparatus convergence SHIPPED 2026-05-23 PM.**
Two commits (`cc3afbc``97fec19`):
- `cc3afbc` adds the GfxObj dump infrastructure (`ACDREAM_DUMP_GFXOBJS`)
mirroring the existing `ACDREAM_DUMP_CELLS` pattern, with new
`GfxObjDump`/`GfxObjDumpSerializer` parallel to `CellDump`. The new
env var triggers `PhysicsDataCache.CacheGfxObj` to write the full
resolved polygon table as JSON when a listed id caches. Closes the
gap that the existing `[resolve-bldg]` probe couldn't fill (the BSP
wire site that populates `LastBspHitPoly` was never wired, so the
probe only emitted GfxObj-level metadata, not per-poly geometry).
- `97fec19` lands the cottage GfxObj fixture (`0x01000A2B`, 74 polygons,
BSP radius 13.989m matching live), the new `RegisterCottageGfxObj`
harness helper, and a minimum-stub landblock so
`TryGetLandblockContext` succeeds at the cellar XY. Harness now
reproduces the live `cn=(0,0,-1)` cap bit-perfect. The full per-field
round-trip uncovers ONE residual: live preserves +0.0266m of +X
motion through the cap (edge-slide along the cottage floor); harness
blocks all motion. Captured in
`LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation`
in documents-the-bug form.
- All 21 issue-#98-relevant tests (12 harness + 4 GfxObjDumpRoundTrip +
1 new PhysicsDiagnosticsTests + 4 CellDumpRoundTripTests) pass
deterministically in isolation.
- Pre-existing test suite flakiness observed (819 failures across runs
of the same code, from PhysicsResolveCapture / PhysicsDiagnostics
statics leaking between test classes). INDEPENDENT of A6.P3 — verified
by stashing the cottage helper and reproducing the same flaky range.
Out of scope for this session; tracked as follow-up.
**Evening v3 finding (2026-05-23 PM, even later) — NEW root-cause
hypothesis identified:** the cottage-floor cap is a SYMPTOM. The actual
bug is **stale ramp contact plane causing per-tick Z drift** that makes
the cap reachable in the first place.
Evidence:
- Body's contact plane at cap = ramp's plane (n=(0, 0.7190, 0.6950),
d=-69.5035) from the live capture's `bodyBefore`
- Cellar ramp's actual world XY: X∈[129.7, 131.3], Y∈[10.19, 13.09]
(computed from the cellar cell fixture's vertex data + WorldTransform)
- Player position at cap: world (141.5, 7.22, 92.74) — **10 m away**
from the ramp in cell-local X
- `AdjustOffset` projects requested motion along the contact-plane
perpendicular. Math: dot((0.0266, -0.4022, 0), (0, 0.719, 0.695))
= -0.2892 → projected = (0.0266, -0.1943, +0.2010). **+0.201 m of
Z gain per tick**, applied because the engine believes the player
is on the slope.
- Head sphere top at cap = foot Z + 1.68 = 94.42. Cottage floor at
Z=94.00. **Head sphere exceeds cottage floor by 0.42 m** → cap fires
- If the contact plane refreshed to the flat cellar floor when the
player walked off the ramp, AdjustOffset would produce zero Z gain
(no Z component in requested motion + horizontal-plane perpendicular).
No drift, no cap.
How this question surfaced: user asked "we know how retail OPENs it
from above, how hard can it be to know how to open it from below?" —
that reframing made the question "what's different about our state
when walking up vs down?" The answer: **nothing, actually — the
cottage geometry is the same. But our contact plane is wrong.** The
six prior fix attempts were all investigating the cap-event mechanics
(step-up, slope projection at the cap, edge-slide, SidesType, +X
residual). None questioned why the contact plane was the ramp at all
when the player was 10 m from the ramp.
**Next-session move:** verify the stale-contact-plane hypothesis
chronologically against the live capture (walk the JSONL records, find
the last tick the player was on the actual ramp, quantify Z drift),
then locate the walkable-refresh code path in
`Transition.FindEnvCollisions` / `SpherePath.SetWalkable` that's
supposed to detect a new walkable polygon under the sphere and
overwrite the contact plane. Retail decomp anchor:
`CObjCell::find_env_collisions`. Full pickup prompt at the bottom of
[`docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md`](docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md).
**A6.P4 door bug — `pos_hits_sphere` near-miss recording shipped
2026-05-25 PM** (commit `3253d84`). Single-line ordering fix in
`BSPQuery.PosHitsSphere`: `if (hit) hitPoly = poly;` now precedes the
front-face cull, matching retail's `CPolygon::pos_hits_sphere` at
`acclient_2013_pseudo_c.txt:322974-322993` where `*arg5 = this` fires
on static-overlap BEFORE `dot(N, movement) >= 0 → return 0`. With this
ordering, Path 5's existing `if (hitPoly0 is not null)` near-miss
branch (`BSPQuery.cs:1869`) finally fires — `NegPolyHitDispatch`
sets `path.NegPolyHit`, the outer `transitional_insert` loop dispatches
via `slide_sphere`, and the sphere slides along walls it's touching
instead of squeezing through. The handoff hypothesized swept-sphere +
closest-considered-polygon tracking; reading retail showed both
`pos_hits_sphere` and `polygon_hits_sphere_slow_but_sure` are STATIC
tests using motion only for the cull — the fix is just the ordering.
3 new RED→GREEN unit tests in `BSPQueryTests.FindCollisions_Path5_*`
cover: overlap + parallel motion (RED→GREEN), overlap + away motion
(RED→GREEN), overlap + into motion (regression guard, already passed).
Zero regressions in full Core suite — with-fix failure set is a strict
subset of baseline (14 vs 17, the 14 are pre-existing static-leak
flakiness + 2 stale-capture document-the-bug tests). Issue #98
`LiveCompare_FirstCap_FixClosesCottageFloorCap` regression test
passes. **Needs visual verification at Holtburg cottage door inside-
out off-center ~50 cm scenario** before A6.P4 is marked complete —
sphere should block at the door surface with no squeeze-through. The
"runs a bit into the door" over-penetration symptom is hypothesized
to close together with the squeeze-through (continuous near-miss
recording while approaching a wall means the sphere slides along it
substep-by-substep rather than catastrophically penetrating then
recovering), but separate investigation if the symptom persists.
Original demo scenario (Holtburg Sewer end-to-end) is unreachable: sewer
doesn't exist on this server, and **issue #95** (portal-graph visibility
blowup) blocks any substitute dungeon. Revised M1.5 demo split into
building/cellar half (PARTIALLY ACHIEVABLE post-slice-1; cellar-ascent
blocked on #98) + dungeon half (blocked on #95). Issues in scope: #80,
#81, #83, #88, #90 (workaround removal after slice 3), **#95**
(visibility; not A6 scope), **#96** (L622 seed; retail divergence
accepted), **#97** (phantom collisions; may close as #98 side-effect),
**#98** (cellar-ascent stuck; A6.P3 slice 3 target), L-indoor,
L-spotlight, indoor sling-out (Finding 3 family with #98), and the
`TryFindIndoorWalkablePlane` definition deletion (A6.P4). **M2
("Kill a drudge") is deferred until M1.5 lands.** Full M1.5 writeup at
the corresponding block in `docs/plans/2026-05-12-milestones.md`.
**A6.P8 — Mesh-AABB-fallback phantom suppression for GfxObj-only stabs — SHIPPED 2026-05-25.**
Three commits: `f6305b1` (PhysicsDataCache.IsPhantomGfxObjSource + 3 unit tests),
`5240d65` (GameWindow.cs wire-in at line 6127), `6ca872f` (test-class doc
line-ref sync from code review). Issue #101 CLOSED — the 10 phantom stair
cyls on the Holtburg upper-floor cottage staircase are gone; collision
falls through to entity `0x40B50089` (GfxObj `0x01000C16`, `hasPhys=True`
BSP with walkable inclined polygon at `Normal.Z=0.717`, world ramp from
(111.10, 25.50, 94.00)→(107.50, 27.10, 97.50)). Visual-verified end-to-end
2026-05-25: holding W continuously climbs Z=94→97.5 over the full 45°
ramp; no phantom diagonal slides (`[cyl-test]` count on `obj=0x40B500*`
post-fix = 0 vs 7101 pre-fix). Spec:
[`docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md`](docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md).
**Issue #100 — Transparent ground around buildings — SHIPPED 2026-05-25 (primary acceptance);
visibility-culling follow-up handed off.** Three commits: `f48c74a` (terrain shader Z nudge,
retail `zFightTerrainAdjust = 0.00999999978` applied per-vertex in `terrain_modern.vert`),
`a64e6f2` (removed ~50 LOC of `hiddenTerrainCells` / `BuildingTerrainCells` plumbing across
LandblockMesh / LoadedLandblock / LandblockLoader / GameWindow / GpuWorldState /
LandblockStreamer + 2 dead tests), `84e3b72` (docs SHA stabilization follow-up).
Visual-verified 2026-05-25 PM at Holtburg: 24m × 24m transparent rectangles around
every cottage are GONE; ground reads as continuous cobblestone / grass. Plan:
[`docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md`](docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md);
predecessor research [`docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md`](docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md).
**Secondary finding from visual verification:** outdoor terrain mesh visible inside
cottage cellars at certain camera angles (clears when camera moves closer; gameplay
unaffected). High-confidence root cause: **indoor-cell visibility culling not gating
outdoor terrain** — same family as filed issue #78 (outdoor stabs visible through inn
floor) and #95 (dungeon portal-graph blowup). Per user direction, NOT filed as a new
issue; treated as additional evidence for #78. Next session investigates + ports
retail's `CEnvCell::find_visible_child_cell` (decomp anchor
`acclient_2013_pseudo_c.txt:311397`) and/or WB's `RenderInsideOut` stencil pipeline.
Full handoff with pickup prompt:
[`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md).
**Today's pre-M1.5 baseline (2026-05-20).** Five surgical fixes
shipped to close the user-reported "logged in inside the inn, ran
through walls" bug: A4 (multi-cell BSP iteration, `691493e`),
#89 (sphere-overlap in CheckBuildingTransit, `7ac8f54`),
#90 (sphere-overlap stickiness in ResolveCellId, `4ca3596` — WORKAROUND,
flagged for removal in A6.P4), #91 (indoor cell shadows in
FindObjCollisions, `c0d8405`), #92 (server cell id at player-mode
entry, `23ab173`). 1147 + 8 baseline maintained throughout. Walls
+ furniture block correctly at Holtburg inn and surrounding cottages
as of visual verification 2026-05-20. M1.5 starts from this baseline.
**M2 ("Kill a drudge") — deferred.** 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 after M1.5: 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). ~610 weeks once M1.5 lands.
**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).
~610 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
@ -1141,38 +801,6 @@ Diagnostic infrastructure: `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
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).
**Indoor walking Phase A4 — Multi-cell BSP iteration shipped 2026-05-20.**
Three commits land the slices (with one revert/reapply during visual
verification proving A4 wasn't the cause of the bug that surfaced):
- `e6369e2``CellTransit.FindCellSet` overload exposes the candidate set
- `493c5e5``Transition.CheckOtherCells` + `ApplyOtherCellResult` combine helper
- `691493e` — wire `CheckOtherCells` into `FindEnvCollisions` (orig `967d065`, revert `3add110`, reapply)
Ports retail's `CTransition::check_other_cells` at
`acclient_2013_pseudo_c.txt:272717-272798`. After the primary cell's BSP
returns OK, every other cell the foot-sphere overlaps is queried. Halt
on first Collided/Adjusted/Slid; Slid clears the contact-plane fields.
10 new unit tests; 1139 + 8 baseline maintained.
**Visual verification surfaced a separate, pre-existing M2 blocker**:
at the Holtburg inn doorway, the CellId ping-pongs between outdoor
`0xA9B40022` and indoor vestibule `0xA9B40164` every few ticks. Indoor
BSP DOES detect walls (Collided/Adjusted/Slid fire on push-back), but
the push-back exits the indoor CellBSP volume → ResolveCellId
reclassifies as outdoor → wall checks bypassed on outdoor ticks → net
appearance "walls walk through." Bug reproduces fully with A4 reverted
(see `launch-revert2.log`), confirming A4 is not the cause. A4 is
correct and tested but **dormant in practice** until the ping-pong is
fixed. Handoff:
[`docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md`](docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md).
**Next: cell-tracking ping-pong fix.** Retail oracle:
`acclient_2013_pseudo_c.txt:308742-308783` (`CObjCell::find_cell_list`
Position-variant). Look for the cell-array hysteresis / stickiness
logic that prevents flipping CellId on a single push-back. Likely
modifies `PhysicsEngine.ResolveCellId` to prefer the previous indoor
classification when the sphere is close to the indoor CellBSP volume.
**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.
@ -1508,24 +1136,6 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
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.
- `ACDREAM_PROBE_PUSH_BACK=1` — A6.P1 cdb probe spike (2026-05-21).
Emits three line types per physics tick: `[push-back]` (per
`BSPQuery.AdjustSphereToPlane` call), `[push-back-disp]` (per
`BSPQuery.FindCollisions` dispatch), `[push-back-cell]` (per
`Transition.CheckOtherCells` off-cell hit). Heavy under motion
(~100500 lines/sec). Pair with retail's cdb breakpoint set at
`tools/cdb/a6-probe.cdb` for the A6.P1 capture protocol.
Runtime-toggleable via the DebugPanel "Diagnostics" section.
- `ACDREAM_CAPTURE_RESOLVE=<path>` — A6.P3 #98 live capture of every
player-side `PhysicsEngine.ResolveWithTransition` call (2026-05-23 PM
apparatus). Each call appends one JSON Lines record with full inputs,
PhysicsBody snapshot before AND after, plus the `ResolveResult`.
Filtered to `IsPlayer` mover flag — NPC / remote DR calls don't
pollute. Pairs with the trajectory replay harness comparison test
(`CellarUpTrajectoryReplayTests.Capture_*`) to diff captured vs harness
state per field — the first divergence pinpoints missing apparatus
state. Capture is OFF when the env var is unset (one null-check
cost per call).
- *(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` +

View file

@ -1,45 +0,0 @@
# Third-Party Notices
This file lists third-party software used by acdream, along with their
license terms and copyright notices.
---
## WorldBuilder
Portions of acdream's rendering and dat-handling code are copied from
WorldBuilder (https://github.com/Chorizite/WorldBuilder), MIT-licensed.
The extracted code lives under:
- `src/AcDream.Core/Rendering/Wb/` — pure helpers (texture decode,
scenery transforms, terrain math).
- `src/AcDream.App/Rendering/Wb/` — GL infrastructure and mesh pipeline.
Original copyright holders: Chorizite contributors (see WorldBuilder's
LICENSE file). Adapted by acdream maintainers to consume our
`DatCollection` directly (replacing WB's `DefaultDatReaderWriter`) and
to remove editor-only code paths.
Original MIT license text:
MIT License
Copyright (c) Chorizite contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -44,186 +44,10 @@ Copy this block when adding a new issue:
---
## #104 — Scene VFX particles not clipped to the PView visible cell set
**Status:** OPEN
**Severity:** LOW
**Filed:** 2026-06-02
**Component:** render, vfx
**Description:** Scene-pass VFX particles (spell effects, smoke) are drawn from their world-space
position only; they are not gated by the PView visible cell set, so a particle emitter in a
sealed (non-visible) cell can bleed past a wall edge. In practice this is mostly masked: scene
particles ARE depth-tested (walls occlude most of their geometry), the dominant indoor entity
bleed is already gated by the Phase W Stage 5 entity gate
(`WbDrawDispatcher.EntityPassesVisibleCellGate`), and Stage 4 already scissors the SKY particle
passes to the doorway. The residual is the occasional additive particle visible past a wall edge.
**Root cause / status:** Particles carry no cell id. `ParticleEmitter` (`Vfx/VfxModel.cs`) has
`AnchorPos` + `AttachedObjectId` but no owning-cell id; `Particle` has a world `Position` only. A
clean fix adds an `OwnerCellId` to `ParticleEmitter` (set at spawn from the owning entity's
`ParentCellId`), threads a `HashSet<uint>? visibleCellIds` into `ParticleRenderer.BuildDrawList`,
and skips emitters whose `OwnerCellId` ∉ the visible set. That touches `IParticleSystem.SpawnEmitter`,
`ParticleSystem`, `ParticleHookSink`, and the `SpawnEmitter` call sites (~68 files) — a plumbing
pass, deliberately deferred out of the Phase W seal (which covers sky/terrain/walls/entities).
**Files:** `src/AcDream.App/Rendering/ParticleRenderer.cs` (BuildDrawList), `src/AcDream.Core/Vfx/`
(ParticleSystem, VfxModel), `src/AcDream.App/Rendering/Vfx/ParticleHookSink.cs`.
**Acceptance:** A scene-particle emitter in a non-visible cell does not draw; outdoor particles
(null `visibleCellIds`) unaffected; no regression on fireplace/spell VFX in the visible cell.
---
## #103 — Phase A8.F portal-frame indoor rendering broken at runtime (visual-gate failure)
**Status:** SUPERSEDED 2026-05-30 by **Phase U (Unified Render Pipeline)**. The
two-pipe (inside/outside) approach this bug lives in is being abandoned wholesale —
the broken `RenderInsideOut` two-pipe path is deleted as Task 1 of Phase U and
replaced by a single unified retail `PView` portal-visibility pipeline. #103 will
not be fixed in place. See
[docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md](research/2026-05-30-unified-render-pipeline-decision-and-handoff.md).
**Severity:** MEDIUM (opt-in branch only — default game unaffected)
**Filed:** 2026-05-29
**Component:** render (indoor visibility)
**Description:** With `ACDREAM_A8_INDOOR_BRANCH=1`, the A8.F retail portal-frame port
renders indoor/outside-in broadly wrong: cottage/cellar interiors covered in outdoor
terrain with transparent walls; invisible walls in other houses from inside and outside.
Default game (env var off) is unaffected — `cameraInsideBuilding = a8IndoorBranchEnabled
&& inside` (GameWindow.cs:7343). The old cellar flap remains in the default path.
**Root cause / status:** Two compounding causes (evidence in the handoff): (1) the
`OutsideView` builder under-produces — `OUTSIDEVIEW polys=0` most frames, and when
non-empty it doesn't recursively narrow (cellar shows ~full window). (2) The Task-6
Job-A/B decoupling draws terrain UNGATED when `OutsideView` is empty (`else` branch),
flooding the cell interior over the (correctly-rendered) walls. Cell walls DO render
(`[opaque]` tris=50-108). Projection math is correct; the builder integration is fragile.
**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (builder under-produces);
`src/AcDream.App/Rendering/GameWindow.cs` `RenderInsideOutAcdream` Step-4 `else` ungated-terrain (~11142).
**Research:** [docs/research/2026-05-29-a8f-visual-gate-failure-handoff.md](research/2026-05-29-a8f-visual-gate-failure-handoff.md) (root-cause analysis, apparatus, first-fix hypothesis, pickup prompt).
**Acceptance:** Holtburg cottage cellar renders with solid walls and no terrain flood;
terrain shows only through correctly-clipped portal openings; no invisible walls.
Related: #102 (builder dungeon-scaling fixpoint).
# Active issues
---
## #102 — A8.F PortalVisibilityBuilder — port retail update_count fixpoint (replace MaxReprocessPerCell cap)
**Status:** PARTIALLY RESOLVED (Phase U.2a, 2026-05-30, commit `d880775`)
**Severity:** MEDIUM → LOW (residual is diamond-topology clip-completeness only)
**Filed:** 2026-05-29
**Component:** rendering, visibility, EnvCell portal traversal
**U.2a resolution (2026-05-30):** Reading the decomp showed retail does NOT
re-enqueue on view-growth: `AddViewToPortals` (433446) enqueues a cell via
`InsCellTodoList` ONLY in the first-discovery branch (`ecx_5 == 0`); later
growth goes through `AddToCell` (433050) in place and never re-enqueues. U.2a
replaced the `MaxReprocessPerCell` cap with an **enqueue-once gate** (a `seen`
set = retail `cell_view_done`, 433784) + a distance-priority work list (retail
`InsCellTodoList`). This **closes I-1 and I-2**: the clip-region union into a
neighbour now runs UNCONDITIONALLY before the enqueue gate, so >4-portal cells
no longer under-count (I-1 gone), and each cell processes its exit portals
exactly once, so cyclic graphs no longer accumulate duplicate polygons (I-2
gone). The new `Build_CyclicHub_TerminatesAndBounds` test enforces the
acceptance (4-room ring ⇒ ≤5 cells, no dups). **Residual scope:** retail's
`AddToCell` ONWARD re-propagation of late growth (a cell reached via a longer
path AFTER it was drawn gets its own `CellView` unioned but does not
re-propagate that growth to ITS children) is NOT ported — this affects only
clip-region completeness on **diamond** topologies, never the visible cell set
or draw order. Track under U.6 (dungeon-scale validation). (The M-4
`OtherPortalClip` stub noted below is now CLOSED by Phase U.2b — a separate
concern from this onward-re-propagation gap.) A naive count-watermark
re-enqueue is NOT a valid fix (it never terminates, because `CellView.Add`
appends without merging) — the faithful fix is the in-place slice
re-propagation.
**Description:** A8.F Task 4 shipped a bounded-BFS port of retail's
`PView::ConstructView``ClipPortals``AddViewToPortals` in
[`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`](../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs).
Code review found NO correctness bugs (the cellar-flap fix works and the
BFS terminates), but two scaling issues that bite only on CYCLIC /
high-fan-in portal graphs (dungeons, network hubs), NOT on the cottage
cellar (a 2-3 cell chain) which is the current M1.5 goal:
- **I-1 — the cap is load-bearing, not a safety net.** `MaxReprocessPerCell = 4`
is the *actual* termination mechanism for cyclic graphs. The
`if (nview.Polygons.Count > before)` re-enqueue-on-growth guard is a
near-no-op because `CellView.Add` (PortalView.cs) appends
unconditionally and never dedupes, so a cell almost always "grows" and
is re-enqueued — convergence relies entirely on the count hitting 4.
A cell reachable through **>4 contributing portals under-counts**
(drops legitimately-visible contributions).
- **I-2 — duplicate polygons accumulate on cyclic/multi-path graphs.**
Measured on a synthetic 4-room ring: 34 `OutsideView` polygons and
216-poly `CellView`s where retail converges to a small fixed set.
Correctness survives (overlapping stencil marks are idempotent) but
it's per-frame cost feeding the stencil pipeline.
**Root cause / status:** We approximate retail's monotone-fixpoint
convergence with a fixed re-process cap. Retail instead converges via an
`update_count` / `set_view(...,i)` slice watermark — each cell records a
timestamp/watermark of how much of its view has been propagated, so a
re-visit only re-propagates the *new* slice and the graph reaches a true
fixpoint with no duplicate accumulation and no arbitrary cap.
Retail anchors (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `AddToCell` 433050 — `esi[0x11]` update-count/slice watermark on the cell
- `InitCell` — per-cell timestamp init
- `AddViewToPortals` 433446 — change-detection that drives the fixpoint
**Related M-4 stub — CLOSED (Phase U.2b, 2026-05-30; reciprocal-resolution
fix 2026-05-30):** the neighbour-side `OtherPortalClip` (decomp:433524) is
ported. After a portal's near-side opening is clipped against the current
cell's view, `PortalVisibilityBuilder.ApplyReciprocalClip` resolves the
neighbour's matching back-portal **by direct index via the dat's
`CellPortal.OtherPortalId` back-link** (retail `arg2->other_portal_id`,
005a54b2), projects it through the neighbour's `WorldTransform`, and
intersects it into the propagated region before the union — so a cell's
clip region is the intersection of the opening seen from BOTH sides. The
reciprocal is `neighbour.PortalPolygons[portal.OtherPortalId]`, NOT a scan
for the first `OtherCellId` match. The direct index is load-bearing: a cell
with TWO portals to the same neighbour (real on the Holtburg cellar —
`0x148` has two portals to `0x149`, polys 40/41, and `0x149` has two
reciprocals back to `0x148`) clips each opening against its OWN reciprocal.
The earlier scan-by-first-match resolved both near-side openings to the
FIRST reciprocal, and disjoint apertures then intersected to empty —
HIDING the geometry through the second opening (under-inclusion). The fix
plumbs `OtherPortalId` through `CellPortalInfo` + `BuildLoadedCell`. Guards
degrade to over-include (never clip against a guessed polygon) when the
index is out of range, the polygon is missing/degenerate, or it projects
behind the camera. Can only TIGHTEN. Covered by
`PortalVisibilityBuilderTests.Build_AppliesReciprocalOtherPortalClip`
(reciprocal tightening) + `…_DegradesGracefully_WhenNoBackPortal`
(over-include degrade) + `…_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal`
(the disjoint two-back-portal regression). (The diamond-topology onward
re-propagation of late growth remains out of scope here — tracked under
U.6.)
**Files:**
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — replace the
`MaxReprocessPerCell` cap + re-enqueue-on-growth guard with a
per-cell slice watermark; honest-limitation comment lives at the
`MaxReprocessPerCell` declaration.
- `src/AcDream.App/Rendering/PortalView.cs``CellView.Add` currently
never dedupes; the fixpoint port either dedupes here or tracks a
propagated-slice index per cell.
**Acceptance:** On a cyclic/hub portal graph (synthetic 4-room ring +
the Town Network dungeon hub), `OutsideView` / `CellView` polygon counts
converge to a small fixed set (no duplicate accumulation), every cell
reachable through any number of contributing portals is included, and
the BFS still terminates. Existing cottage-cellar tests stay green.
**MUST land before A8.F is relied on for dungeons** (dungeons are
currently blocked on #95 regardless).
---
## #87 — Drop WB fork patch by switching to PrepareEnvCellGeomMeshDataAsync
**Status:** OPEN
@ -307,18 +131,11 @@ the indoor-lighting plumbing.
---
## #78 — Outdoor geometry (stabs + terrain mesh) visible inside EnvCells
## #78 — Outdoor stabs/buildings visible through the rendered floor
**Status:** OPEN — **PROMOTED 2026-06-02 to the full render-pipeline redesign** (this IS the
core interior-seal bug; root cause now PROVEN). See
[docs/research/2026-06-02-render-pipeline-redesign-handoff.md](research/2026-06-02-render-pipeline-redesign-handoff.md)
+ [the redesign plan](superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md). Decisive evidence
(2026-06-02 [shell]/[vis] probes): the PVS + cell shells render correctly; the failure is the SEAL +
three inconsistent gates — concretely the `WbDrawDispatcher.cs:1756` `ParentCellId==null → return true`
bypass draws outdoor scenery indoors, and the indoor render draws the outdoor world then gates it
instead of running ONLY `DrawInside` (retail: visibility IS the cull). Fix = redesign Phase R1→R3.
**Severity:** HIGH (immediate visual jank; broadened scope per 2026-05-25 PM finding)
**Filed:** 2026-05-19 (broadened 2026-05-25; promoted to redesign 2026-06-02)
**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
@ -327,88 +144,30 @@ 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.
**Additional evidence (2026-05-25 PM, post-#100 visual verification):**
After issue #100 shipped (commits `f48c74a`, `a64e6f2`, `84e3b72`) and
removed the `hiddenTerrainCells` cell-collapse mechanism, the OUTDOOR
TERRAIN MESH is now (correctly per retail) rendered everywhere on the
landblock — including in 3D regions occupied by indoor EnvCell volumes.
Visual verification at a Holtburg cottage cellar showed a sharp-edged
rectangular grass patch (outdoor terrain at Z≈93.99) rendering over the
cellar stair geometry at certain camera angles. Clears when camera
moves closer (cottage walls + stair treads geometrically occlude the
terrain from new vantage points). Gameplay unaffected. **This is the
same root cause as the existing #78 hypothesis #2** ("outdoor stabs not
culled when player in EnvCell"), just with outdoor terrain mesh
affected in addition to outdoor stab entities. Per user direction,
NOT filed as a new issue — additional evidence reinforces #78's
hypothesis #2, broadens scope of the fix to include terrain culling.
**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. **(High confidence as of 2026-05-25)** Outdoor geometry (stabs AND
terrain mesh) isn't being culled when the player is inside an
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. Retail anchor:
`docs/research/named-retail/acclient_2013_pseudo_c.txt:311397`
(`CEnvCell::find_visible_child_cell` at address `0x0052dc50`,
called from `acclient_2013_pseudo_c.txt:280028`).
that acdream never invokes.
**Files:**
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (per-entity walk —
the dispatcher already filters by `entity.ParentCellId ∈
visibleCellIds` but outdoor stabs have `ParentCellId == null` so they
always pass; needs an explicit indoor-camera gate).
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (currently
renders all loaded landblock terrain unconditionally; needs
visibility gating when camera resolves to an indoor cell).
consider gating outdoor stab entities on visible-cell membership).
- `src/AcDream.App/Rendering/CellVisibility.cs:222+` (`ComputeVisibility`
returns `VisibleCellIds`; existing portal-LOS infrastructure to build on).
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`
(`RenderInsideOut` pipeline — reference implementation, never invoked).
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. Cellar-stairs case (2026-05-25 finding):
standing in a Holtburg cottage cellar at any camera angle, no outdoor
terrain mesh visible over the stair geometry.
**Research:**
[`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](research/2026-05-25-issue-100-shipped-and-culling-handoff.md)
— full session handoff with cellar-stairs evidence, family map (#78 +
#95 + cellar-stairs), root-cause hypothesis, retail anchors, WB
references, do-not-retry list, and pickup prompt for the
investigation session.
**2026-05-31 update (post-U.4c-flap-fix):** the U.4c flap fix (`0ee328a`, root
indoor visibility at the player's cell) made this MORE visible — terrain now
draws inside again (it was Skipped during the flap), so the "floor shows outdoor
ground / cellar floor transparent / see the world from below" symptom is now
prominent. Confirmed at visual gate. Fix direction unchanged: gate outdoor
terrain by indoor-cell visibility (port retail `CEnvCell::find_visible_child_cell`
`acclient_2013_pseudo_c.txt:311397` + `seen_outside` landscape-keep). See
[`docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md`](research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md)
(residual 1).
**2026-05-31 (PM) — promoted to the RENDER ARCHITECTURE RESET target.** A week of
point-fixing produced no shippable indoor render. #78 is now understood as the visible
symptom of an architectural gap, NOT a standalone bug: acdream enforces visibility via
THREE inconsistent gates (terrain `TerrainClipMode` / shell per-cell clip / entity
`ParentCellId` filter with a `ParentCellId==null` outdoor-stab bypass) instead of retail's
ONE PView gate. Direct evidence (`[shell]` probe, `ACDREAM_PROBE_SHELL`) RULED OUT every
other subsystem: the interior cell shells render fine (geometry/texture/opaque/depth
correct); the residual is purely that outdoor geometry isn't gated to portal openings
when indoors. The fix is the unified PView gate (one traversal → one gate for ALL
geometry), which closes #78 + transparent walls + grey enclosure together. **Canonical
(read first):**
[`docs/research/2026-05-31-render-architecture-reset-handoff.md`](research/2026-05-31-render-architecture-reset-handoff.md)
+ the "Render Pipeline" section of `docs/architecture/acdream-architecture.md`.
visible through the portal.
---
@ -444,7 +203,7 @@ matching torch-light pools.
## #80 — Camera on 2nd floor goes very dark
**Status:** OPEN — **M1.5 scope (A7 lighting fidelity)**
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-05-19
**Component:** lighting
@ -473,7 +232,7 @@ ground floor; transition is not abrupt.
## #81 — Static building stabs don't react to atmospheric lighting changes
**Status:** OPEN — **M1.5 scope (A7 lighting fidelity)**
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-05-19
**Component:** lighting, rendering
@ -525,8 +284,8 @@ slopes shows matching shading.
## #83 — Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck)
**Status:** OPEN — **M1.5 scope (A6 physics fidelity, primary umbrella issue)**. Foundation work landed 2026-05-19; root-cause fix scoped to A6.P1-P3 cdb-driven investigation.
**Severity:** HIGH (blocks vertical indoor traversal + degrades single-floor cases). M1.5 acceptance depends on this closing.
**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
@ -713,7 +472,7 @@ propagates through portal connectivity data in `CEnvCell`.
## #88 — Indoor static objects vibrate (bookshelves, open furnaces)
**Status:** OPEN — **M1.5 scope (A6 physics — suspected sub-step state corruption family)**
**Status:** OPEN
**Severity:** MEDIUM (visual jitter; doesn't block gameplay)
**Filed:** 2026-05-19
**Component:** rendering, animation
@ -750,394 +509,6 @@ propagates through portal connectivity data in `CEnvCell`.
---
## #90 — Cell-id ping-pong at indoor doorway threshold
**Status:** OPEN — **WORKAROUND in place (sphere-overlap stickiness, commit `4ca3596`). M1.5 scope (A6.P4) — workaround removal after underlying push-back fix.** User-visible symptom resolved 2026-05-20; root cause still to investigate.
**Severity:** HIGH (workaround unblocks indoor visibility for M1.5 baseline; M1.5 acceptance requires the proper fix)
**Filed:** 2026-05-20
**Component:** physics — cell tracking
**Description:** Walking into the Holtburg inn through its doorway causes the player's CellId to ping-pong between outdoor cell `0xA9B40022` and indoor vestibule cell `0xA9B40164` every few ticks. Indoor BSP DOES detect walls (Collided/Adjusted/Slid all fire on push-back), but the push-back exits the indoor CellBSP's bounding volume → `PhysicsEngine.ResolveCellId` reclassifies the player as outdoor → next tick bypasses indoor BSP entirely → player advances freely → re-enters → repeats. Net aggregate behaviour: walls APPEAR to walk through even though indoor wall hits ARE firing on the indoor frames.
**Root cause / status:** Cell-id stickiness missing. When the indoor BSP pushes the foot-sphere back during wall collision, the resulting world position lies just outside the indoor cell's CellBSP volume (the BSP's volume is tightly bounded to the room's interior). The cell resolver then re-evaluates and prefers the outdoor cell. Retail likely has hysteresis or a "keep previous cell unless clearly outside" rule.
**Files:**
- `src/AcDream.Core/Physics/PhysicsEngine.cs:259-329``ResolveCellId` outdoor-then-indoor branch logic
- `src/AcDream.Core/Physics/CellTransit.cs:235-325``FindCellList` / `BuildCellSetAndPickContaining` containment test
- `src/AcDream.Core/Physics/BSPQuery.cs:950-963``PointInsideCellBsp` (radius-less)
- `src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit` (line ~162) — outdoor→indoor entry test
**Research:** [`docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md`](research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md) — full ping-pong analysis with launch-revert2.log evidence (61 indoor-bsp queries firing, 11 inside=True building-transit events, 18 cell-id flips between `0xA9B40022``0xA9B40164`).
Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783` (`CObjCell::find_cell_list` Position-variant). Not yet decompiled in detail. Bug-A cousin (see [`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`](research/2026-05-20-indoor-walking-bug-a-handoff.md)) — different symptom (free-fall vs walk-through), same family (doorway-edge geometry mismatch).
**Acceptance:** Walking into the Holtburg inn, the player's CellId promotes to `0xA9B40164` and STAYS there while the user is spatially inside the inn (not flipping back to outdoor on each wall push-back). Walls visibly block. Indoor BSP results dominate the per-tick collision evaluation while user is inside the inn. A4's `[other-cells]` probe starts firing for indoor cells adjacent to the primary.
---
## #93 — Indoor lighting broken (M1.5 lighting umbrella)
**Status:** OPEN — **M1.5 scope (A7 lighting fidelity, primary lighting issue)**
**Severity:** HIGH (degrades indoor experience; M1.5 acceptance depends on it closing)
**Filed:** 2026-05-20
**Component:** lighting, rendering
**Description:** Interior cells (inn, cottages, dungeons — anywhere with `cellLow >= 0x0100`) render with lighting that doesn't match retail. Specific symptoms include #80 (2nd floor goes dark), wrong per-cell ambient, missing cell-internal light sources (torches/lanterns), and outdoor day-cycle bleeding into indoor cells. Umbrella issue covering the family; sub-issues to be filed during A7.L1 probe spike.
**Root cause / status:** Suspected family of bugs in (a) per-cell environment-light tag parsing from the dat (we may not parse `cell.envLightInfo` correctly), (b) cell-light association (which lights belong to which cell), (c) indoor visibility culling for lights, (d) the indoor branch of `GameWindow.UpdateSunFromSky` which uses a flat ambient. Investigation deferred to A7.L1.
**Files:**
- `src/AcDream.App/Rendering/GameWindow.cs:8330+` (`UpdateSunFromSky`, indoor branch with flat ambient)
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (per-pixel light evaluation)
- `references/WorldBuilder/...` (any WB lighting helpers we inherit)
- Retail oracle: grep `Render::lighting_*` in `acclient_2013_pseudo_c.txt`
**Acceptance:** Holtburg inn interior lighting matches retail at the same character position. Holtburg Sewer dungeon torchlight reads correctly per-room. 2nd-floor cells brightness matches ground floor.
---
## #94 — Held items project spotlight on walls
**Status:** OPEN — **M1.5 scope (A7 lighting fidelity)**
**Severity:** MEDIUM (visual fidelity; doesn't block gameplay)
**Filed:** 2026-05-20
**Component:** lighting, rendering
**Description:** Items the player is holding (torches, light-source items) project a spotlight effect onto nearby walls. The spotlight direction is wrong — should be omnidirectional from the item, but appears to project specifically toward wall surfaces.
**Root cause / status:** Per-entity light direction transform. `LightingHookSink` owner-tracking applies an entity-rotation transform that's probably wrong for held-light items — likely passing the entity's facing-direction as the spotlight cone direction when retail's behavior is omnidirectional point-light.
**Files:**
- `src/AcDream.App/Rendering/Vfx/LightingHookSink.cs` (suspected — verify during A7.L1)
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (point-light eval branch)
**Acceptance:** Held-item lighting illuminates nearby surfaces uniformly without directional cone artifacts. Matches retail's behavior at the same item in same scene.
---
## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
**Status:** OPEN — **explains user-observed "dungeons are broken"**
**Severity:** HIGH (blocks all dungeon navigation visually)
**Filed:** 2026-05-21
**Component:** rendering, visibility, EnvCell portal traversal
**Description:** When +Acdream enters a dungeon via portal (verified at Town Network hub in A6.P1 scen5), the `visibleCells` count per cell explodes from a normal ~4-7 to **135-145**, and cells from **multiple disconnected landblocks** are loaded simultaneously. Observed result: the player can see through walls, sees geometry from other dungeons rendering inside the current dungeon, and rendering is generally garbled. This single bug is responsible for "dungeons are broken" as a whole — every portal-accessed dungeon hits this on entry.
**Root cause / status:** Suspected: portal-graph traversal in the EnvCell visibility computation walks outbound portals recursively without proper termination, so a network hub (which has many outbound portals to different dungeons) marks 100+ cells from disconnected dungeons as visible. The visibility computation likely needs to (a) cap traversal depth, (b) terminate at portal boundaries to OTHER landblocks, or (c) only include cells that share line-of-sight through a chain of portals from the camera's current cell.
**Evidence (committed):**
- `docs/research/2026-05-21-a6-captures/scen5_sewer_entry/acdream.log` — full trace of the rendering breakdown after portal teleport.
- Pre-teleport: `visibleCells=4` per cell (normal outdoor).
- Post-teleport: `visibleCells=135-145` per cell at landblock 0x0007 + spurious cells from 0x020A and 0x0408 (different worldOrigins, i.e. different dungeons entirely).
- Cell-transit chain: `0xA9B40003 -> 0x00070143 reason=teleport` is the portal entry; everything after the teleport is corrupted.
**Files:**
- `src/AcDream.App/Streaming/` — cell streaming + visibility logic (suspect: cell-cache visibility computation)
- WB-extracted visibility: `src/AcDream.App/Rendering/Wb/` (whichever file owns `visibleCells`)
- Check `EnvCellRenderManager` + `VisibilityManager` in `references/WorldBuilder/` for the WB-original algorithm and where our extraction may have diverged
**Research:** scen5 acdream.log is the primary evidence. Compare against WorldBuilder's original portal-traversal termination logic.
**Acceptance:** After portal entry to any dungeon, `visibleCells` per cell stays in the normal ~4-15 range, cells from non-adjacent landblocks do NOT appear in the cell-cache, and visually no other-dungeon geometry renders through walls.
---
## #96 — Per-tick PhysicsEngine.ResolveWithTransition CP seed (retail divergence)
**Status:** PARTIALLY ADDRESSED — accepted as documented retail divergence
**Severity:** LOW (cosmetic — CP-write counter inflates but behavior is correct)
**Filed:** 2026-05-21
**Component:** physics, ContactPlane retention
**Description:** After A6.P3 slice 1 (commits `5aba071` + `5f7722a` + `39fc037`) stripped the `TryFindIndoorWalkablePlane` synthesis path from `Transition.FindEnvCollisions` indoor branch, scen3 post-fix re-capture showed acdream still writes ContactPlane fields 25,082 times during a flat-floor walk — 24,906 of those (99.3%) come from `PhysicsEngine.ResolveWithTransition` line 622, which seeds `ci.ContactPlane` from `body.ContactPlane` at every transition start when the body is grounded. Retail's equivalent code path fires `set_contact_plane` zero times during the same flat-floor walk (scen3 retail BP7 = 0).
**Slice 2 attempt + outcome (2026-05-22, commits `892019b` + `f8d669b`):**
- **v1 attempt (`892019b`):** Removed the L622 seed entirely to match retail's `CTransition::init` clear-at-start behavior. Verified per-rebuild that the change deployed. CP-write count dropped 91% (30,420 → 2,690). **But broke BSP step_up at the last step of stairs** — sub-step 1's `AdjustOffset` had no ContactPlane to compute the lift direction, BSP step_up thrashed (12,489 push-back-disp + 2,226 push-back-cell signal). User confirmed: "I can't pass the last step of the stairs."
- **v2 fix (`f8d669b`):** Reverted the seed removal + added no-op-if-unchanged guard inside `CollisionInfo.SetContactPlane`. The guard early-returns when called with values identical to current state. **The guard doesn't trigger for the L622 seed** because each tick gets a fresh `Transition` (so `ci.ContactPlaneValid=false` on entry → guard fails → write fires). So slice 2 v2 didn't actually reduce CP-write count for the seed case. It does dedupe within-tick redundant writes (e.g. Mechanism B restoring LKCP that equals current ci.CP), which is a small benign improvement.
**Root cause / status (updated 2026-05-22):** The L622 seed IS load-bearing for `AdjustOffset` slope projection on sub-step 1, which BSP step_up depends on. Retail uses a different architecture (no seed; first sub-step has no CP and BSP path-6 establishes it). Matching retail would require a deeper refactor — making `AdjustOffset` fall back to `body.ContactPlane` when `ci.ContactPlane` is invalid, OR re-architecting the sub-step loop to not require CP for the first iteration. Both are non-trivial.
**Accepting the divergence:** the per-tick seed call is functionally correct — it propagates the player's current contact plane to the transition. The cost is a noisy CP-write counter (cosmetic) but the BEHAVIOR matches retail (player stays grounded on the correct plane, slope-snap works, step_up works). Closing #96 fully is deferred to a future refactor or accepted as is.
**Lessons learned:**
- A counter-based metric (CP-write count) is not always a direct proxy for "behavior matches retail." Retail's set_contact_plane firing rate differs from ours because the call-site structure differs, not because the behavior differs.
- The slice 1 hypothesis "Finding 1 (dispatcher entry frequency) may close as side-effect of Finding 2 (CP-write)" was confirmed by stairs+cellar working post-slice-1. But the slice 2 follow-up assumption "remaining 99.3% of CP writes are also a problem" was partially wrong — those writes are correct state propagation.
**Files:**
- `src/AcDream.Core/Physics/PhysicsEngine.cs:620-626` (the seed call site, retained with updated comment)
- `src/AcDream.Core/Physics/TransitionTypes.cs:259-279` (`CollisionInfo.SetContactPlane` no-op guard, retained as small improvement)
**If revisited:** investigate `AdjustOffset` fallback to `body.ContactPlane` when `ci.ContactPlane` is invalid — that would let us safely remove the seed. Or investigate retail's exact first-sub-step behavior to see if there's a different missing piece in our BSP step_up that would let it work without a seeded CP.
---
## #97 — Phantom collisions + occasional fall-through on indoor 2nd floor (post-slice-1 happy-testing)
**Status:** OPEN — **investigate after issue #96 lands** (hypothesized to be a side-effect)
**Severity:** MEDIUM (intermittent; doesn't block stair-walking which works post-slice-1)
**Filed:** 2026-05-21
**Component:** physics, ContactPlane stability
**Description:** During user happy-testing post-A6.P3 slice 1 (2026-05-21), walking on the inn 2nd floor in acdream produced:
- Intermittent "phantom collisions" — hitting invisible barriers in open floor space.
- One observed "fall-through the floor" — character dropped through the 2nd floor at a specific spot.
These are NOT the indoor stair-climb or cellar-descent symptoms (those WORK post-slice-1). They appear during normal flat-floor walking.
**Root cause / status:** Hypothesis: caused by issue #96 (L622 per-tick CP seed). The seed writes `ci.ContactPlane` every tick from `body.ContactPlane`, which may carry stale values across cell transitions or after the BSP didn't land a fresh plane. If a transient `ci.ContactPlane` value points to a plane that doesn't match the actual current floor geometry, `ValidateWalkable` (called from the outdoor terrain fallback) or downstream physics may briefly believe the player is below the floor → fall-through; OR may believe a wall is present where there isn't one → phantom collision.
Falsifiable: if #96 fix closes #97 as a side-effect, the hypothesis is confirmed. If #97 persists post-#96, deeper investigation needed (possibly cell-resolver stickiness — Finding 3 family).
**Reproduction (informal — needs sharpening):**
- Launch acdream, teleport to inn 2nd floor.
- Walk back and forth across the floor for ~30 seconds in various patterns.
- Phantom collisions appear intermittently — exact reproduction location unknown.
- Fall-through happened at one specific spot; location not recorded.
**Files:**
- `src/AcDream.Core/Physics/PhysicsEngine.cs` (CP seed + body persist)
- `src/AcDream.Core/Physics/TransitionTypes.cs` (`Transition.FindEnvCollisions` indoor branch + `Transition.ValidateTransition`)
- `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 land write site)
**Acceptance:** Walking on inn 2nd floor for ≥60 seconds in varied patterns produces zero phantom collisions and zero fall-through events.
---
## #98 — [DONE 2026-05-24 · `b3ce505`] Cellar ascent stuck at top (NOT BSP step; per-cell-list architectural divergence)
**Closed:** 2026-05-24
**Commit:** `b3ce505 fix(phys): A6.P3 #98 — gate outdoor shadow radial sweep on indoor primary cell`
**Resolution:** The proximate fix is the indoor-primary radial-sweep
gate in `ShadowObjectRegistry.GetNearbyObjects`. Architectural root
cause: our landblock-wide spatial shadow registry diverges from
retail's per-cell `shadow_object_list` with portal-aware registration —
the cottage GfxObj (registered landblock-wide via cellScope=0) was
returned to sphere queries inside the cellar EnvCell, and its
downward-facing floor poly at world Z=94 head-bumped the climbing
sphere from below.
After ~10 failed speculative fix attempts across four sessions, the
fix landed cleanly once the apparatus converged. The "v3 stale ramp
contact plane" hypothesis was falsified by chronological replay against
`a6-issue98-resolve-capture-2.jsonl` — the player IS on the ramp at the
cap event; the contact plane is correctly the ramp's plane; the head
sphere bumps the cottage GfxObj's floor poly from below (the
evening-v2 finding was correct all along).
Decomp anchors (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- 308742+ : `CObjCell::find_cell_list` — indoor/outdoor branch
- 308751-308769 : the branch — indoor adds 1 cell; outdoor calls `add_all_outside_cells`
- 308773-308825 : portal-visible neighbor recursion
- 308916 : `CObjCell::find_obj_collisions(this, ...)` — strict per-cell iteration
**Visual verification 2026-05-24:** user confirmed "Finally I can go up!"
**Knowledge artifacts:**
- Findings doc resolution section: [`docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md`](research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md) (bottom)
- Memory: `feedback_retail_per_cell_shadow_list.md`, `feedback_apparatus_for_physics_bugs.md`
- A6.P4 phase planned to do the full retail-faithful per-cell port and obviate the b3ce505 stopgap
**Known regression introduced:** doors at doorway thresholds — see #99 below.
---
## #99 — Run-through doors at building thresholds (regression from b3ce505)
**Status:** OPEN
**Severity:** HIGH (M1 demo regression — opening doors was previously a working demo target)
**Filed:** 2026-05-24
**Component:** physics, shadow-object collision query
**Description:** With the issue #98 fix (commit `b3ce505`), the
indoor-primary radial-sweep gate causes our engine to miss outdoor-
registered door entities when a sphere has crossed the threshold and
the primary cell resolves to the indoor side. Players can walk through
doors that previously blocked them.
User report 2026-05-24: "I can also run through doors."
**Root cause / status:** This is the doorway edge case explicitly
flagged in the b3ce505 commit message. Doors are server-spawned
entities with their own cylinder collision, registered via
`UpdatePosition` to whichever cell their position resolves to. Doors
at building thresholds typically resolve to **outdoor** cells. The
b3ce505 gate skips the outdoor radial sweep when the sphere's primary
cell is indoor → outdoor-registered doors are not returned → no
collision → walk-through.
Retail handles this case via the portal-visible recursion in
`find_cell_list` (lines 308773-308825 of the named-retail decomp): at
registration time, an object is added to its position's cell PLUS all
portal-visible neighbor cells. So a door at a doorway portal ends up in
both the outdoor cell's shadow list AND the indoor cell's list — a
sphere on either side sees it.
**Fix path:** Closes naturally as part of A6.P4 (per-cell shadow
architecture refactor — see design spec at
`docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md`).
A6.P4 ports retail's `find_cell_list` indoor branch + portal recursion
into `ShadowObjectRegistry.Register`, eliminates the cellScope=0
landblock-wide approximation, and removes the b3ce505 stopgap.
If A6.P4 takes longer than expected, an intermediate "portal-aware
indoor query" patch (~20 lines: walk indoor cells' `VisibleCellIds`,
collect portal-reachable outdoor cells, include in `GetNearbyObjects`
indoor branch) would close #99 without touching registration. Tagged
as fallback option B in the A6.P4 spec.
**Files:**
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs``GetNearbyObjects` indoor branch
- `src/AcDream.Core/Physics/TransitionTypes.cs:2180+``FindObjCollisions` caller
**Acceptance:** Doors at Holtburg cottage/inn doorways block the player
from both sides (outside walking in, inside walking out). Issue #98's
cellar-up fix remains intact.
**Related:** #98 (sibling — same architectural cause), #97 (phantom
collisions on 2nd floor — also likely closed by A6.P4), Finding 3
family (sling-out — also likely).
---
---
## #98-old-context-preserved-for-reference
(retained from the OPEN form for historical context — superseded by the
DONE resolution above. Skip to next active issue if you've read enough.)
**Status:** OPEN — **NEW diagnosis after A6.P3 slice 3 (2026-05-22)**
**Severity:** HIGH (blocks M1.5 demo cellar half — user can descend but cannot return)
**Filed:** 2026-05-22
**Component:** physics, BSP step_up / step_down at cellar stair geometry
**Diagnosis update 2026-05-22 (post A6.P3 slice 3):** The cell-resolver ping-pong (the original hypothesis when this issue was filed) WAS confirmed and is now FIXED by slice 3 (commits `8898166` v1 + `3e140cf` v2 — point-in stickiness check in `ResolveCellId`). Data confirms: scen4_cottage_cellar_slice3v2 capture shows only 1 cell-transit event (login teleport) vs 20+ pre-fix.
BUT the cellar-up symptom PERSISTS even with the cell-resolver fix. The remaining cause is a BSP step physics issue at the cellar stair geometry. User report: "I'm running up the stairs, at the top it looks like I'm running into something. Still running animation but not going up." Player can climb most of the stair flight but gets blocked at the TOP step where the cellar transitions to the cottage main floor.
**Evidence from slice3v2 capture:**
```
[push-back] site=adjust_sphere in=(*, -0.0752, 0.0077) out=(*, -0.0752, 0.7577)
delta=(0, 0, 0.7500) n=(0, -0.7190, 0.6950) d=-0.1007
r=0.4800 winterp=1.0000->0.0000 applied=True
```
- Surface normal `(0, -0.719, 0.695)` — sloped 44° (walkable per FloorZ=0.664)
- Push-back lifts sphere by 0.75m (step_down probe distance) repeatedly
- `winterp 1.0→0.0` — entire walk interpolation consumed by the lift each tick
- Player Z stays stuck around 0.0077 (relative to cell) → not progressing
**Hypothesis:** the step_down probe at the top of the cellar stair is hitting the sloped TOP step face (or possibly a wall poly), and consuming all walk interp pushing back. No remaining interp to actually walk forward over the top.
**Diagnosis sharpened 2026-05-22 (commit `134c9b8`)** — paired retail+acdream cdb capture confirmed cellar ascent ends with retail's BP7 setting ContactPlane to the cottage main floor (flat plane at world Z=94, 18 BP7 hits all the same plane).
**Diagnosis CORRECTED 2026-05-22 evening (slice 5 `[place-fail]` probe)** — the morning handoff's "Path 5 vs Path 6 in `BSPQuery.FindCollisions`" diagnosis is **WRONG**. The slice-5 probe-driven evidence shows:
- Retail's BP4 trace has every find_collisions hit with `collide=0`. Retail enters the same `(state & 1) Contact` branch our acdream does. There is NO outer-dispatcher path-selection divergence.
- Retail's BP5 fires on the ramp poly 17+ times during the ascent, NOT "30 hits all on flat planes" as the morning claim said. We misread the retail data.
- The actual blocker is polygon **0x0020** in the cellar cell's BSP (`n=(0,0,-1) d=-0.2` in cell-local, world Z=93.82 — the cellar's ceiling). When step-up's step-down probe lifts the sphere onto a 45° walkable surface, the sphere top extends past the ceiling polygon and `SphereIntersectsSolidInternal` correctly rejects.
- Retail succeeds because its `check_cell` transitions to cottage main floor cell 0xA9B40146 during the ascent, where the cellar's ceiling polygon is absent. Our `check_cell` stays at cellar 0xA9B40147.
Full slice 5 evidence + sharpened next-step pickup at [`docs/research/2026-05-22-a6-p3-slice5-handoff.md`](docs/research/2026-05-22-a6-p3-slice5-handoff.md). Capture data at `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_place_fail/`.
**Diagnosis FINALIZED 2026-05-23 evening** (commit `28c282a`, divergence doc at [`docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md`](docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md)). After 4 sessions of speculative fixes (10+ variants, none worked), apparatus shipped to turn evidence-driven analysis into a 200ms test loop:
- Deterministic replay harness: [`tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs`](tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs) loads the three cottage/cellar cell fixtures (captured live via the new `ACDREAM_DUMP_CELLS` probe) and drives the failing-frame sphere through our walkable predicates. 7 tests, all pass, all reproduce the live failure without a client launch.
- Retail comparison: [`docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.decoded.log`](docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.decoded.log) — 35K cdb BP hits during the equivalent retail cellar-up.
**REAL divergence**: NOT cell-resolver. NOT path-selection. NOT polygon 0x0020 the cellar ceiling.
- Retail's sphere is at world Z ≈ **94.48** (resting on cottage floor) when `find_walkable` accepts the cottage main floor plane.
- Our failing-frame sphere is at world Z ≈ **92.01** (2.47m lower) when our walkable query rejects the cottage main floor.
- Retail's `ContactPlane` writes during cellar-up are ONLY flat horizontal planes (cellar floor Z=90.95 OR cottage floor Z=94.00). Never the ramp.
- Retail's `find_crossed_edge` fires ONCE in 35K BPs. Acdream uses it heavily.
**Fix targets** (priority order, from the comparison doc):
1. (HIGHEST) Step-up + ramp climb doesn't gain enough Z per tick. Retail climbs gradually across thousands of ticks; ours oscillates at Z≈92. Look at `Transition.AdjustOffset` slope projection + `Transition.DoStepUp` WalkInterp handling.
2. Cottage-cell candidacy uses wrong sphere reference (pre-step-up vs step-lifted center).
3. `find_crossed_edge` over-use in our walkable acceptance path.
4. (LOW) Ramp polygon normal divergence.
**Failed fix attempts (informational):**
- WalkInterp reset before placement_insert (commit `bbd1df4`) — logical retail-faithful improvement but doesn't fix the cellar-up symptom. Keep.
- Slice 3 v1/v2/v3 cell-resolver stickiness — closed ping-pong but didn't help cellar-up. v3 reverted (`8bd3117`).
- Slice 5: `[place-fail]` probe + diagnosis correction. Useful infrastructure; not a fix.
- Slice 6 (2026-05-22 PM): 6 placement-insert bypass variants. None unstuck the player.
- Slice 7 (2026-05-23 AM): terrain hole cutout, multi-sphere CellTransit, building bldg-check, negative-side polygon support, render-vs-physics origin split. Triaged in commit `35b37df`: kept render-physics split + multi-sphere CellTransit + diagnostic probes; reverted neg-poly + bldg-check (didn't fix #98).
**Related:**
- Inn stairs UP works (different geometry, doesn't trigger this specific failure mode)
- Cellar descent works (only ascent fails — direction matters)
- Issue #90 (cell-id ping-pong workaround in `ResolveCellId`) is now superseded by slice 3 v2's stickiness check; can be removed in A6.P4 after broader visual verification
**Description:** Walking UP from a Holtburg cottage cellar in acdream gets stuck "just almost at the last step up." Stairs going UP elsewhere (inn 2nd floor) work fine post-A6.P3 slice 1. Cellar DESCENT works. Only the cellar ASCENT from the bottom back to ground level fails — specifically at the last step where the player should transition from the indoor cellar cell to the cottage ground-floor cell.
**Evidence:** captured in slice 2 v2 verification at `docs/research/2026-05-21-a6-captures/scen3_inn_2nd_floor_slice2v2/acdream.log`. Cell-transit chain shows the resolver ping-ponging between three adjacent cells:
```
0xA9B4014B → 0xA9B4014A → 0xA9B4013F → 0xA9B4014A → 0xA9B4014B → ...
(Z stays ~96.4 throughout the ping-pong — vertical position stable but cell classification oscillating)
```
Eventually the player gives up and returns down: `0xA9B4013F → 0xA9B40143 (Z drops to 94.020) → 0xA9B40146 (Z 93.426) → ...`
Each cell-transit event has `reason=resolver`, meaning `PhysicsEngine.ResolveCellId` is making the decision. The resolver classifies the position into a different cell each tick → `AdjustOffset` operates against a different cell's geometry each tick → can't accumulate forward motion → stuck.
**Root cause / status:** Same family as scen4 sling-out (A6.P2 Finding 3) and issue #90 cell-id ping-pong (which has a workaround). The retail oracle is `CObjCell::find_cell_list` Position-variant at `acclient_2013_pseudo_c.txt:308742-308783`. Retail uses cell-array hysteresis / stickiness to prevent flipping CellId on adjacent-cell boundaries when the sphere is on the boundary.
Our `ResolveCellId` + `CheckBuildingTransit` lack this stickiness — every tick they re-classify based on current position, ignoring "we were already in cell X last tick; if the new position is still close to X, stay in X."
**Fix sketch (slice 3):**
1. Port retail's cell-array hysteresis from `CObjCell::find_cell_list`.
2. Modify `ResolveCellId` to prefer the previous tick's CellId when the sphere is close to (but slightly outside) the previous cell's CellBSP volume.
3. Modify `CheckBuildingTransit` similarly for building-shell transitions.
4. May obsolete issue #90's workaround (the same stickiness mechanism would handle the doorway ping-pong too).
**Related issues:**
- Issue #90 — Cell-id ping-pong at indoor doorway threshold (existing workaround; should be removed if Finding 3 fix lands cleanly)
- Issue #97 — Phantom collisions + fall-through on 2nd floor (may also be the same cell-resolver instability)
- A6.P2 Finding 3 — Indoor cell-resolver sling-out (scen4)
**Files:**
- `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolveCellId`)
- `src/AcDream.Core/Physics/CellPhysics.cs` (`CheckBuildingTransit`)
- `src/AcDream.Core/Physics/CellTransit.cs` (cell list iteration; may need stickiness here)
**Acceptance:** User can walk up out of a Holtburg cottage cellar without getting stuck at the last step. Cell-transit log shows no ping-pong on the cellar boundary. Issue #90 workaround can be removed (verified by ping-pong staying absent at the inn doorway too).
**2026-05-23 evening session update — Shape 1 attempted + reverted:**
- New apparatus committed:
- `8a232a3``[step-walk-adjust]` probe inside `Transition.AdjustOffset` (PhysicsDiagnostics.LogStepWalkAdjust + four branch tokens). Reveals which projection branch fires per call.
- `8daf7e7` — captured findings note at [`docs/research/2026-05-23-a6-stepwalkadjust-findings.md`](docs/research/2026-05-23-a6-stepwalkadjust-findings.md) + log snapshot at `docs/research/2026-05-23-a6-captures/stepwalkadjust/acdream.log`.
- **Refined diagnosis (corrects the 2026-05-23 evening "fix targets" priority above):** AdjustOffset is CORRECT — 145/146 calls take the `into-plane` branch with consistent +0.045 m mean zGain per call when offset points into the ramp normal. Sphere world Z climbs monotonically 90.95 → 92.80 across the ramp. **The climb caps at world Z ≈ 92.80** (cottage floor at 94.00 still 1.20 m above) because at the ramp top, the proposed check (Z=92.85) gets rejected by step-up's downward step-down probe — no walkable surface exists below the proposed position within stepDownHeight=0.6 m (cottage floor is ABOVE, not below). 101 `stepdown-reject` hits in the capture vs 1 acceptance.
- **Shape 1 fix attempted (`0cb4c59`, reverted in `402ec10`):** Added `PhysicsGlobals.ContactPlaneFlatThreshold = 0.99f` and gated `BSPQuery.AdjustSphereToPlane`'s two `SetContactPlane` call sites by `worldNormal.Z >= threshold`. The intent: match retail's cdb-observed pattern where CP is ONLY ever set on flat polygons (cellar floor or cottage floor — Normal.Z = 1.0 in all 161 BPE writes). Live test confirmed the fix breaks OnWalkable tracking: 18,916 / 25,671 step-walk lines (74%) ended in `contact=False onWalkable=False cp=n/a walkPoly=False` (the falling state). User report: "can't get up the first step. Jumped, stuck in falling animation." The gate was too aggressive — sloped walkable polygons (stair tops, ramp faces) NEED ContactPlane set for the sphere to register as on a surface.
- **What we learned about Shape 1:** simply skipping `set_contact_plane` on sloped polygons doesn't match retail behavior. Either retail synthesizes a flat CP from a sloped contact (the `step_sphere_down:321203` `Plane::Plane(&plane, esi, &point)` codepath — `esi` may be a synthesized direction, not the polygon's normal), OR retail's gate is upstream of `set_contact_plane` (the polygon never reaches CP-setting in the first place), OR our `OnWalkable` tracking is over-coupled to `ContactPlaneValid` in a way retail's isn't. The named-decomp research did not converge on a definitive answer.
**Session paused 2026-05-23 evening after two days of work.** Apparatus + probe + findings + plan + first failed fix + revert all committed. M1.5 demo's cellar half remains blocked. The honest next-session moves, in order:
1. **Build a deterministic trajectory replay harness** (drives the physics engine through N ticks with mocked input + snapshotted starting state, runs in <500ms). The Issue98 replay tests are half of this they have the cell fixtures. The missing half is the per-tick driver. With a 200ms inner loop instead of 5-minute live-test iteration, evidence-driven fix attempts become tractable.
2. **OR pivot to another M1.5 issue** with less cross-subsystem coupling. The cellar-up bug lives at the seam of AdjustOffset + ContactPlane + WalkInterp + step-up + walkable tracking + OnWalkable + cell-set membership — fixing one piece breaks another. Less-coupled issues (chronic open #2/#4/#28/#29/#37/#41, or #90 workaround removal) would yield faster forward progress.
3. **OR a deeper named-decomp research pass** focused specifically on `CEnvCell::find_env_collisions``BSPTREE::find_collisions` → indoor CP-setting chain. This path was never fully traced; the first two research passes worked on the outdoor (`CLandCell`) path. The indoor path is where the cellar lives.
**Replay tests at [`tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs`](tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs)** document the failing-frame geometry and will be the regression oracle when a real fix lands. They do not currently simulate trajectory.
**2026-05-23 PM extension — trajectory replay harness shipped, blocked on a SECOND bug:**
Commits `4c9290c``5c6bdbe` ship a deterministic N-tick trajectory replay at [`tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`](tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs). 200-tick runs complete in <100 ms. 5 tests pass.
- **Finding:** the cellar ramp polygon is NOT in `cellStruct.PhysicsPolygons`. It lives in a separate GfxObj (a static building piece, registered as a ShadowEntry on the landblock). `CellDumpSerializer` correctly captures cell polygons; the ramp comes from a different data source entirely. The harness reconstructs the ramp polygon programmatically from the live capture's polydump data via `RegisterStairRampGfxObj`.
- **Finding:** `CellDumpSerializer.Hydrate` sets `BSP=null` per its xmldoc — so the indoor BSP collision path is skipped for hydrated fixtures. Harness wraps cells with a synthetic one-leaf BSP via `AttachSyntheticBsp` to fire the indoor path.
- **Finding:** `PhysicsBody` seeding requires BOTH `ContactPlane*` AND `WalkablePolygon*` fields. The engine at `PhysicsEngine.cs:665-673` only calls `SpherePath.SetWalkable(...)` if `body.WalkablePolygonValid && body.WalkableVertices.Length >= 3`. Without this the engine treats the sphere as "grounded but anchorless" — a contradictory state.
**NEW BLOCKER (open finding):** Even with the full apparatus (CP + WalkablePolygon seeded body, synthetic BSP, synthetic stair GfxObj registered, stub landblock), the sphere goes airborne at tick 1 with `hit=(0,1,0)` — a +Y wall normal matching no registered geometry. The hit is set by `ValidateTransition` between the `after-insert` and `after-validate` probe sites, but the inner `TransitionalInsert` call sets `ci.CollisionNormal=(0,1,0)` before ValidateTransition runs. 12 different `SetCollisionNormal` call sites in `TransitionTypes.cs` — root cause not yet isolated.
6 hypotheses tested via the harness, all failed to isolate root cause: WalkablePolygon seeding, initial Z lift (0 vs 0.05m), stair GfxObj presence, stub landblock terrain, cell BSP null vs synthetic, body=null vs seeded. Per systematic-debugging skill's "3+ failures = question architecture" rule, stop speculation; next session needs a side-by-side comparison harness against live `PlayerMovementController` state.
**Pickup document:** [`docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md`](docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md) is the canonical resume artifact — has the chronological commit list, apparatus inventory, exclusion list, and three concrete next-session options ranked by recommendation.
---
**Status:** DONE
@ -1399,7 +770,7 @@ or +small fix if different. Not blocking M1.
## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
**Status:** OPEN
**Severity:** MEDIUM (Stage A now causes real play mis-picks through open doors/windows)
**Severity:** LOW (Stage A — screen-rect picker — is sufficient for M1)
**Filed:** 2026-05-16
**Component:** selection / picker
@ -1419,15 +790,6 @@ 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).
**New evidence (2026-05-28 / Phase A8 visual gate):** User stood outside
a Holtburg building, saw a vendor through an open doorway/window, clicked
the visible vendor, and acdream selected the door instead:
`[B.4b] pick guid=0x7A9B4015 name=Door`. This is exactly the Stage A
failure mode: the open door's projected `Setup.SelectionSphere` rect is
closer than the vendor's rect, even though the visible door polygon is not
under the cursor. The fix is polygon refinement against visible GfxObj
triangles plus current animated part transforms; do not special-case doors.
**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
@ -1438,8 +800,8 @@ 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-28 that the door/vendor case is now observable in real
play, so this should be scheduled soon after A8 rather than left as polish.
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.
---
@ -3674,122 +3036,6 @@ Unverified. The likely culprits, ranked by suspected probability:
# Recently closed
## Cottage doorway "flap" — [DONE 2026-06-03 · 22a184c + e5457f9 + 79fb6e7] membership pick + render-root clobbering (the TWO causes)
**Status:** DONE (user-verified inside-looking-out)
**Closed:** 2026-06-03
**Commits:** `b44dd14`/`bc56545`/`22a184c`/`e5457f9` (membership Stage 1) + `79fb6e7` (blue-hole render-root)
**Component:** physics/membership, rendering
**Resolution:** The cottage doorway flap (full-screen bluish void + flicker) had TWO independent
causes, both fixed this session:
1. **Membership pick ping-pong**`CellTransit.BuildCellSetAndPickContaining` used an unordered
`HashSet` + a pre-pick fork in `FindEnvCollisions`. Ported retail's verbatim ordered `CELLARRAY`
`find_cell_list` pick (current cell at index 0, interior-wins-break) + the collide-then-pick order
(`find_env_collisions``check_other_cells`, removing the pre-pick that swapped collision geometry
with the cell mid-tick). `[cell-transit]` 47→13→DELTA=0 while standing still. (Stage 1; faithful.)
2. **Render-root clobbering**`CellGraph.CurrCell` ("the player's cell", the render root) was
written by the PER-ENTITY `ResolveWithTransition`/`ResolveCellId`. A jumping Holtburg NPC near the
doorway overwrote the player's render root every tick → render rooted at the NPC's tiny connector
cell (0170) instead of the player's room (0171) → only its ~8-tri shell drew, rest = GL clear color
= the blue void. Fixed: `CurrCell` is now written ONLY by the player
(`PhysicsEngine.UpdatePlayerCurrCell` via `PlayerMovementController.UpdateCellId`).
Diagnosed via `[flap-cam]`/`[shell]`/`[cell-transit]` (player stable in 0171, render rooted at 0170
for 77,951 frames). **Residuals are NOT the flap** — three known render phases remain (A
camera-collision: walls grey while inside; B R1b/#104 particles through ground; C R2 outside-looking-in
transparent walls) + membership Stage 2 (uniform collision + intrinsic entry, faithfulness debt). Full
record: [`docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md`](research/2026-06-03-membership-and-bluehole-shipped-handoff.md).
## Phase U.4c doorway "flap" — [DONE 2026-05-31 · 0ee328a] indoor visibility rooted at the camera eye
**Status:** DONE (Phase U.4c flap sub-step)
**Closed:** 2026-05-31
**Commits:** `0ee328a` (fix) + `13d58ca`/`b5f2bf2`/`8941d1e` (characterization)
**Component:** rendering, visibility
**Resolution:** Crossing a doorway, terrain + building shells + cell shells flapped off
(grey void + floating entities). Root cause (converged on a live `ACDREAM_PROBE_FLAP`
capture, after disproving a side-test/`PortalSide` hypothesis and a PVS-grounding
hypothesis): indoor portal visibility was rooted at the 3rd-person camera **eye**, which
drifts out of the player's cell; `FindCameraCell` then returned a **stale cell for its 3
grace frames**, and from that stale root the doorway portal was culled as "behind" the eye
→ the exit cell + terrain dropped. Fix: root indoor visibility (cell resolution + portal-
side test) at the **player's cell** (retail `CellManager::ChangePosition` tracks `curr_cell`
by the player; acdream already roots lighting at the player). Eye still drives projection.
Visual-verified "flap gone." **Residuals are NOT the flap** — see #78 (terrain not gated
inside, now more visible) + a new camera-collision need (the chase eye is outside the
player's cell ~79% of frames → eye-projected clip over-includes → transparent outer walls)
+ U.5 (outside-looking-in). Full record:
[`docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md`](research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md).
## #100 — [DONE 2026-05-25 · f48c74aa + a64e6f2] Transparent rectangular patches around every house (terrain rendering)
**Status:** DONE
**Closed:** 2026-05-25
**Commits:** `f48c74aa`, `a64e6f2`
**Component:** rendering, terrain
**Resolution (2026-05-25 · #100):** Replaced the cell-level
`hiddenTerrainCells` mechanism with retail's per-vertex Z nudge
(`zFightTerrainAdjust = 0.00999999978`) applied inside the modern
terrain vertex shader. Render terrain everywhere; coplanar building
floors win the depth test by being 1 cm higher than the rendered
terrain. Physics path untouched. ~50 LOC of `BuildingTerrainCells`
plumbing removed across LandblockMesh / LoadedLandblock /
LandblockLoader / GameWindow / GpuWorldState / LandblockStreamer
plus the corresponding unit test. Retail anchors:
acclient_2013_pseudo_c.txt:1120769 + :702254.
**Description:** Standing outside any Holtburg house, the ground in a
rectangular footprint around the building appears as a flat dark patch
instead of cobblestone / grass terrain. Visible as a sharp-edged
rectangle the size of the house's outdoor footprint. Same shape on
every house observed.
User report 2026-05-24 (with screenshot): "around every house now I
missing the ground texture, it is transparent. I can see through the
ground."
**Root cause:** Bisect 2026-05-24 — commit `35b37df` is the introducer. It
added a `hiddenTerrainCells` parameter to `LandblockMesh.Build` that collapses
terrain triangles owned by buildings to zero-area degenerates. The hide
mechanism works at outdoor-cell granularity (24 m × 24 m cells), so the entire
cell terrain was hidden but the cottage geometry only covers a smaller area inside
it — leaving a dark transparent rectangle. The fix renders terrain everywhere and
uses retail's Z nudge to ensure building floors win the depth test.
---
## #101 — [DONE 2026-05-25 · 5240d65 + 6ca872f] Stair-step cylinder phantom blocks player on multi-part EnvCell entity
**Closed:** 2026-05-25
**Commits:** `f6305b1` — feat(physics): #101 — add IsPhantomGfxObjSource predicate; `5240d65` — fix(physics): #101 — suppress mesh-aabb-fallback for phantom GfxObj stabs; `6ca872f` — docs(test): #101 — sync stale GameWindow.cs line ref in test class doc
**Component:** physics, dat-handling
**Resolution.** `PhysicsDataCache.IsPhantomGfxObjSource(gfxObjId)` predicate returns `true` when
the entity's `SourceGfxObjOrSetupId` has the GfxObj high byte (`0x01`) AND no cached
`GfxObjPhysics` entry exists (or its `BSP.Root` is null) — i.e., the underlying GfxObj had
`HasPhysics=False` so `PhysicsDataCache.CacheGfxObj` short-circuited. The inline
mesh-AABB-fallback gate at `GameWindow.cs:6127` checks this predicate and skips the shadow-shape
registration entirely when the source is a phantom. The 10 phantom stair cyls from
`GfxObj 0x0100081A` (`hasPhys=False`) that previously blocked the player at the foot of the
Holtburg upper-floor staircase are no longer registered. Collision falls through to entity
`0x40B50089` (GfxObj `0x01000C16`, `hasPhys=True` BSP with walkable inclined polygon at
`Normal.Z=0.717`, world ramp from (111.10, 25.50, 94.00)→(107.50, 27.10, 97.50)). 3 unit tests
in `PhysicsDataCachePhantomSourceTests.IsPhantomGfxObjSource_*` (no BSP → true; has BSP →
false; non-GfxObj high byte → false) shipped alongside the predicate.
**Investigation:** [`docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md`](research/2026-05-25-a6-stairs-cyl-retail-investigation.md).
**Plan:** [`docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md`](superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md).
**Verification.** Visual-verified at Holtburg upper-floor cottage stairs 2026-05-25 — `[cyl-test]`
count on `obj=0x40B500*` post-fix = 0 (was 7101 pre-fix); `src=0x0100081A` mesh-aabb-fallback
count = 0 (was 28 pre-fix). Player climbed Z=94→97.5 holding W continuously over the full 45°
ramp — no phantom diagonal slides.
---
## #86 — [DONE 2026-05-19 · 3764867 + 4e308d5] Click selection penetrates walls
**Closed:** 2026-05-19

View file

@ -311,51 +311,6 @@ The plugin API exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
---
## Render Pipeline (SSOT — current state + unified-PView target)
> **The per-frame render step above is STALE** (it names deleted classes
> `TerrainRenderer` / `StaticMeshRenderer`). The modern path (Phase N.5, mandatory) is
> `WbDrawDispatcher` (entities) + `EnvCellRenderer` (indoor cell shells) +
> `TerrainModernRenderer` (terrain), fed by the portal-visibility stack. This section is
> the authoritative description of how indoor/outdoor rendering is *supposed* to work and
> where the code currently diverges. Canonical reset handoff:
> `docs/research/2026-05-31-render-architecture-reset-handoff.md`.
**The principle (retail PView).** acdream must render the world the way retail does —
through **one** portal-visibility traversal whose output **gates every geometry type
uniformly**. From the player's cell, walk the portal graph; each visible cell carries a
screen-space clip region (its portal opening, recursively intersected); the **outside**
(terrain + outdoor scenery) is reached only through **exit portals** and is clipped to
those openings. Interior cell shells, interior statics, and the outside are **all**
clipped to their PView region. This is why retail is **seamless by construction**. Decomp
anchors: `PView::ConstructView` (`:433750`), `InitCell` (`:432896`), `DrawCells`
(`:432715`), `CEnvCell::find_visible_child_cell` (`:311397`), `SmartBox::update_viewer`
(`:92761`). Reference port acdream owns but never invokes: WB `RenderInsideOut` /
`VisibilityManager`.
**The one rule:** *compute visibility once; enforce it once, for all geometry.* Indoors,
you see the outside **only** through portal openings (clipped); an empty outside-view
(windowless interior) draws **no** outdoor geometry. Outdoors, the gate is "everything."
**Current divergence (the patchwork — what the reset must fix).** acdream computes the
visibility correctly (`CellVisibility.ComputeVisibility``PortalVisibilityBuilder.Build`,
a `ConstructView` port → `ClipFrameAssembler`) but then **enforces it three different,
inconsistent ways**:
1. `TerrainModernRenderer` — gated by `TerrainClipMode {Skip|Scissor|Planes}` (the Scissor
fallback over-includes).
2. `EnvCellRenderer` — gated by the per-cell clip slot (≈correct; the shells DO render —
proven by the `[shell]` probe, `ACDREAM_PROBE_SHELL`).
3. `WbDrawDispatcher` — gated by `ParentCellId ∈ visibleCellIds`, **but outdoor stabs
(`ParentCellId==null`) bypass the gate** → outdoor scenery/terrain shows from inside
(issue #78).
Three gates that must agree but don't → structural seams (transparent walls,
terrain-through-floor, grey enclosure). **The reset consolidates them into the single
PView gate** (outside content clipped to the `OutsideView` region; no `ParentCellId==null`
bypass; no Scissor over-include). This is a **consolidation of existing machinery**
(`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case
gate to mask a seam — that anti-pattern produced the patchwork.
## Roadmap Model
The old R1-R8 architecture sequence was a useful early refactor sketch, but it

View file

@ -1,55 +1,35 @@
# WorldBuilder Inventory — what we extracted, adapted, or left behind
# WorldBuilder Inventory — what we take, adapt, or leave
> **Phase O shipped 2026-05-21.** The ~33 WB files we actually use have
> been extracted into our tree. `references/WorldBuilder/` stays as a
> **read-reference only** — nothing in `src/AcDream.*` references it as a
> project dependency. `DatCollection` is now the only dat reader in process.
>
> Use this document to:
> 1. Know **where our extracted code lives** (look for the "Extracted to"
> column / notes in each section below).
> 2. Know **what WB still has** that we haven't needed yet — grep
> `references/WorldBuilder/` if you ever need to add something.
> 3. Know **what WB never had** (the 🔴 list) — those are always ours.
**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.
**Pre-O status (archived for context):** As of Phase N.4 (2026-05-08)
acdream relied heavily on WorldBuilder as a project reference for rendering
and dat-handling. WorldBuilder is MIT-licensed, 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 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.
**Post-O integration model:** Extracted WB code lives in two locations in
our tree (see CLAUDE.md for the full breakdown):
- `src/AcDream.Core/Rendering/Wb/` — pure helpers (no GL): `TerrainUtils`,
`TerrainEntry`, `RegionInfo`, `SceneryHelpers`, `TextureHelpers`.
- `src/AcDream.App/Rendering/Wb/` — GL infrastructure + mesh pipeline:
`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache,
shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes.
`DatCollectionAdapter` bridges our `IDatCollection` to the `IDatReaderWriter`
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
internal `_dats.*` call sites — above the 20-site inline-swap threshold).
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
it but we haven't extracted it yet, grep `references/WorldBuilder/` and extract
as needed. Retail decomp remains the oracle for things WB never had (🔴 list).
Attribution: WorldBuilder is MIT-licensed. `NOTICE.md` includes WB attribution.
**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.
---
## Read-reference layout (under `references/WorldBuilder/`, not project-referenced)
## Repo layout (as of cloned snapshot under `references/WorldBuilder/`)
- **`Chorizite.OpenGLSDLBackend/`** — full OpenGL renderer (Silk.NET). The
components we use are extracted into `src/AcDream.App/Rendering/Wb/`.
- **`Chorizite.OpenGLSDLBackend/`** — full OpenGL renderer (Silk.NET).
- **`WorldBuilder.Shared/`** — data models, dat parsers, landscape module.
The helpers we use are extracted into `src/AcDream.Core/Rendering/Wb/`.
- **`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 only).
- **`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):
@ -254,28 +234,17 @@ WorldBuilder is a dat editor; it does not have:
---
## What this means for the workflow (post-Phase O)
## 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).
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.
For anything in 🟢 that we've already extracted: **the code is in our
tree at `src/AcDream.{Core,App}/Rendering/Wb/`**. Read it there — don't
grep `references/WorldBuilder/` unless you want to compare against the
original. Re-porting from retail decomp when we already have a tested
port is still how we'd get the scenery edge-vertex bug back.
For anything in 🟢 that we have NOT yet extracted: grep
`references/WorldBuilder/` to find the source, then extract it using the
Phase O pattern (verbatim copy → adapt constructor to accept
`IDatCollection` via `DatCollectionAdapter` where needed → add to
`src/AcDream.App/Rendering/Wb/`). Do NOT add a new project reference back
to `WorldBuilder.Shared` or `Chorizite.OpenGLSDLBackend` — Phase O
permanently removed those.
When we discover a behavior mismatch with retail (rare — the extracted
code is the same as the original), the resolution is: reconcile extracted
code ↔ retail decomp ↔ holtburger ↔ ACE ↔ ACViewer (the existing
reference hierarchy in CLAUDE.md). Our extracted code ranks at the top
of that hierarchy for anything 🟢.
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 🟢.

View file

@ -5,30 +5,6 @@
---
## Current program: Phase W — Unified Cell Graph (UCG)
**Pivot (2026-06-02).** The render-pipeline reset stalled because the indoor "world from
below" is a cell-**membership** disagreement between the render-side `CellVisibility` and
the physics-side `ResolveCellId` — not any single draw gate (pixel-grounded evidence:
[`docs/research/2026-06-02-render-cell-membership-evidence.md`](../research/2026-06-02-render-cell-membership-evidence.md)).
We committed to a full migration onto **one retail `CObjCell` cell graph** shared by
physics + collision + render + streaming. **Supersedes** the render-only "Phase U" framing
and the abandoned A8 two-pipe (#103). Five verify-each stages on branch
`claude/thirsty-goldberg-51bb9b`:
| Stage | What | Status |
|---|---|---|
| **W1** | `ObjCell` scaffold — Core `ObjCell`/`EnvCell`/`LandCell`/`CellPortal`/`CellGraph` built alongside the legacy systems, consumed by nobody (zero behavior change). | **Shipped 2026-06-02** (`9cb1571``f2663b7`; 22 tests; Opus-reviewed) |
| W2 | One membership — player `curr_cell` via retail `find_cell_list` + `change_cell` + doorway hysteresis; collapses `ResolveCellId` + `FindCameraCell` into one answer. *First behavior-changing stage.* | **W2a shipped + visually verified 2026-06-02** (`0e27a6c`+`02acac5`: render reads physics `CurrCell`; the indoor "world from below" is FIXED — cellar/stairs seal walls+floor). **W2b next** (doorway hysteresis — ping-pong `0170↔0031` confirmed). Baseline+handoff: [`docs/research/2026-06-02-phase-w-w2a-shipped-baseline-handoff.md`](../research/2026-06-02-phase-w-w2a-shipped-baseline-handoff.md) |
| W3 | Render on the graph — PView walk + **one gate** for terrain/shells/entities. **The visible M1.5 indoor fix.** | Planned |
| W4 | Collision on the graph — physics queries the same `ObjCell`s; retire parallel `CellPhysics`. | Planned |
| W5 | Streaming → `ObjCell`s — terrain as `LandCell`; the frozen-streaming rewrite. | Planned |
W1 spec: [`docs/superpowers/specs/2026-06-02-unified-cell-graph-stage1-design.md`](../superpowers/specs/2026-06-02-unified-cell-graph-stage1-design.md) ·
W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../superpowers/plans/2026-06-02-unified-cell-graph-stage1.md).
---
## Phases already shipped
| Phase | What landed | Verification |
@ -97,8 +73,6 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
| 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 ✓ |
| Indoor walking Phase A4 — Multi-cell BSP iteration | 2026-05-20. Ports retail's `CTransition::check_other_cells` (`acclient_2013_pseudo_c.txt:272717-272798`). After the primary cell's BSP returns OK, every other cell the foot-sphere overlaps is queried via `BSPQuery.FindCollisions`. Halt on first Collided/Adjusted/Slid; Slid clears the contact-plane fields. Three commits land the slices: `e6369e2` `CellTransit.FindCellSet` overload (refactor `FindCellList` to expose the candidate set); `493c5e5` `Transition.CheckOtherCells` + `ApplyOtherCellResult` combine helper; `691493e` (orig `967d065`, then `3add110` revert, then this reapply) wires `CheckOtherCells` into `FindEnvCollisions`. 10 new unit tests; 1139 + 8 baseline maintained. **Visual verification surfaced a separate, pre-existing M2 blocker:** at the Holtburg inn doorway the CellId ping-pongs between outdoor `0xA9B40022` and indoor vestibule `0xA9B40164` rapidly because indoor BSP push-back exits the indoor CellBSP volume → ResolveCellId reclassifies as outdoor → wall checks bypassed on outdoor ticks. Bug reproduces fully with A4 reverted (`launch-revert2.log`), confirming A4 is not the cause. A4 is correct and tested but **dormant in practice** until the ping-pong is fixed. Handoff: [`docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md`](../research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md). Spec: [`docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md`](../superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md). Plan: [`docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md`](../superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md). | Live ✓ (dormant pending cell-tracking fix) |
| Phase O — DatPath Unification | 2026-05-21. ONE thing touches the DATs. Extracted ~33 WB files / ~7.7K LOC into `src/AcDream.{Core,App}/Rendering/Wb/`. Dropped project references to `WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend` from `AcDream.App.csproj` and `AcDream.Core.csproj`. `DefaultDatReaderWriter` eliminated; `DatCollection` is the only dat reader. `WbMeshAdapter` consumes it via `DatCollectionAdapter` (O-D7 fallback; `ObjectMeshManager` has 26 internal `_dats.*` call sites exceeding the 20-site inline-swap threshold). `references/WorldBuilder/` stays in-tree as read-reference. **Visual side-by-side passed**: Holtburg town, inn interior, dungeon all render identically to pre-O. `NOTICE.md` includes WB MIT attribution. Spec: [`docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md`](../superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md). Audit: [`docs/superpowers/specs/2026-05-21-phase-o-t1-wb-audit.md`](../superpowers/specs/2026-05-21-phase-o-t1-wb-audit.md). Plan: [`docs/superpowers/plans/2026-05-21-phase-o-plan.md`](../superpowers/plans/2026-05-21-phase-o-plan.md). | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -110,227 +84,6 @@ Plus polish that doesn't get its own phase number:
## Phases ahead — agreed order
### Phase O — DatPath Unification — SHIPPED 2026-05-21
**Tagline:** ONE thing touches the DATs.
**Filed:** 2026-05-21. **Status:** SHIPPED. **Spec:** [`docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md`](../superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md).
See the shipped-table entry above for the full summary. Phase O is
complete; M1.5 (indoor walking, paused for Phase O) resumes from
the 2026-05-20 baseline.
**Acceptance met** (full list in spec §6):
- `dotnet build` + `dotnet test` green; ~1147 test count maintained.
- Zero `using WorldBuilder.*` or `Chorizite.OpenGLSDLBackend.*` in `src/AcDream.*`.
- `DefaultDatReaderWriter` referenced in zero places in our source.
- Resident memory at `radius=4 + 50 entities visible`: **≥ 50 MB reduction** vs. pre-O main.
- Visual side-by-side identical for the three reference scenes; user confirms.
- `NOTICE.md` includes WB MIT attribution.
**Non-goals** (explicit, spec §8): re-porting from retail decomp;
performance optimization of extracted code; API cleanup. Verbatim
copy + swap to `DatCollection` only. Refactors in follow-up phases.
**After Phase O ships:** M1.5 resumes from its 2026-05-20 baseline
with no code changes lost — M1.5 doesn't touch WB-extracted territory.
### Milestone M1.5 — "Indoor world feels right" (ACTIVE — Phase O shipped; resuming from 2026-05-20 baseline)
The current top of the work order. M2 ("kill a drudge") is deferred until M1.5
lands — drudges live in dungeons and the M2 demo target requires solid indoor
navigation. Full milestone block in
[`docs/plans/2026-05-12-milestones.md`](2026-05-12-milestones.md).
**2026-05-30 — render-pipeline pivot.** Indoor *rendering* (the seamless in/out
seam: the flap, missing/transparent walls, terrain bleed) is NO LONGER pursued via
the WB-inherited two-pipe (inside/outside) split. That whole approach (Phase A8/A8.F,
issue #103) is **abandoned**. Indoor rendering is now **Phase U** below. Phase A6
(physics) and A7 (lighting) inside M1.5 are unaffected.
#### Phase U — Unified retail-faithful render pipeline (NEW — supersedes A8/A8.F)
**Decision (2026-05-30):** replace the two render paths (outdoor `Draw` +
`RenderInsideOut` stencil, toggled on `cameraInsideBuilding`) with ONE pipeline driven
by retail's portal-visibility view (`PView::ConstructView` / `ClipPortals` / `GetClip`;
`CEnvCell::find_visible_child_cell`). The camera's cell is just the root of a recursive
per-portal clip-region traversal; all visible cells (indoor + outdoor) draw in one pass.
Seamless in/out **by construction** — no inside/outside branch. Modern code, retail
behavior.
- **Why:** the two-pipe split is a WB-editor inheritance, not a game-client design; you
cannot make two pipes hand off seamlessly at a doorway. Retail never splits. The A8.F
attempt to graft retail recursion onto the WB stencil failed its visual gate (#103).
- **Keep:** WB mesh/dat pipeline (ObjectMeshManager/WbDrawDispatcher/terrain), the
2026-05-30 camera-collision + physics work. **Salvage (verify):** the A8.F CPU
clip-builder (PortalProjection/ScreenPolygonClip/ViewPolygon/PortalVisibilityBuilder —
unit-test-correct). **Task 1:** delete the dead two-pipe code (RenderInsideOutAcdream,
the cameraInsideBuilding branch, IndoorCellStencilPipeline, the ACDREAM_A8_INDOOR_BRANCH
kill-switch) — audit first; some A8 commits fixed real bugs (BuildingId stamping, pool
aliasing) that stay.
- **Scope + next-session pickup:**
[`docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`](../research/2026-05-30-unified-render-pipeline-decision-and-handoff.md).
Start with `superpowers:brainstorming`; visual verification at Holtburg
cottage/cellar/inn + a portal dungeon is the acceptance gate (unit tests did not
catch #103).
- **Camera-collision (shipped 2026-05-30, kept):** retail `SmartBox::update_viewer`
swept-sphere spring arm (`CameraDiagnostics.CollideCamera`, `PhysicsCameraCollisionProbe`,
`RetailChaseCamera` integration) + viewer/sight bypass of the 30-step transition cap.
Specs: [`2026-05-29-a8f-camera-collision-design.md`](../superpowers/specs/2026-05-29-a8f-camera-collision-design.md).
**Today's pre-M1.5 baseline** (2026-05-20 — committed in this
session): A4 multi-cell BSP iteration (`691493e`), #89 sphere-overlap
in CheckBuildingTransit (`7ac8f54`), #90 sphere-overlap stickiness in
ResolveCellId (`4ca3596`**WORKAROUND**, scheduled for removal in
A6.P4), #91 indoor cell shadows in FindObjCollisions (`c0d8405`),
#92 server cell id at player-mode entry (`23ab173`). 1147 + 8 baseline
maintained. Holtburg inn + cottage interiors visually verified
2026-05-20.
#### Phase A6 — Indoor physics fidelity (cdb-driven)
**Hypothesis:** Our `BSPQuery.FindCollisions` 6-path dispatcher (and
its callers) produce collision responses that systematically diverge
from retail's. Symptoms in different geometry (doorways, stairs,
multi-Z, cellars, dungeons) share one underlying mechanism — most
likely push-back distance / direction / CP synthesis.
**Investigation methodology:** cdb-attached comparison. Toolchain
documented in CLAUDE.md's "Retail debugger toolchain" section. Used
successfully 2026-04-30 for the steep-roof case. Matching binaries
(acclient.exe v11.4186) + PDB present.
**Sub-pieces (slices):**
- **✓ SHIPPED — A6.P1 — cdb probe spike** (2026-05-21). Built cdb
scripts (`tools/cdb/a6-probe.cdb` v4 with PDB-verified offsets +
hex-bits float output + Python decoder), PowerShell runner with
ASCII encoding, PDB-match verification, and the new
`[push-back]`/`[push-back-disp]`/`[push-back-cell]` acdream probe
family (env `ACDREAM_PROBE_PUSH_BACK=1`). Captured 5 of 9 scenarios
with paired retail+acdream traces (scen1 inn doorway, scen2 inn
stairs, scen3 inn 2nd floor, scen4 cottage cellar, scen5 town
network portal as substitute for Holtburg Sewer entry). Scen6-9
cancelled — Holtburg Sewer doesn't exist on this server, and any
substitute dungeon hits issue #95 (portal-graph visibility blowup)
on portal entry, making physics-only analysis impossible. Five
captures are sufficient evidence for A6.P2. Commits: infra Tasks
1-14 + cdb iterations + scen1 capture (prior session); scen2-5
captures (`a9a427f`, `297d1c5`, `4b5aebc`, `46c6e08`, `35d5c58`)
+ issue #95 filing (`5be784e`) (this session).
- **✓ SHIPPED — A6.P2 — Analysis report** (2026-05-21, `184933d`).
Output: [`docs/research/2026-05-21-a6-cdb-capture-findings.md`](../research/2026-05-21-a6-cdb-capture-findings.md).
Four findings ready for A6.P3: Finding 2 (ContactPlane resynthesis
blowup — 250× to ∞× more CP writes in acdream; primary M1.5 root
cause) is HIGH severity and the highest-confidence single-cause
fix candidate. Finding 1 (dispatcher entry frequency mismatch —
4× to 281× fewer dispatcher entries in acdream) is likely a
secondary effect of Finding 2's missing retention paths. Finding 3
(indoor cell-resolver sling-out captured in scen4) — HIGH severity,
separate fix surface in ResolveCellId/CheckBuildingTransit.
Finding 4 (portal-graph visibility blowup discovered incidentally
in scen5) — filed as issue #95, scope-adjacent, handled outside A6.
Tables 1+2 (per-site push-back delta + path-frequency diff)
deferred to optional A6.P1.5 (entry+exit BPs in cdb script);
not blocking A6.P3. M1.5 symptom coverage matrix shows every
in-scope physics symptom mapped to at least one finding.
- **A6.P3 — Fix the BSP correction paths** (~35 days). Multi-slice.
- **✓ SHIPPED — A6.P3 slice 1 — Indoor ContactPlane retention**
(2026-05-21, commits `ba9655f` plan + `6b4be7f`/`c6bc2b9` T1
research + `869edd9` T2 counter + `36975ef`/`a32f569` T3 test +
`5aba071` T4 Mechanism B + `5f7722a`/`39fc037`/`bd5fe2e` T5 strip
+ `066568a` scen2 postfix proof + `<this commit>` T8 bookkeeping).
Stripped `TryFindIndoorWalkablePlane` synthesis path from
`Transition.FindEnvCollisions` indoor branch (matches retail's
tiny `CEnvCell::find_env_collisions` shape at acclient_2013_pseudo_c.txt:309573).
Added Mechanism B (LKCP restore) in `Transition.ValidateTransition`
matching retail's pattern at acclient_2013_pseudo_c.txt:272565-272583.
Per-unit-of-activity CP-write rate dropped 63×. **Unexpected win:
stairs + cellar descent now WORK in acdream** (user happy-test
confirmed). A6.P2 Finding 1 (dispatcher entry frequency mismatch)
CLOSED as side-effect (dispatcher shape now retail-like). Finding 2
PARTIALLY CLOSED — 99.3% of remaining cp-writes come from L622
per-tick body-CP seed at `PhysicsEngine.ResolveWithTransition:622`
(filed as issue #96 for slice 2).
- **✓ SHIPPED — A6.P3 slice 2 — L622 seed investigation + no-op guard**
(2026-05-22, commits `892019b` v1 + `f8d669b` v2). v1 removed the
L622 seed entirely; broke BSP step_up at the last step of stairs
(user happy-test surfaced the regression). v2 reverted v1 + added
a no-op-if-unchanged guard inside `CollisionInfo.SetContactPlane`.
**#96 PARTIALLY ADDRESSED — accepted as documented retail
divergence.** The seed is load-bearing for `AdjustOffset`
slope-projection on sub-step 1 which BSP step_up depends on.
Matching retail would require deeper refactor (e.g. AdjustOffset
fallback to body.ContactPlane). Guard is benign improvement;
further #96 closure deferred.
- **✓ SHIPPED — A6.P3 slice 3 — cell-resolver stickiness**
(2026-05-22, commits `8898166` v1 + `3e140cf` v2). Added
point-in stickiness check at top of `ResolveCellId`'s indoor
branch. Cell-resolver ping-pong FULLY CLOSED (data: scen4 cellar
capture shows 1 cell-transit vs 20+ pre-fix). **Outcomes:**
Finding 3 (cell-resolver instability) closed. #90 workaround
redundant (deferred A6.P4 removal). #97 phantom collisions
hypothesis pending re-test (likely closed too). #98 cellar-up
symptom PERSISTS but with NEW diagnosis (re-filed in #98 as BSP
step-physics at cellar stair top — sloped step-face mis-handling,
NOT cell-resolver).
- **A6.P3 slice 4 (or A6.P4)? — BSP step-physics at cellar
stair top (#98 new diagnosis)** (NEXT or DEFERRED). Investigate
why step-down probe consumes all walk-interp at cellar stair top.
Evidence: scen4 cottage_cellar_slice3v2 push-back trace. May
require reading BSP step_up + step_down decomp + comparing to
cellar stair geometry. Could be its own slice or merged into a
broader A6.P4 cleanup phase.
- Issue #95 (visibility blowup) NOT in A6.P3 scope — separate work
surface.
- **A6.P4 — Remove workarounds + visual verification** (~1 day after
P3). Revert #90 sphere-overlap stickiness in
`PhysicsEngine.ResolveCellId`. Delete `Transition.TryFindIndoorWalkablePlane`
+ its caller in `FindEnvCollisions`. Visual verification at Holtburg
inn + cellar + (if #95 is also fixed by then) a dungeon. The
original A6.P4 plan named "Holtburg Sewer end-to-end" as the
acceptance walk; since the sewer doesn't exist, the M1.5 demo
scenario needs an alternative (see milestones doc).
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
**Hypothesis layers (less mapped than physics):**
- Per-cell environment-light tag association — indoor cells should
inherit only their own env lights, not outdoor day-cycle.
- Light visibility culling — what lights actually contribute to each
cell's render.
- Per-entity light direction transform — held-item-spotlight bug
(#L-spotlight) is per-entity attribution gone wrong.
- Static-stab atmospheric inheritance (#81).
**Investigation methodology:** less existing infrastructure than
physics. Requires:
- New `[indoor-light]` probe (per-frame dump of active lights for the
player's cell + each visible entity: position, color, attenuation,
direction).
- RenderDoc frame capture at the same 9 scenarios as A6.
- Grep retail's `Render::lighting_*` family in
`acclient_2013_pseudo_c.txt` to map per-cell light selection logic.
**Sub-pieces (slices):**
- **A7.L1 — Lighting probe spike** (~35 days). Build `[indoor-light]`
probe. Capture baselines at all 9 scenarios. RenderDoc captures
paired with each. Decomp study of retail's lighting selection.
- **A7.L2 — Analysis report** (~12 days). Likely surfaces 24
distinct bugs across the lighting issues.
- **A7.L3 — Fix lighting paths** (~37 days). Wide variance because
the surface area is unknown. Could touch indoor env-light parsing,
`LightingHookSink`, WB rendering pipeline, shader uniforms.
**M1.5 acceptance criterion (shared by A6 + A7):** Walk Holtburg Sewer
end-to-end. Walls block (physics). Stairs work (physics). Items
block (physics). Lighting reads correctly throughout (lighting).
Cell transitions are smooth (physics). No regressions in M1 outdoor
behavior. Estimated 1726 days focused work, 35 weeks calendar.
**Specs:** to be written 2026-05-20 (after milestone commit lands).
---
### Phase A — Foundation (in progress)
**Goal:** walk across 10+ landblocks without crashes, without hitches at landblock boundaries, and without framerate cratering.

View file

@ -2,7 +2,7 @@
**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.5 — Indoor world feels right.**
**Currently working toward:** **M1 — Walkable + clickable world.**
---
@ -185,115 +185,7 @@ close range and the player sees "You pick up the X." in chat.
---
### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (resumed 2026-05-21 after Phase O ship)
**2026-05-30 — render-pipeline pivot.** The indoor *rendering* seam (seamless
in/out: the flap, missing/transparent walls, terrain bleed) will be solved by a
**single unified retail-faithful render pipeline (Phase U)**, replacing the
abandoned two-pipe inside/outside split (A8/A8.F, issue #103). The two-pipe split
is a WorldBuilder inheritance; retail uses one portal-visibility pass and is
seamless by construction. Decision + scope:
[`docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`](../research/2026-05-30-unified-render-pipeline-decision-and-handoff.md).
Camera-collision + a physics viewer-cap fix shipped 2026-05-30 and are kept (they
were a detour from the real seam fix, but retail-faithful and worth keeping). A6
(physics) and A7 (lighting) are unaffected.
**Phase O — DatPath Unification — shipped 2026-05-21.** ONE thing
touches the DATs. ~33 WB files (~7.7K LOC) extracted into
`src/AcDream.{Core,App}/Rendering/Wb/`; project references to
`WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend` dropped;
`DefaultDatReaderWriter` eliminated. Visual side-by-side passed at
Holtburg town, inn interior, and dungeon. Phase O pre-empted M1.5
per user direction 2026-05-21; M1.5 now resumes from the 2026-05-20
baseline with no code changes lost — Phase O did not touch dat-loading
infrastructure for physics or collision, only the rendering pipeline.
M1.5's planned phases (A6 + A7) are unaffected.
**Demo scenario (updated 2026-05-21):** The original demo target was
"enter the Holtburg Sewer dungeon" but that location doesn't exist on
this ACE server (discovered during A6.P1 capture session). Additionally,
A6.P1 surfaced **issue #95** (portal-graph visibility blowup) which makes
ANY dungeon visually unusable on entry. Demo scenario revised in two
parts:
**Building/cellar demo (achievable after A6.P3 lands):**
Walk into the Holtburg inn, climb to the 2nd floor, walk around without
sling-out or wall-clip. Enter a cottage cellar, descend without falling
through. Throughout:
- Walls block — no walk-through anywhere, indoor or stab-shell.
- Stairs work — ascend + descend without falling through or stuck-in-falling.
- Items block — furniture, decorations.
- Cell transitions are smooth — no CellId ping-pong, no flicker.
- Lighting reads correctly — torchlit rooms are bright, no spotlight
artifacts, static decorations participate in env lighting.
**Dungeon demo (blocked on issue #95 fix; promote to post-M1.5 if
the visibility bug isn't addressed in M1.5 scope):**
Enter any dungeon via portal (substitute for "Holtburg Sewer"). Navigate
~3-5 rooms without rendering corruption (no see-through-walls, no
other-dungeons-rendered-inside). Walls block, stairs work, items block,
lighting correct, cell transitions smooth.
**Why this is its own milestone:** M1 landed walkable + clickable as a
specification (the doorways open, NPCs select, items pick up — all visible
in the demo target). But continued indoor testing surfaced a deep family of
physics + lighting bugs (BSP push-back distance probably diverges from
retail, per-frame ContactPlane synthesis is a known unfaithful stop-gap,
indoor lighting + item-spotlight bugs reported during 2026-05-21 sessions).
Three workarounds shipped today (#89 sphere-overlap CheckBuildingTransit,
#90 sphere-overlap stickiness, #92 spawn-cell-id seed) closed the visible
symptom at Holtburg inn, but #90 specifically is a CLAUDE.md-rules
workaround (explicit retail divergence) that needs a proper root-cause fix.
The umbrella indoor-physics issue (#83) has been open since 2026-05-19 with
multiple aborted fix attempts. Promoting this to milestone scope forces the
fix to be central, retail-anchored, and complete — not another whack-a-mole
patch.
**Phases included:**
| Phase | What it does |
|---|---|
| A6 — Indoor physics fidelity (cdb-driven) | Capture retail's per-tick BSP collision response state at 9 scenarios (4 buildings + 5 dungeon sites). Analyze the gap vs ours. Fix BSP correction paths. Remove #90 stickiness + `TryFindIndoorWalkablePlane` synthesis workarounds. |
| A7 — Indoor lighting fidelity (RenderDoc + retail-decomp) | Capture per-cell light state + per-pixel attribution at the same 9 scenarios. Analyze cell-light association, visibility culling, per-entity light direction. Fix indoor lighting + #80 (upper-floor dark) + #81 (static-stab atmospheric) + the held-item-spotlight bug. |
**Issues in scope (M1.5):**
- **#80** — Camera on 2nd floor goes very dark
- **#81** — Static building stabs don't react to atmospheric lighting
- **#83** — Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck)
- **#88** — Indoor static objects vibrate (suspected sub-step state corruption — A6.P2 maps to Finding 2 family)
- **#90** — CellId ping-pong (workaround in place; remove during A6.P4)
- **#95** — Portal-graph visibility blowup (filed 2026-05-21; **blocks the dungeon half of the M1.5 demo** but is NOT in A6 scope; either add a dedicated phase inside M1.5 to fix it OR promote the dungeon demo to post-M1.5)
- **L-indoor** — Lighting indoors broken (file as new # during M1.5 kickoff)
- **L-spotlight** — Items projecting spotlight on walls (file as new # during M1.5 kickoff)
- **Stairs walk-through** — captured + characterized by A6.P2 (Finding 2 family); fix in A6.P3
- **2nd-floor walking** — captured + characterized by A6.P2 (Finding 2 — scen3 shows infinite CP-write ratio on flat 2nd-floor walk); fix in A6.P3
- **Cellar descent** — same physics family as stairs; fix in A6.P3
- **Indoor sling-out** (new symptom from A6.P1 scen4) — captured + mapped to A6.P2 Finding 3 (cell-resolver in ResolveCellId / CheckBuildingTransit); fix in A6.P3
- **`TryFindIndoorWalkablePlane`** — synthesis workaround removal (Bug A's original goal, finally unblocked; A6.P4)
**Frozen phases during M1.5:** all M0 + M1 phases stay frozen. Plus
specifically the recently-shipped A4 + #89 + #91 + #92 (today's work) — those
land in main as the M1.5 baseline and shouldn't be revisited except as part
of A6.P4 removal of the workarounds.
**Estimated timeline:** 35 weeks calendar (1726 days focused work). Bigger
than a normal milestone because lighting is open-ended (less existing
diagnostic infrastructure than physics). Could be shorter if the cdb
analysis surfaces a single-fix opportunity.
**What "M1.5 lands" looks like:** the indoor world reads as solid. Players
can navigate buildings, basements, multi-floor inns, and dungeons without
encountering walls they walk through, lighting that looks wrong, or
position glitches. The two known workarounds (#90 stickiness +
TryFindIndoorWalkablePlane synthesis) are removed; the codebase no longer
has indoor-physics "duct-tape." Dungeons are usable enough to support M2's
"kill a drudge" demo target (drudges live in dungeons; this milestone
unblocks that).
---
### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (was: NEXT)
### M2 — "Kill a drudge" — 🔵 NEXT (~610 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

View file

@ -1,278 +0,0 @@
# Indoor walking — Bug A wrong-scope handoff (2026-05-20)
**Status:** Bug B shipped (`de8ffde`). Bug A attempted + reverted (`9f874f4``0a7ce8f`). The real bug is deeper than scoped and needs a fresh session with full context. ISSUES #83 remains OPEN.
This doc captures everything learned today so the next session picks up clean.
---
## TL;DR
I went into today expecting to land "ContactPlane retention" as a 2-slice phase:
- **Slice 1 (Bug B):** indoor BSP world-origin fix. SHIPPED at `de8ffde`. Closed a real corruption (320 corrupt CP writes/session with `D≈0` instead of world floor Z).
- **Slice 2 (Bug A):** delete the per-frame `TryFindIndoorWalkablePlane` synthesis on the indoor OK path. REVERTED. Caused worse regression (player fell through ground when crossing thresholds).
The probe + decomp study revealed Slice 2's premise was wrong:
- **Retail's `BSPTREE::find_collisions` Path 5B (grounded mover) does NOT call `find_walkable` either.** It only checks for walls. So Bug A's "delete the synthesis and trust the BSP" had nothing to fall back on for the no-step-down case.
- Retail keeps grounded movement coherent via THREE interacting mechanisms — A (Path 6 land), B (LKCP proximity restore), C (post-OK step-down probe). We have all three in our code already.
- **The actual failure mode** is when the player crosses a threshold (doorway) and the step-down probe finds **no floor poly** at the new XY. Step-down returns OK without writing CP, Mechanism B's proximity check fails because the player moved laterally past the cached plane, `oi.Contact` clears, player goes airborne, gravity wins.
This is a **cell geometry / cell-transition** problem, not a CP retention problem. Outside Bug A's scope.
---
## What's on main / what's on this branch
**Branch:** `claude/sad-aryabhata-2d2479` (worktree, not merged).
**Commits ahead of `main` (in order):**
| SHA | Subject | Status |
|---|---|---|
| `66de00d` | `feat(physics): [cp-write] probe for ContactPlane retention spike` | **KEEP** — invaluable for next session |
| `865634f` | `docs(spec): indoor BSP world-origin / world-rotation fix (Bug B)` | **KEEP** — describes the shipped fix |
| `56816fc` | `docs(plan): indoor BSP world-origin fix implementation plan` | **KEEP** |
| `39d4e65` | `test(physics): BSPQuery.FindCollisions writes world-space plane...` | **KEEP** — regression test for Bug B |
| `de8ffde` | `fix(physics): pass cell world-transform to indoor BSP collision` | **KEEP** — the Bug B fix |
| `3bec18f` | `docs(spec): remove per-frame indoor walkable-plane synthesis (Bug A)` | **KEEP** but mark `wrong-approach` |
| `686f27f` | `docs(plan): remove per-frame indoor walkable-plane synthesis (Bug A)` | **KEEP** but mark `wrong-approach` |
| `9f874f4` | `fix(physics): remove per-frame indoor walkable-plane synthesis` | **REVERTED by next commit** |
| `0a7ce8f` | `Revert "fix(physics): remove per-frame indoor walkable-plane synthesis"` | **The revert.** Brings back pre-Bug-A behavior. |
The branch is in a self-consistent post-Bug-B state: world-origin fix shipped, synthesis re-instated as it was before the session.
**Decision for next session:** merging Bug B to main is safe (closes a real corruption with strong probe evidence). The Bug A spec/plan + revert can stay on this branch as a tried-and-reverted record, or get cleaned up before merging.
---
## What Bug B actually fixed (slice 1, shipped)
### The defect
Indoor cell BSP queries at `TransitionTypes.cs:1442` invoked `BSPQuery.FindCollisions` with `Quaternion.Identity` + defaulted `Vector3.Zero` for `worldOrigin`. Inside the BSP, Path 3 (`step_sphere_down`) and Path 4 (land-on-surface) use those args via `TransformVertices` + `BuildWorldPlane` to produce a world-space ContactPlane. With both args defaulted, the produced plane was in cell-LOCAL space — `D ≈ 0` instead of `D = -world_floor_Z` (e.g., `-94.02` for Holtburg cottages).
### The fix (`de8ffde`)
```csharp
Quaternion cellRotation;
Vector3 cellOrigin;
if (!Matrix4x4.Decompose(cellPhysics.WorldTransform, out _, out cellRotation, out cellOrigin))
{
Console.WriteLine($"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose ...");
cellRotation = Quaternion.Identity;
cellOrigin = cellPhysics.WorldTransform.Translation;
}
var cellState = BSPQuery.FindCollisions(
cellPhysics.BSP.Root, cellPhysics.Resolved, this,
localSphere, localSphere1, localCurrCenter,
Vector3.UnitZ, 1.0f,
cellRotation,
engine,
worldOrigin: cellOrigin);
```
Mirrors the existing correct pattern at `TransitionTypes.cs:1808` (object BSP via `FindObjCollisions`).
### Evidence (probe-driven)
Pre-fix (`launch-cp-probe.log`): 320 `[cp-write] caller=BSPQuery.StepSphereDown:1123` writes producing `D=-0.000` instead of `D=-94.020`.
Post-fix (`launch-cp-probe-postfix-v2.log`): step-down writes show `D=-94.020`, `D=-66.020`, `D=-158.994`, `D=-159.129` — all matching the cell's actual world floor Z. The 2 remaining `D=0.000` outliers are either polygons legitimately at world Z=0 or marginal edge cases.
### Tests
- Unit test added: `BSPQueryTests.FindCollisions_StepDown_TranslatedWorldOrigin_WritesWorldSpacePlane` — verifies BSPQuery writes world-space CP when called with a translated worldOrigin.
- 8-failure physics baseline holds (no new regressions).
### Recommendation
**Ship Bug B alone.** The Bug A spec/plan + revert can stay or get cleaned. The probe (`66de00d`) is worth keeping in tree until the deeper investigation is complete.
---
## What Bug A tried and why it failed (slice 2, reverted)
### The hypothesis
Per the previous handoff (`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`) and the subagent's first decomp study, retail's `BSPTREE::find_collisions` does NOT call `find_walkable` on the OK path. ContactPlane is **retained** across OK frames from the prior tick's seed (our equivalent: `PhysicsEngine.ResolveWithTransition:583`, the `init_contact_plane` analogue). The synthesis we added in Phase 2 (`eb0f772` 2026-05-19) was an unfaithful stop-gap that runs every frame, 99.87% MISSES due to tangent epsilon rejection in `walkable_hits_sphere`, and falls through to outdoor terrain → wrong CP plane.
**Proposed fix:** delete the synthesis call + outdoor fallthrough from the indoor OK path. Just `return TransitionState.OK;` after the indoor BSP returns OK. Let CP retain via the seed and let BSP Path 3/4 refresh it during step-down or landing.
### The fix (`9f874f4`)
Replaced `TryFindIndoorWalkablePlane(...) → ValidateWalkable(...) → fallthrough to outdoor terrain` with `return TransitionState.OK;`. Deleted the method + constant + 9 tests. -491 lines.
### The regression
User report: "I could not get out of the building, I had to jump out of the door, then I fell through the ground."
Probe data (`launch-buga-v2.log`):
```
[indoor-bsp] cell=0xA9B40125 wpos=(96.880,159.403,61.536) result=OK
[indoor-bsp] cell=0xA9B40125 wpos=(96.800,159.603,61.336) result=OK
[indoor-bsp] cell=0xA9B40125 wpos=(96.720,159.803,61.130) result=OK
...continues until wpos=(67,233,-262) ~350m below cell floor
```
The player's Z decreased ~0.2m per tick (gravity step), inside an indoor cell, with the BSP returning OK every frame (no walls below them). No step-down probe lines firing during the fall — `oi.Contact` had cleared.
### Why Mechanisms A/B/C didn't catch this
The full decomp study (in this session's subagent transcript, file
`C:\Users\erikn\AppData\Local\Temp\claude\C--Users-erikn-source-repos-acdream--claude-worktrees-sad-aryabhata-2d2479\cd9bbcf4-a861-4797-99e3-8c1c623ff66e\tasks\a88c5ab14446853ea.output`)
mapped retail's three CP retention mechanisms:
| | Retail location | acdream location | Status |
|---|---|---|---|
| **A** — Path 6 collide-path land (`set_contact_plane`) | `acclient_2013_pseudo_c.txt:323924` | `BSPQuery.cs:1615` (Path 4) | Present, works |
| **B**`validate_transition` LKCP proximity restore | `:272565-272578` | `TransitionTypes.cs:2618-2662` | Present, has proximity-guard |
| **C**`transitional_insert` post-OK step-down probe | `:273242-273307` | `TransitionTypes.cs:896-933` | Present, gated on `oi.Contact && !ci.ContactPlaneValid && oi.StepDown` |
All three exist in our code. The failure was that they're **all gated on conditions that fail in the doorway-crossing case**:
1. Step-down probe (Mech C) fires correctly: log shows ~209 successful Adjusted results from BSPQuery.StepSphereDown.
2. Player walks toward the cottage doorway. Sub-step moves `lpos.Y` from -5.994 to -6.398 (past the cottage floor edge).
3. At new position, step-down probe BSP returns OK + `poly=n/a` (no floor poly at this XY) — same for Z probes at -0.75, -1.5, -2.25. The cottage's indoor cell has no floor poly extending past the doorway threshold.
4. Step-down returns OK without writing CP. `ci.ContactPlaneValid` stays false.
5. Mechanism B (LKCP proximity) checks distance from sphere to cached plane: sphere moved ~0.4m laterally, the prior plane is at the prior XY, but the proximity check is `radius + EPSILON > |angle|` where `angle = N·sphere + D`. For a horizontal floor, `angle = sphere.Z - cached_floor_Z`. If sphere.Z hasn't moved much vertically, this should pass...
- **Actually:** I didn't fully trace this. Mech B might fire correctly. Need next-session probe to confirm.
6. Either way: by the time the player has traveled a few sub-steps with no floor underneath, `oi.Contact` clears via the ValidateTransition else-branch (line 2664-2666). Mech C stops firing (it requires `oi.Contact`).
7. Player free-falls. Path 5 stops firing (no Contact). Path 6 fires for airborne movement. No CP gets re-established.
### Why the previous "stuck-falling on 2nd-floor edge" symptom is the same bug
The user's PRIOR symptom (pre-Bug-A): "Walking up the stairs, if I sort of just touch the floor on top of me I get stuck in falling animation."
That's the same root cause manifesting in a different geometry: step-down probe doesn't find a floor poly at the 2nd-floor edge → Mechanism C can't catch → some path along the synthesis → wrong CP → ValidateWalkable marks airborne → falling animation never recovers.
The Phase 2 synthesis (TryFindIndoorWalkablePlane) was a duct-tape over this — it tried to find a "best-guess" floor poly via XY scan. When the scan returned the wrong poly (rare HIT case) or missed (99% case) the player got stuck. But it didn't make them free-fall through the void because the fallthrough to outdoor terrain at least gave them SOMETHING (just slightly below the cottage floor).
Bug A removed the duct-tape. With nothing replacing it, the player free-falls.
### Key insight: the duct-tape was hiding a deeper bug
The Phase 2 synthesis (`eb0f772`) was patching over a real defect: **indoor cell floor polygons don't extend to cover the player's full possible XY range when crossing thresholds.** Either:
- **(a)** Retail's indoor cells have floor polys that extend further than ours do (dat-decoder bug?).
- **(b)** Retail's cell-transition timing moves the player into the outdoor cell BEFORE they step past the indoor floor poly edge, so the indoor BSP query at the threshold always has a floor under the sphere.
- **(c)** Retail has a mechanism we haven't found yet that handles "no floor poly at this XY" gracefully (e.g., extending the search to neighbor cells via portals).
- **(d)** Retail's player-collision-sphere is sized differently so the player physically can't reach the edge of the cottage floor.
Without further investigation, I can't say which. The next session needs to figure this out.
---
## State of the [cp-write] probe
Committed at `66de00d`. Converts 8 `CollisionInfo` fields (CP + LKCP groups, 4 sub-fields each) from public fields to public properties with logging setters. Logging is gated on `PhysicsDiagnostics.ProbeContactPlaneEnabled` (env var `ACDREAM_PROBE_CONTACT_PLANE=1`, also runtime-toggleable). When the flag is off, the property accessors are inlined to direct field access by the JIT — zero cost.
**Keep this in tree** — the next session will need it to validate any new hypothesis before designing the fix. The Bug B + Bug A specs both say "remove the probe when the retention fix lands"; that's not yet, defer the removal.
The probe surfaces:
- Each write site's source line (`PhysicsEngine.cs:583`, `BSPQuery.cs:1123`, `BSPQuery.cs:1615`, `TransitionTypes.cs:663` etc).
- Old value → new value, only logged when actually changed (value-equality suppression in the setter).
- Plane Normal + D, CellId, IsWater, Valid flags.
Caller distribution from the failed Bug A run is in `launch-buga-v2.utf8.log`:
| Count | Caller | Role |
|---|---|---|
| 57,144 | `PhysicsEngine.ResolveWithTransition:583` | Per-tick seed (`init_contact_plane`) |
| 607 | `Transition.FindTransitionalPosition:663` | Sub-step CPV=0 reset |
| 341 | `Transition.ValidateWalkable:1488` | Outdoor terrain on-surface |
| 217 | `BSPQuery.StepSphereDown:1123` | Path 3 step-down (Mechanism C fires) |
| 19 | `Transition.ValidateWalkable:1511` | Outdoor terrain below-surface |
| 0 | `Transition.ValidateWalkable` (indoor) | Bug A removed the indoor path |
| 0 | `[indoor-walkable]` lines | Bug A removed the probe |
---
## Investigation targets for next session
If picking this up, the priority order:
1. **Confirm the doorway-edge hypothesis with cdb on retail.** The retail debugger toolchain (CLAUDE.md "Retail debugger toolchain" section) lets us attach to a live retail client. Set a breakpoint at `BSPLEAF::find_walkable` and walk the same cottage threshold. Capture the polygons the floor BSP iterates over. Either:
- Retail's cell has more floor polys covering the threshold → our dat-decoder is missing some polys.
- Retail's cell-id changes BEFORE the sphere reaches the edge → our cell-transition timing lags.
- Retail does something we haven't seen yet.
2. **Cross-reference with WorldBuilder.** The CLAUDE.md "Reference hierarchy by domain" table says WB is the production base for EnvCell geometry. Look at `WorldBuilder/EnvCellRenderManager.cs` and `WorldBuilder/PortalRenderManager.cs` for how WB handles cell boundaries.
3. **Add a probe that logs each indoor cell's floor poly count + extent.** Diagnostic-only. When the player enters an indoor cell, dump the cell's floor polys + their XY bounding boxes. Compare to the player's eventual XY position when step-down misses. Tells us whether the floor poly genuinely doesn't extend that far OR whether something else is wrong.
4. **Look at Phase 2 cell-transition work.** The `[cell-transit]` probe + the portal-graph traversal in `CellTransit.FindCellList` were shipped 2026-05-19 (commits `1969c55` through `eb0f772`). Whether they fire in time at the cottage doorway is unclear.
5. **Don't repeat the Bug A approach.** "Just delete the synthesis and trust BSP" doesn't work because the BSP genuinely has no floor poly at the threshold. Some replacement is needed — the question is what.
### Anti-patterns the next session should avoid
- **Don't trust the previous handoff's recommendation blindly.** The 2026-05-19 handoff said "remove TryFindIndoorWalkablePlane" — that recommendation was based on incomplete decomp analysis. The proper fix requires understanding cell geometry, not just CP retention.
- **Don't design a fix before the probe data points at the right code path.** I designed Bug A's spec on a "Mechanism C will catch us" assumption that the data didn't validate.
- **Don't fix two related bugs in one session.** Bug B + Bug A were both indoor-CP issues but they had different root causes. Slicing them was the right call; what went wrong was Bug A's design.
### Things to definitely KEEP from today's work
- Bug B fix (`de8ffde`) — closes a real corruption.
- The `[cp-write]` probe (`66de00d`).
- The `[indoor-bsp]` probe (pre-existing, from Phase 1).
- BSPQuery regression test (`39d4e65`).
- Spec + plan docs for Bug B (good engineering artifacts).
- This handoff doc.
### Things to consider removing on next session
- Bug A spec/plan docs (3bec18f / 686f27f) — they document a wrong approach. Optional to delete; they're useful as a "tried this, didn't work, here's why" record.
---
## How to start a fresh session
Copy this into a new Claude Code session in the acdream worktree:
```
Pick up the acdream indoor walking issue (ISSUES #83). Read
docs/research/2026-05-20-indoor-walking-bug-a-handoff.md FIRST. The
prior session today shipped Bug B (BSP world-origin fix, de8ffde) but
attempted-and-reverted Bug A. The real bug is deeper than scoped — see
the handoff for the full diagnosis and investigation targets.
1. Don't try Bug A again ("just delete TryFindIndoorWalkablePlane and
trust retention"). That was today's wrong approach; data showed
Mechanism C can't catch when there's no floor poly past the
threshold.
2. The probe (66de00d) + [indoor-bsp] probe should stay in tree until
the proper fix lands.
3. Investigation targets are in the handoff's "Investigation targets
for next session" section. The most useful first move is probably
attaching cdb to retail at the same cottage threshold and watching
what BSPLEAF::find_walkable iterates over.
4. CLAUDE.md rules apply. No workarounds, no band-aids. Visual
verification is the acceptance test.
5. M2 critical path candidates remain (F.2 / F.3 / F.5a / L.1c /
L.1b). If this investigation looks like it'll burn a phase or two
to nail down, consider whether the user wants you to pivot to M2
work and address indoor walking in M7 polish.
State the milestone + chosen phase in the first action you take.
```
Or just say "Read docs/research/2026-05-20-indoor-walking-bug-a-handoff.md and start a fresh session."
---
## Lessons from today (for future Claude)
1. **The user's pickup-prompt language was right: probe-first, design-second.** I did the probe spike for Bug B — that worked great. For Bug A I didn't do an equivalent spike for "will Mechanism C catch the no-floor case?" before deleting the synthesis. The R1 risk I called out in the spec was the actual failure mode.
2. **A spec's "Out of scope" + "Risks" sections can lie.** I wrote them after the design was decided, and they reflected the design's blind spots, not actual blind spots. Next time: list risks BEFORE writing the design, treat them as falsification tests, validate them with the probe before shipping.
3. **"Three failed visual verifications in a session" is the stop signal.** I got to two and pushed for a third (which triggered the revert decision via the user's "Got stuck falling in the staircase" report + "I had to jump out of the door, then I fell through the ground" report). The third should have been the trigger to stop and write the handoff — instead I dispatched another subagent and dug deeper. That additional dig was useful (it surfaced the doorway-edge insight) but it would have happened in the fresh session too with a fresher context budget.
4. **`Matrix4x4.Decompose` works fine for cell transforms.** Bug B's mechanical fix landed cleanly. The pattern (decompose once at the call site, pass rotation + origin to a function that previously took defaults) is a clean idiom for places where we have a Matrix and the API wants a Quaternion + Vector3.
5. **Test build + binary timestamp paranoia is real.** During Bug B's first visual verification, my test passed but I'd accidentally rebuilt the AcDream.Core DLL from un-stashed code, so the launched client didn't have the fix. The mismatch was only caught by checking the binary mtime against the source mtime. After every code change to be tested in the client, verify the build is fresh.
---
**Recommendation:** merge Bug B to main. Keep the rest of this branch around as a learning artifact. Start fresh on the deeper investigation in a new session with this handoff as the starting brief.

View file

@ -1,400 +0,0 @@
# M1.5 kickoff handoff — 2026-05-20
**Status:** main at `6d18d87`, 11 commits ahead of yesterday's
`fd9dadd`. 1147 + 8 baseline maintained throughout. Five surgical
indoor-physics fixes shipped + M1.5 milestone promoted. Holtburg
inn + cottage interiors visually verified.
**Pasteable session-start prompt at the bottom of this doc.**
## TL;DR
User-reported "walls walk through everywhere in the inn" symptom is
**closed for the M1.5 baseline** via five fixes:
1. **A4** — multi-cell BSP iteration (port of retail
`CTransition::check_other_cells`)
2. **#89** — sphere-overlap in `CheckBuildingTransit`
3. **#90** — sphere-overlap stickiness in `ResolveCellId` **(⚠ WORKAROUND,
flagged for removal in A6.P4)**
4. **#91** — indoor cell shadows in `FindObjCollisions`
5. **#92** — server cell id at player-mode entry
The visible symptom is gone. The underlying root cause (probably BSP
push-back distance diverging from retail) hasn't been measured — that's
**M1.5**, which was opened today and is now the active milestone.
**M2 ("Kill a drudge") is deferred** until M1.5 lands. Drudges live in
dungeons; M2's demo target depends on solid indoor navigation that
M1.5 delivers.
## State both altitudes
> **Currently working toward: M1.5 — "Indoor world feels right."**
>
> **Current phase: A6 — Indoor physics fidelity (cdb-driven).**
>
> **Next concrete step: A6 spec authoring (brainstorm → write-plan).
> Then A6.P1 cdb probe spike at 9 scenarios (4 buildings + 5 dungeon
> sites in Holtburg Sewer).**
## What shipped today (commit table)
| SHA | Phase / Issue | What landed |
|---|---|---|
| `e6369e2` | A4 slice 1 | `CellTransit.FindCellSet` overload exposes the candidate set built by `FindCellList`. 3 unit tests. Refactor-only, no behavior change to existing callers. |
| `493c5e5` | A4 slice 2 | `Transition.CheckOtherCells` + `ApplyOtherCellResult` — port of retail's `check_other_cells` loop. 6 unit tests. Method exists but is not yet called from production code. |
| `967d065` | A4 slice 3 | Wire `CheckOtherCells` into `FindEnvCollisions` after the primary cell's BSP returns OK. 1 integration test. |
| `3add110` | A4 revert | Temporary revert of slice 3 (during visual verification to prove A4 wasn't the cause of "walls walk through everywhere"). |
| `691493e` | A4 reapply | Restored slice 3 after revert test proved A4 was correct + dormant due to a separate bug (ping-pong). |
| `1534990` | docs | Initial A4 ship + #90 ping-pong filed as separate issue. |
| `4ca3596` | #90 ⚠ WORKAROUND | `BSPQuery.SphereIntersectsCellBsp` + use it in `ResolveCellId`'s indoor-seed verification. Sphere-overlap stickiness prevents flip-out on push-back. **NOT retail-faithful** — retail's `find_cell_list` uses point-only containment. Flagged for removal in A6.P4 once the root cause (probably BSP push-back distance) is fixed. |
| `c0d8405` | #91 | `ShadowObjectRegistry.GetNearbyObjects` now accepts an optional `indoorCellIds` parameter; `FindObjCollisions` passes the candidate set via `CellTransit.FindCellSet`. Closes "interior items don't block" (regression from A1.5's interior-cell shadow scoping). |
| `7ac8f54` | #89 | `CellTransit.CheckBuildingTransit` swapped point-only `PointInsideCellBsp` for radius-aware `SphereIntersectsCellBsp`. Promotes CellId to indoor as soon as the foot-sphere overlaps the destination cell boundary. Retail-faithful — direct port of `CCellStruct::sphere_intersects_cell`. |
| `23ab173` | #92 | `GameWindow.EnterPlayerModeNow` now uses `spawn.Position.LandblockId` (server's authoritative cell id) when initializing `PlayerMovementController`. Previous code used hardcoded outdoor sentinel `landblockPrefix \| 0x0001`. Closes "login-inside-inn ran through exterior walls until I re-entered." |
| `6d18d87` | M1.5 promotion | `docs/plans/2026-05-12-milestones.md` (M1.5 block inserted), `docs/plans/2026-04-11-roadmap.md` (A6 + A7 phases), `CLAUDE.md` (currently-working-toward + baseline paragraph), `docs/ISSUES.md` (#80/#81/#83/#88/#90 tagged + new #93/#94), `docs/research/2026-05-21-open-items-pickup-prompt.md` (landscape table). |
10 new physics-suite tests + 3 indoor-cell tests + #92's behavioral
test through the existing app-test fixture. **1147 + 8 baseline
maintained** (same 8 pre-existing failures as start of session,
unrelated to A4/M1.5 work).
## Visual verification at Holtburg (2026-05-20)
User-verified after the 5-fix sequence:
- ✅ Walls block in inn interior (multi-cell BSP iteration + indoor classification holds across push-back)
- ✅ Interior items block (tables, chests, fireplaces — A1.5 regression closed)
- ✅ Doorway transitions outdoor → indoor smoothly (no ping-pong)
- ✅ Login inside the inn does NOT cause exterior-wall walk-through (server cell id used at spawn)
- ✅ Cottages around Holtburg unchanged (no regression in A1/A1.5/A1.6/A1.7)
## What's still broken (M1.5 in-scope)
Per `docs/ISSUES.md` (tagged "M1.5 scope" as of today):
### Physics (A6)
- **#83** — Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck). Umbrella issue, open since 2026-05-19. M1.5 primary.
- **Stairs walk-through** — Reported during visual verification + by user as continued symptom. Subsumed by #83.
- **2nd-floor walking / cellar descent** — Reported by user. Subsumed by #83.
- **#88** — Indoor static objects vibrate. Suspected sub-step state corruption family.
- **#90** — CellId ping-pong (workaround in place; A6.P4 removes it once root cause is fixed).
- **`TryFindIndoorWalkablePlane`** — Per-frame CP synthesis (99.87% MISS rate per 2026-05-21 walk-miss probe data). Retail retains CP via Mechanisms A/B/C; we synthesize per-frame. A6.P4 deletes it.
### Lighting (A7)
- **#80** — Camera on 2nd floor goes very dark.
- **#81** — Static building stabs don't react to atmospheric lighting.
- **#93 (new)** — Indoor lighting broken umbrella. M1.5 primary.
- **#94 (new)** — Held items project spotlight on walls.
## Workarounds in tree — must remove during A6.P4
Two known unfaithful workarounds shipped or retained today:
### 1. #90 — Sphere-overlap stickiness in `PhysicsEngine.ResolveCellId`
**Location:** `src/AcDream.Core/Physics/PhysicsEngine.cs:285-300`. Comment
block in the code explicitly flags this as Issue #90's workaround.
**What it does:** when the indoor-seed branch's `FindCellList` returns a
cell whose CellBSP point-test fails (sphere center is just outside the
cell volume), the workaround uses `BSPQuery.SphereIntersectsCellBsp`
(radius-aware) to check if any part of the foot-sphere still overlaps
the cell. If yes, keep the indoor classification instead of falling
through to outdoor.
**Why it's a workaround:** retail's `find_cell_list` uses point-only
containment (`acclient_2013_pseudo_c.txt:308810` calls
`point_in_cell` via vtable +0x84). Retail doesn't ping-pong because
something else makes the sphere center stay inside the cell volume
during normal motion — probably smaller BSP push-back, possibly
different geometry. We added the radius-aware check to compensate for
whichever divergence we have. **Don't keep this code long-term.**
**A6.P4 removal criteria:** once A6.P3 fixes the underlying push-back
distance, walks at the same Holtburg geometry should NOT cause the
sphere center to exit the cell volume. Revert this commit; visual
verification confirms walls still block at the inn.
### 2. `Transition.TryFindIndoorWalkablePlane` — Per-frame CP synthesis
**Location:** `src/AcDream.Core/Physics/TransitionTypes.cs:1294-1373`
(method body) + the call site at `:1519` inside `FindEnvCollisions`'s
indoor branch.
**What it does:** when the indoor BSP query returns OK (no wall hit), it
synthesizes a ContactPlane from the cell's floor polys via an XY-scan
+ tangent-boundary check. 99.87% of synthesis attempts MISS (per
`launch-walk-miss-capture-findings.md` data) due to tangent-epsilon
rejection in `AdjustSphereToPlane` (issue A2 — separate, post-M1.5).
**Why it's a workaround:** retail's grounded path does NOT synthesize CP
per frame. Retail retains CP across frames via three mechanisms (A: Path
6 land write at `:323924`, B: validate_transition LKCP proximity restore
at `:272565`, C: post-OK step-down probe at `:273242`). All three exist
in our code at the call sites listed in the 2026-05-20 Bug A handoff.
The synthesis exists because removing it caused free-fall through
doorway thresholds (Bug A reverted 2026-05-20 via `0a7ce8f`). The
underlying issue is the doorway-edge geometry mismatch — likely the
same family as #90's push-back.
**A6.P4 removal criteria:** once A6.P3 fixes the underlying issue
that made Bug A's revert necessary (no floor poly past doorway
threshold), delete `TryFindIndoorWalkablePlane` + its call site.
Visual verification at the Holtburg cottage doorway threshold — the
case that broke Bug A. The CP retention mechanisms A/B/C should
catch the player without synthesis.
## M1.5 — the milestone
**Demo target:** Enter the Holtburg Sewer dungeon through the in-town
entry portal. Navigate to the end (57 rooms with stairs + a multi-Z
chamber). Exit back to town. Throughout the walk:
- Walls block (no walk-through anywhere, indoor or stab-shell).
- Stairs work (ascend + descend without falling through or stuck).
- Items block (sarcophagi, urns, decorations, tables, chests, fireplaces).
- Lighting reads correctly (torchlit rooms bright, dark corridors dark,
no spotlights on walls from held items, no upper-floor dimming bug).
- Cell transitions are smooth (no ping-pong, no CellId flicker).
**Phases:**
- **A6 — Indoor physics fidelity (cdb-driven).** Sub-slices A6.P1
(probe spike), A6.P2 (analysis), A6.P3 (fixes), A6.P4 (workaround
removal). ~911 days.
- **A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven).**
Sub-slices A7.L1, A7.L2, A7.L3. ~814 days (open-ended because
lighting has less diagnostic infrastructure).
**Estimated timeline:** 1726 days focused work / 35 weeks calendar.
## A6.P1 — the cdb probe spike
**Reads first:**
- `CLAUDE.md` § "Retail debugger toolchain (live runtime trace)" — full setup, watchouts.
- `docs/plans/2026-04-11-roadmap.md` § "Phase A6 — Indoor physics fidelity" for the slice list.
- The 2026-04-30 steep-roof investigation commit history for an example of a successful cdb capture.
**Methodology:**
1. Verify retail binary matches our PDB:
```bash
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"
```
Expect: `=== MATCH ===`.
2. Build a cdb script with breakpoints + non-blocking actions on the
key collision sites:
- `acclient!CTransition::transitional_insert` — outer sub-step loop
- `acclient!CTransition::step_up` — Path 5 step-up corrective adjustment
- `acclient!SPHEREPATH::set_collide` — wall-collision halt
- `acclient!BSPTREE::step_sphere_up` / `step_sphere_down` — BSP path branches
- `acclient!BSPTREE::find_collisions` — entry point
- `acclient!CTransition::validate_walkable` — ground-plane verdict
- `acclient!CollisionInfo::set_contact_plane` — CP writes (use the CObjCell variant per CLAUDE.md symbol-naming caveat)
3. Each breakpoint logs `dt acclient!CTransition @ecx` for the relevant
struct fields, then `gc` (go continue). Auto-detach after a hit
threshold via `qd` to avoid manual cleanup.
4. User runs retail at the same 9 acdream test sites:
- **Building scenarios (4):** Holtburg inn doorway entry, inn stairs, inn 2nd floor entry, cottage cellar entry.
- **Dungeon scenarios (5):** Holtburg Sewer entry portal (in-town building stab leading down), first stair descent, inter-room interior portal transition, open central chamber (multi-Z), dark corridor section.
5. Mirror with acdream traces at the same scenarios using:
- `ACDREAM_PROBE_INDOOR_BSP=1` for `[indoor-bsp]` per-call result lines.
- `ACDREAM_PROBE_CELL=1` for `[cell-transit]`.
- `ACDREAM_PROBE_CONTACT_PLANE=1` for `[cp-write]`.
- New `[push-back]` probe (build during A6.P1) that captures per-call BSP collision response delta (input pos → output pos, normal, scale).
6. Analysis (A6.P2): line up retail vs acdream per scenario. Compute
the per-sub-step push-back delta in each system. Identify systematic
differences. Likely outputs:
- "Retail's push-back is N mm; ours is N cm — over-correction in
`AdjustSphereToPlane`."
- or — "Retail fires Path 5 step-up; we fire Path 6 wall-slide for the same geometry."
- or — "Our sub-step state mutation leaves a stale CP between cells."
**Output of A6.P1:** a `docs/research/<date>-a6-cdb-capture-findings.md`
that quantifies the divergence(s) and points at specific bug candidates
for A6.P3.
## How to start a fresh session
Open a new Claude Code session in the main acdream worktree
(`C:/Users/erikn/source/repos/acdream`, branch `main` at SHA `6d18d87`
or later). Then paste:
---
```
Pick up the M1.5 milestone work. Read
docs/research/2026-05-20-m15-kickoff-handoff.md FIRST. M1.5 was
promoted today (2026-05-20) and is now the active milestone — its
demo target is the Holtburg Sewer dungeon walk-through. Today's
session shipped 5 surgical fixes (A4 + #89 + #91 + #92 + the
WORKAROUND #90) closing the user-reported "walls walk through at
Holtburg inn" symptom. The proper root-cause fix is the actual M1.5
work.
State both altitudes at session start:
Currently working toward: M1.5 — "Indoor world feels right."
Current phase: A6 — Indoor physics fidelity (cdb-driven).
Next concrete step: brainstorm + spec + plan A6 (including A6.P1
cdb probe spike).
1. Read docs/research/2026-05-20-m15-kickoff-handoff.md (this doc).
Then docs/plans/2026-05-12-milestones.md M1.5 block. Then
docs/plans/2026-04-11-roadmap.md M1.5 entry (top of "Phases ahead").
2. The 5 fixes shipped today are merged to main at 6d18d87. Don't
revisit them. 1147 + 8 baseline holds. #90 is a workaround
flagged in the code; do NOT remove it casually — A6.P4 removes
it after the root-cause fix lands in A6.P3.
3. **Set up isolation FIRST.** Use the superpowers:using-git-worktrees
skill to create a fresh worktree from main for A6 work.
4. The next phase to design + ship is **A6 (Indoor physics fidelity,
cdb-driven)**. Sub-slices outlined in the roadmap. Start by:
- Using superpowers:brainstorming to design A6.
- Using superpowers:writing-plans to plan A6.P1 (cdb probe spike).
- Executing A6.P1 with the user supplying retail-client time
at the 9 scenarios.
5. cdb toolchain is documented in CLAUDE.md § "Retail debugger
toolchain (live runtime trace)." Used successfully 2026-04-30
for the steep-roof case. Matching binaries (acclient.exe v11.4186)
+ PDB present.
6. CLAUDE.md rules apply:
- No workarounds without explicit approval. (Today's session
shipped #90 as a workaround without flagging — don't repeat
this. A6.P3 fixes the root cause, A6.P4 removes #90.)
- Probe-first, design-second. A6.P1 IS the probe spike.
- Visual verification at the Holtburg Sewer dungeon is the M1.5
acceptance test.
- Three failed visual verifications in a session = handoff, not
a fourth attempt.
7. A7 (Indoor lighting fidelity) follows A6 once physics is solid.
Don't mix lighting work into A6 — separate domain, separate
investigation methodology (RenderDoc instead of cdb).
8. Launch command (light probes only):
$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"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
dotnet build -c Debug
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch.log"
DO NOT set ACDREAM_PROBE_RESOLVE — 400k+ lines at 30Hz, lags the
client.
```
---
## Anti-patterns from today's session
1. **Don't ship workarounds without flagging them upfront.** #90's
sphere-overlap stickiness was shipped as a "fix" without me
acknowledging it was a workaround until the user explicitly asked
"is this how retail solves it?" CLAUDE.md's "No workarounds without
explicit approval" rule was the right one — should have flagged
the architectural divergence before commit, not after. Doing so
would have led to scope-promoting M1.5 hours earlier and avoided
committing a workaround as part of the M1.5 baseline.
2. **Don't trust "visual verification works" as proof of correctness.**
The user reported "walls block now" after #90. Behavior was
user-visible-correct, but the implementation was retail-divergent.
Visual verification is necessary but not sufficient. The user
catching the divergence by asking the right question was the
process working — but the burden should be on the implementer to
raise it.
3. **Don't conflate "issue is fixed" with "root cause is understood."**
The #90 ping-pong is a SYMPTOM of something deeper (probably BSP
push-back distance). Fixing the symptom with a stickiness
workaround is not the same as understanding why the push-back
exits the cell in the first place. Conflating these leads to
stacking workarounds.
4. **Don't dismiss user-reported symptoms as "you didn't enter the
inn."** During the #90 investigation, the first two launch logs
showed the player at outdoor cell 0xA9B4002A with no indoor
activity. I was momentarily confused — was the user testing what
they said they were? Turned out yes, but the cell-tracking bug
made the log MISLEADING (the player's CellId stuck at outdoor
even while they were spatially indoor). Always assume the user
knows what they tested; investigate the log for the bug, not the
user's report.
5. **Don't underestimate scope of "indoor world feels right."** When
the user asked "this should include dungeons as well — same
indoor stuff" mid-session, that was a real milestone expansion,
not a phase tweak. Promoting to M1.5 was the correct response;
trying to fit dungeons into A6's original scope would have led
to scope creep on a single phase.
## Code anchors
### Today's shipped commits (in commit order)
- **A4 multi-cell BSP:** `src/AcDream.Core/Physics/CellTransit.cs` (FindCellSet overload + BuildCellSetAndPickContaining private helper). `src/AcDream.Core/Physics/TransitionTypes.cs:1380-1486` (CheckOtherCells + ApplyOtherCellResult). `src/AcDream.Core/Physics/TransitionTypes.cs:1614-1631` (wire-up in FindEnvCollisions).
- **#89 sphere-overlap CheckBuildingTransit:** `src/AcDream.Core/Physics/CellTransit.cs:179-218` (CheckBuildingTransit body) + `src/AcDream.Core/Physics/BSPQuery.cs:965-1003` (SphereIntersectsCellBsp).
- **#90 WORKAROUND stickiness:** `src/AcDream.Core/Physics/PhysicsEngine.cs:285-300` (the comment block flagging the workaround) + `BSPQuery.SphereIntersectsCellBsp` (shared with #89).
- **#91 indoor cell shadows:** `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:251-269` (indoorCellIds branch in GetNearbyObjects) + `src/AcDream.Core/Physics/TransitionTypes.cs:1913-1935` (FindObjCollisions plumbs the candidate set).
- **#92 server cell id:** `src/AcDream.App/Rendering/GameWindow.cs:10109-10135` (EnterPlayerModeNow uses spawn.Position.LandblockId).
### Tests added
- `tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs` (3 tests).
- `tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs` (6 tests).
- `tests/AcDream.Core.Tests/Physics/FindEnvCollisionsMultiCellTests.cs` (1 integration test).
- `tests/AcDream.Core.Tests/Physics/SphereIntersectsCellBspTests.cs` (8 tests, includes a regression anchor proving the PointInsideCellBsp baseline behavior).
### Specs + plans archived
- `docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md` (spec)
- `docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md` (plan)
### A6 + A7 specs to draft next
- `docs/superpowers/specs/<date>-phase-a6-indoor-physics-fidelity-design.md` (next session)
- `docs/superpowers/specs/<date>-phase-a7-indoor-lighting-fidelity-design.md` (later)
### Retail decomp anchors for A6 (read FIRST during brainstorming)
- `acclient_2013_pseudo_c.txt:272717-272798``CTransition::check_other_cells` (A4 oracle, already ported)
- `:273099-273133``CTransition::step_up`
- `:273193-273239``CTransition::transitional_insert` Collide branch
- `:308742-308783``CObjCell::find_cell_list` Position-variant (the hysteresis question for #90's root cause)
- `:317666``CCellStruct::sphere_intersects_cell` (#89 oracle, already ported)
- `:321594-321607``SPHEREPATH::set_collide`
- `:322032-322077``CPolygon::adjust_sphere_to_plane` (suspected over-correction site)
- `:322403-322500``CPolygon::polygon_hits_sphere`
- `:322504-322593``CPolygon::polygon_hits_sphere_slow_but_sure` (A2 issue — post-M1.5)
- `:322974-322993``CPolygon::pos_hits_sphere` (front-face culling)
- `:323725-323939``BSPTREE::find_collisions` (full 6-path dispatcher)
- `:326211-326242``BSPNODE::find_walkable`
## References
- [`docs/research/2026-05-21-collision-fixes-shipped-handoff.md`](2026-05-21-collision-fixes-shipped-handoff.md) — yesterday's handoff (A1/A1.5/A1.6/A1.7 + probe spike)
- [`docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md`](2026-05-20-phase-a4-shipped-cell-pingpong-finding.md) — earlier handoff from today (A4 ship + #90 ping-pong investigation, written BEFORE #90 workaround was added)
- [`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`](2026-05-20-indoor-walking-bug-a-handoff.md) — Bug A's tried-and-reverted story (the synthesis removal that A6.P4 will retry)
- [`docs/research/2026-05-21-walk-miss-capture-findings.md`](2026-05-21-walk-miss-capture-findings.md) — the synthesis 99.87% MISS rate evidence
- [`docs/plans/2026-05-12-milestones.md`](../plans/2026-05-12-milestones.md) — M1.5 block
- [`docs/plans/2026-04-11-roadmap.md`](../plans/2026-04-11-roadmap.md) — A6 + A7 detailed phases

View file

@ -1,218 +0,0 @@
# Phase A4 shipped + cell-tracking ping-pong finding — 2026-05-20
**Status:** A4 (multi-cell BSP iteration) shipped in 3 commits + 1 revert + 1 reapply
+ 1 doc. Build green, 1139 + 8 baseline failures (same as pre-A4 baseline).
A4 is **dormant in practice** because of a separate, pre-existing cell-tracking
bug at the inn doorway that prevents the player from stably remaining in an
indoor cell.
## TL;DR
- A4 ports retail's `CTransition::check_other_cells` (`acclient_2013_pseudo_c.txt:272717-272798`).
After the primary cell's BSP returns OK, every other cell the foot-sphere overlaps
is queried via `BSPQuery.FindCollisions`. Halt on first
Collided/Adjusted/Slid; Slid clears the contact-plane fields. Matches retail
exactly.
- 10 new unit tests pass; full test suite holds at the prior 8-failure baseline.
Three commits land the slices (FindCellSet overload → CheckOtherCells helper →
FindEnvCollisions wire-up).
- **Visual verification surfaced a different bug**: walking into the Holtburg
inn ping-pongs the player's CellId between indoor `0xA9B40164` and outdoor
`0xA9B40022` rapidly. Indoor BSP DOES detect walls (Collided / Adjusted /
Slid all fire on push-back), but the push-back moves the sphere outside the
indoor CellBSP's volume → `ResolveCellId` reclassifies the player as outdoor
→ next tick bypasses indoor BSP entirely → player advances freely → re-enters
→ repeats.
- Because the player never STAYS in an indoor cell, A4's multi-cell pass is
rarely (if ever) actually exercised in production. The user's reported
"walls walk through everywhere in the inn" reproduces fully with A4 wire-up
reverted, confirming A4 is not the cause.
## What shipped
| SHA | Phase | Description |
|---|---|---|
| `b100d54` | A4 spec | docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md |
| `a8a0366` | A4 plan | docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md |
| `e6369e2` | A4 slice 1 | `CellTransit.FindCellSet` overload + 3 unit tests |
| `493c5e5` | A4 slice 2 | `Transition.CheckOtherCells` + `ApplyOtherCellResult` + 6 unit tests |
| `967d065` | A4 slice 3 | Wire `CheckOtherCells` into `FindEnvCollisions` + 1 integration test |
| `3add110` | A4 revert | Temporary revert of slice 3 to confirm A4 wasn't the cause |
| `691493e` | A4 reapply | Restored slice 3 after revert test proved A4 not the cause |
Total: ~380 LOC added (3 new test files + helper methods); 1139 + 8 baseline
maintained throughout.
## Visual verification — what we tested
Launched twice with the light-probe set (`ACDREAM_PROBE_INDOOR_BSP`,
`ACDREAM_PROBE_CELL`, `ACDREAM_PROBE_CELL_CACHE`).
### Launch 1 — A4 wire-up active (`launch-a4.log`, 782 lines)
User walked from spawn toward the Holtburg inn. Log captured:
- Player CellId stayed at outdoor `0xA9B4002A` the entire session.
- 0 indoor-bsp probes fired.
- 0 other-cells probes fired (A4 wire-up only runs for indoor cells).
- User reported "all interior walls in the inn can be walked through; going
from indoor to outdoor broken."
But — A4 wire-up only fires when `cellLow >= 0x0100`. The player never reached
that state. So A4 couldn't possibly be the cause of the reported behavior.
### Launch 2 — A4 wire-up reverted (`launch-revert2.log`, 18490 lines)
User walked into and out of the inn multiple times. Log captured:
- 18 cell-transit events: outdoor cells `0xA9B40021` / `0xA9B40022` /
`0xA9B4002A` ping-ponging with indoor cell `0xA9B40164` (vestibule).
- 11 `[check-bldg] inside=True` events — player crossed the building threshold.
- 61 indoor-bsp queries against `0xA9B40164` (58) and `0xA9B40162` (3).
- Indoor BSP results: 40 OK + 7 Adjusted + 7 Collided + 7 Slid.
- User confirmed: "walls still walked through (same bug)" with A4 reverted.
**The bug reproduces with A4 reverted, proving A4 is not responsible.**
## The actual bug — cell-tracking ping-pong at doorway threshold
The repeating cycle observed in the revert log:
1. Player at outdoor cell `0xA9B40022`, walking toward inn door.
2. `CheckBuildingTransit` returns `inside=True` for portal to `0xA9B40164`.
3. ResolveCellId promotes CellId to `0xA9B40164`.
4. Next tick: indoor branch of FindEnvCollisions fires. BSP query against
`0xA9B40164`'s walls returns Adjusted/Collided/Slid. The sphere is pushed
back (Adjusted/Slid) or halted (Collided).
5. The push-back moves the sphere's world position BACK toward the outdoor
side, beyond the indoor CellBSP's volume.
6. ResolveCellId re-evaluates: indoor CellBSP no longer contains the sphere
center → falls through to outdoor resolution → returns `0xA9B40022`.
7. CellId flips back to outdoor. Next tick: indoor BSP not queried, player
keeps advancing.
8. Player re-crosses the building threshold → goto 2.
Net effect: the player visually moves through the doorway zone, walls
intermittently push them back, but most ticks classify them as OUTDOOR and
those ticks bypass wall collision entirely. The aggregate behavior LOOKS LIKE
"walls walk through" even though wall hits are firing.
### Why A4 doesn't help here
A4 multi-cell iteration only runs when the primary cell BSP returns OK. In the
ping-pong cycle, the primary cell BSP returns NON-OK (Collided/Adjusted/Slid)
on most indoor frames — so A4 short-circuits early at the existing `if (cellState
!= TransitionState.OK) return cellState;` path. A4 would help if the player
were STABLY indoor (cellLow >= 0x100) AND the primary cell's BSP had sparse
geometry that missed walls in adjacent cells. The ping-pong prevents both
conditions.
### Why this is a Bug A cousin
The 2026-05-20 Bug A investigation
([docs/research/2026-05-20-indoor-walking-bug-a-handoff.md](2026-05-20-indoor-walking-bug-a-handoff.md))
documented a similar doorway-edge problem: indoor cell floor polys don't
extend past the doorway threshold, causing free-fall when stepping out.
The current ping-pong is the same family of bug, different symptom: the
indoor CellBSP volume doesn't extend past the doorway threshold either, so
the push-back from a wall collision exits the cell's containment volume,
and the cell-id resolver bounces the player back to outdoor.
Hypothesis: the inn's vestibule cell `0xA9B40164` has a CellBSP that's tightly
bounded to the room's interior volume. The doorway threshold is right at the
boundary. Walking against an interior wall pushes the foot-sphere back toward
the boundary → exits CellBSP → outdoor classification.
## Next steps (not blocking A4 ship)
Two paths to investigate the ping-pong, both out of A4's scope:
1. **CellBSP-volume retention.** Match retail's behaviour: once a player enters
an indoor cell, don't flip back to outdoor until they cross the EXIT portal
plane, not just because they exited the CellBSP volume on a push-back.
Likely a `ResolveCellId` modification that prefers the previous indoor
classification when sphere is "close enough" to the indoor CellBSP volume.
2. **CellBSP-volume expansion.** Pad the indoor cell's CellBSP volume by the
sphere radius (~0.48m) on all sides. The push-back stays within the
padded volume. Risk: may incorrectly classify nearby outdoor positions as
indoor.
The retail oracle for cell-id stickiness is at
`acclient_2013_pseudo_c.txt:308742-308783` (`CObjCell::find_cell_list` Position-
variant) and the cell-array hysteresis logic around it. Not yet ported in
detail.
## Why ship A4 anyway
- **Correctness.** A4 matches retail's `check_other_cells` exactly. 10 unit
tests pin the halt semantics + integration test verifies the wire-up. Pure
port, no design improvisation.
- **No regressions.** 1139-passing + 8-pre-existing-failing baseline holds.
All A1 / A1.5 / A1.6 / A1.7 / Bug B fixes remain green.
- **Foundation for A3.** A3 (synthesis removal) is unblocked by A4 being in
place — it can rely on multi-cell BSP coverage for floor synthesis once the
ping-pong is fixed and players stay indoor long enough.
- **Reverting it would lose work.** A4 is correct and tested. The dormant
state is caused by an unrelated bug. Reverting would just make the
unrelated bug harder to investigate (no multi-cell foundation to build on).
## What this is NOT
This is **NOT** a fix for the user's "walls walk through" report. That bug is
pre-existing, caused by cell-tracking instability at doorway thresholds.
This is **NOT** a regression introduced by A4. The bug reproduces fully with
A4's wire-up reverted (verified by `launch-revert2.log`).
This is **NOT** the same as Bug A (synthesis removal). Bug A's symptom was
free-fall on doorway exit; this is wall walk-through due to CellId classification
flipping back to outdoor on each push-back.
## Code anchors
- Phase A4 wire-up: [src/AcDream.Core/Physics/TransitionTypes.cs:1614-1631](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1614).
- `CheckOtherCells` + `ApplyOtherCellResult`: TransitionTypes.cs (search `CheckOtherCells`).
- `FindCellSet` overload: [src/AcDream.Core/Physics/CellTransit.cs](../../src/AcDream.Core/Physics/CellTransit.cs) (search `FindCellSet`).
- ResolveCellId outdoor branch (where the ping-pong happens): [src/AcDream.Core/Physics/PhysicsEngine.cs:259-329](../../src/AcDream.Core/Physics/PhysicsEngine.cs#L259).
## Probe captures
- `launch-a4.log` (782 lines) — A4 active, player stayed outdoor (didn't reach
inn). Confirms A4's indoor branch never fired in that session.
- `launch-revert.log` (1.2M lines) — A4 reverted, player parked at outdoor cell
with 400K+ `[check-bldg]` probes all returning `inside=False`. Player never
moved.
- `launch-revert2.log` (18490 lines) — A4 reverted, player walked into inn
multiple times. Captured the ping-pong cycle. Indoor BSP results breakdown:
40 OK + 7 Adjusted + 7 Collided + 7 Slid. 11 `inside=True` building-transit
events.
## How to start a fresh session
Open a new Claude Code session, then:
```
Pick up the cell-tracking ping-pong investigation that blocked Phase A4
from being exercised in practice.
1. Read docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md
FIRST. It documents A4 ship (correct, dormant) + the ping-pong bug it
surfaced.
2. A4 is shipped (3 commits at e6369e2, 493c5e5, 691493e). Don't touch it.
1139 + 8 baseline holds.
3. The real M2 blocker: at the Holtburg inn doorway, CellId ping-pongs
between 0xA9B40022 (outdoor) and 0xA9B40164 (vestibule) every few ticks
because indoor BSP push-back exits the indoor CellBSP volume → outdoor
reclassification → walls bypassed on outdoor ticks.
4. Investigate cell-id hysteresis. Retail oracle:
acclient_2013_pseudo_c.txt:308742-308783 (CObjCell::find_cell_list
Position-variant). Look for the cell-array stickiness logic that retail
uses to prevent ping-pong.
5. CLAUDE.md rules: no workarounds, retail-faithful, probe-first.
State M2 as the milestone, "cell-tracking ping-pong fix" as the phase.
```

File diff suppressed because it is too large Load diff

View file

@ -1,356 +0,0 @@
# A6.P2 cdb capture findings — 2026-05-21
**Status:** SHIPPED — 5 of 9 scenarios captured; scen6-9 cancelled (see
"Capture inventory" below). Findings 1-4 ready for A6.P3 fix surfacing.
**Spec:** [`docs/superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md`](../superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md).
**PDB match verification:** [`pdb-match-verification.txt`](2026-05-21-a6-captures/pdb-match-verification.txt).
**Prior handoffs:**
- [A6.P1 partial-ship handoff](2026-05-21-a6-p1-partial-ship-handoff.md) (this session continues from there)
## TL;DR — what the 5 captures prove
1. **Finding 2 (ContactPlane resynthesis blowup) is overwhelmingly confirmed.**
Across all 5 scenarios, acdream writes ContactPlane fields **250× to ∞×**
more often than retail. The infinite ratio is scen3 (flat 2nd-floor walk):
retail's `set_contact_plane` fires **zero times** while acdream writes
86,748 field updates. This is the M1.5 root cause for "indoor walking
feels broken." A6.P3 fix surface: stop resynthesizing CP every frame.
2. **Finding 1 (dispatcher entry frequency mismatch) extends to all scenarios**
but is shape-divergent. Retail calls `BSPTREE::find_collisions` 4× to
281× more often than acdream's `BSPQuery.FindCollisions`. Largest gap on
scen5 (Town Network walk: retail 9,552 vs acdream 34 — 281× fewer in
acdream). Suggests retail's `transitional_insert` calls dispatcher
per-sub-step regardless of expected collisions; acdream's modern path
is lazier.
3. **Finding 3 (cell-resolver indoor sling-out) directly captured on scen4.**
+Acdream walked a few meters inside a Holtburg cottage cellar and the
resolver flung the character across a landblock boundary (`0xA9B40148 →
0xA9B40029 → 0xA9B30030`, all `reason=resolver`). Indoor BSP was barely
queried (only 2 `[indoor-bsp]` hits during the sling-out); `[check-bldg]`
fired 5,495 times trying to re-resolve which building +Acdream was in.
This is a distinct failure family from the stair-attempt pattern (scen2)
and the CP-write blowup.
4. **Finding 4 (portal-graph visibility blowup, scope-adjacent).** Discovered
incidentally during scen5: after portal teleport to Town Network hub,
`visibleCells` per cell exploded from ~4 to 135-145 and cells from
disconnected landblocks (0x0007, 0x020A, 0x0408) were cached. Filed
separately as **issue #95** — this is the underlying cause of
user-observed "dungeons are broken (see through walls / other dungeons
rendering)" across the project.
## Capture inventory
| # | Tag | Walk script | Retail | Acdream | Status |
|---|---|---|---|---|---|
| 1 | scen1_inn_doorway | Walk through inn door, stop just inside | ✅ | ✅ | committed (prior session) |
| 2 | scen2_inn_stairs | Walk up 4 steps; acdream re-captured as stair-FAILURE | ✅ | ✅ | committed (this session) |
| 3 | scen3_inn_2nd_floor | Forward 3m, sidestep 1m, walk back (teleport into acdream) | ✅ | ✅ | committed (this session) |
| 4 | scen4_cottage_cellar | Retail ascent + acdream teleport-in + sling-out | ✅ | ✅ | committed (this session) |
| 5 | scen5_sewer_entry | Town Network portal entry (Holtburg sewer doesn't exist) | ✅ | ✅ | committed (this session) |
| 6 | scen6_sewer_first_stair | — | ❌ | ❌ | **cancelled** — Holtburg Sewer doesn't exist on this server |
| 7 | scen7_sewer_inter_room | — | ❌ | ❌ | **cancelled** — same |
| 8 | scen8_sewer_chamber | — | ❌ | ❌ | **cancelled** — same |
| 9 | scen9_sewer_corridor | — | ❌ | ❌ | **cancelled** — same; any substitute dungeon hits issue #95 (visibility blowup) on portal entry, making physics-only analysis impossible |
**Why scen6-9 cancelled:** The A6.P1 design spec assumed the Holtburg Sewer existed and was accessed via portal. Neither is true on this ACE server. Any substitute dungeon (the path we'd normally take) hits the portal-graph visibility bug (issue #95) immediately on portal entry, making the dungeon visually unusable for navigation. A dedicated A6.P1-redux capturing post-#95-fix dungeon physics is a candidate for A8 or M1.5-residual scope; for A6.P2 the 5 captured scenarios provide sufficient evidence.
## Analysis tables
### Table 1 — Per-site push-back delta
**DEFERRED.** The current cdb probe (v4) captures BP5 (`adjust_sphere_to_plane`) at function **entry only**. Computing per-call delta `‖output_center input_center‖` requires a paired epilogue breakpoint that captures the corrected sphere center after the function returns. This was scoped out of A6.P1 to keep the cdb script simple; adding it is a future A6.P1.5 (estimated 1 hour: add `bu` exit breakpoints to v5 of `a6-probe.cdb`).
**What we have instead:** BP5 entry-count is a proxy for "how often does adjust_sphere fire." From the BP5 counts in Table 3 (column 1), acdream's BP5 call rate is divergent from retail's, but without paired entry/exit values we can't quantify the over-correction directly.
**Bug-candidate threshold flagged in the spec (acdream > 3× retail on delta):** unmeasurable from current data; defer to A6.P1.5 or accept Findings 1-3 as sufficient triggers for A6.P3 fixes.
### Table 2 — Path-frequency diff
**DEFERRED.** Same reason as Table 1 — the cdb probe captures `BSPTREE::find_collisions` entry only, not which of the 7 exit paths (`PLACEMENT_INSERT`, `check_walkable`, `step_down`, `collide_with_pt`, `set_collide+slid`, `step_sphere_up`, `find_walkable`) was taken. Adding exit-discriminating breakpoints requires breakpoint after each return site in `find_collisions` — non-trivial cdb scripting work, deferred to A6.P1.5.
**What we have instead:** total dispatcher entries per scenario (Table 3 column 1). Acdream's overall dispatcher call rate is wildly lower than retail's in every scenario — see Finding 1 below.
### Table 3 — ContactPlane lifecycle diff (the smoking gun)
Walk duration was variable per scenario; values below are raw counts over the full capture window. Ratios are comparable across rows because both clients walked similar-duration scenarios per pair.
| Scenario | Retail BP4 dispatcher | Acdream push-back-disp | Acdream/Retail dispatcher ratio | Retail BP7 set_contact_plane | Acdream cp-write | **CP-write ratio (acd/retail)** |
|---|---:|---:|---:|---:|---:|---:|
| 1 inn doorway | 9,289 | 295 | 0.032× (31× fewer) | 18 | 73,304 | **4,072×** |
| 2 inn stairs (acdream: stair-fail) | 47,783 | 4,156 | 0.087× (11× fewer) | 136 | 33,969 | **250×** |
| 3 inn 2nd floor (acdream teleport) | 10,636 | 2,752 | 0.259× (4× fewer) | **0** | 86,748 | **∞** |
| 4 cottage cellar (acdream sling-out) | 12,596 | 82 | 0.007× (154× fewer) | 3 | 35,624 | **11,875×** |
| 5 town network portal | 9,552 | 34 | 0.004× (281× fewer) | 65 | 20,956 | **322×** |
**Geometric mean of CP-write ratio across the 4 finite scenarios (excluding scen3 ∞):** ~1,470×. **Median (excluding scen3):** ~2,200×.
**Verdict:** acdream writes the ContactPlane on order of 1,000× more frequently than retail. The only scenario where ratios are "small" (250×) is scen2's stair-attempt, where acdream's CP-write count is actually LOWER than the other scenarios because the failing physics couldn't synthesize a valid CP — see Finding 2 inversion.
### Table 4 — Sub-step state mutations
**PARTIAL.** Per-field mutation counts require shadow-state diffing across sub-steps, which the v4 probe doesn't emit. What we CAN report is per-tag firing rates that approximate state-mutation pressure:
| Scenario | Retail BP2 step_up | Acdream indoor-bsp | Acdream indoor-walkable | Acdream cell-cache | Acdream check-bldg |
|---|---:|---:|---:|---:|---:|
| 1 inn doorway | (0) | 26 | 18 | 540 | 9,530 |
| 2 inn stairs (fail) | 188 | 1,286 | 859 | 527 | 81 |
| 3 inn 2nd floor | (0) | 1,061 | 707 | 527 | 740 |
| 4 cottage cellar (sling) | 13 | 2 | 2 | 540 | **5,495** |
| 5 town network | 1 | 2 | 2 | 9,642 | 740 |
Notable patterns:
- **Acdream check-bldg fires 1-2 orders of magnitude more than push-back-disp** in scen1 (9,530 vs 295), scen4 (5,495 vs 82), and scen5 (740 vs 34). The `CheckBuildingTransit` machinery is constantly re-resolving "which building is the player in" even when the BSP itself isn't being queried. This is a state-thrash separate from the CP-write blowup.
- **Acdream indoor-bsp and indoor-walkable scale together** in the stair-attempt scenarios (scen2: 1,286/859; scen3: 1,061/707) but stay near zero on outdoor/portal walks (scen4/5: ~2 each). Suggests indoor BSP is gated by something that doesn't fire during normal Holtburg walking but DOES fire during stair attempts.
- **Cell-cache scales with how much landblock streaming happened**: 540 on standstill scenarios, 9,642 on scen5 where the player walked across Holtburg to reach the network portal.
## Per-scenario narrative
### Scenario 1 — Inn doorway (prior session)
User walked through the Holtburg inn front door, stopped just inside. Standard short walk over a threshold.
Retail: 18 set_contact_plane calls (~one per second of walking).
Acdream: 73,304 cp-write events. **Ratio: 4,072×.**
Per-call shape match (BP5 hit#1, vertical step-down probe against ground):
- Plane: (0, 0, 1), d≈0 — identical.
- Sphere radius: 0.48 — identical.
- WalkInterp: 1.0 — identical.
- Sphere.center.z and Movement.z DIFFER between retail and acdream (retail: -0.27 / -0.75; acdream: +0.46 / -0.50). Could be local-space convention difference (retail's `localspace_pos` vs our per-cell transform) OR could be the BSP correction-path divergence the spec hypothesizes. A6.P3 work surface.
### Scenario 2 — Inn stairs (acdream re-captured as stair-FAILURE)
Retail walked successfully up 4 inn stair steps. Acdream re-captured AFTER an initial mislabeled door-walk: user attempted to climb the inn stairs, character failed (couldn't ascend).
**Retail signature:** BP2 step_up=188 — clean stair-climb signature (scen1 doorway had only 1 BP2 hit). BP6 check_walkable=677 (with threshold=FloorZ 0.6642, confirmed by hex decoder).
**Acdream failure signature (stair-attempt vs door-walk):**
| Tag | door-walk | stair-attempt | Ratio |
|---|---:|---:|---:|
| push-back-disp | 1,141 | 4,156 | 3.6× |
| push-back-cell | 87 | 1,478 | **17×** |
| other-cells | 87 | 1,478 | **17×** |
| indoor-bsp | 343 | 1,286 | 3.7× |
| indoor-walkable | 227 | 859 | 3.8× |
| cp-write | 70,244 | 33,969 | 0.5× (inverse!) |
The 17× explosion on push-back-cell / other-cells is the failure: when the indoor BSP query can't resolve a stair-step, the multi-cell fallback fires constantly. The cp-write DROP (half the door-walk volume) is the inverse signal: when no ground plane resolves, no CP gets written. Both are A6.P3 fix-surface indicators.
### Scenario 3 — Inn 2nd floor (acdream via @teleport)
Flat-floor walk: forward 3 m, sidestep 1 m, walk back. Both clients in the same physical space (acdream got there via `@teleport` admin command).
**Retail signature:** BP1=10,217, BP4=10,636, BP5=113, BP6=113, **BP2=0, BP3=0, BP7=0.** No stairs, no walls, no contact plane updates — retail's physics did almost nothing because the 2nd-floor is flat and there's nothing to collide with.
**Acdream signature:** cp-write=86,748, push-back-disp=2,752, indoor-bsp=1,061, push-back=320.
The infinite-ratio CP-write blowup. Retail wrote CP zero times across an entire flat-floor walk; acdream rewrote CP fields 86,748 times. This is the cleanest evidence for Finding 2: the bug fires equally on ordinary flat indoor walking, not just on stair attempts.
### Scenario 4 — Cottage cellar (asymmetric pair)
Retail: walked UP out of cellar (2-step ascent + indoor→outdoor exit).
Acdream: teleported INTO cellar, walked a few meters, resolver flung +Acdream OUTSIDE the cottage.
**Retail signature:** BP2=13 (cellar ascent is 2 steps; gives 13 step_up hits — non-linear vs scen2's 188 hits for 4 stair steps; depends on step height and tick density). BP7=3 (almost no CP updates during ascent + exit).
**Acdream sling-out signature:** distinct from scen2's stair-attempt:
- check-bldg=5,495 (CheckBuildingTransit fired constantly during the sling)
- cell-transit=3 events captured the sling: `0xA9B40148 → 0xA9B40029 → 0xA9B30030`, all `reason=resolver`. The third transit crossed a landblock boundary (`A9B4 → A9B3`).
- indoor-bsp=2 (indoor BSP was barely queried during the sling!)
- push-back=1 (no real sphere-adjustment happened)
The sling-out is the cell-RESOLVER misbehaving, not the BSP. ResolveCellId pushed the player out of indoor space without engaging the indoor BSP collision path at all. The check-bldg storm is the symptom: every tick, CheckBuildingTransit re-tries to figure out which building the player is in, gets it wrong, and the resolver acts on that wrong answer.
### Scenario 5 — Town Network portal entry
Substituted for "Holtburg Sewer entry" (which doesn't exist). Both clients walked to the Town Network Portal in Holtburg, entered it, walked 2 m forward in the network hub.
**Retail signature:** clean walk + portal transition + indoor walking in hub. BP1=13,863, BP4=9,552, BP5=97, BP6=55, BP7=65 (moderate CP updates around the portal threshold), BP2=1 (portal threshold step-up).
**Acdream signature:** clean physics — no failure mode. cp-write=20,956 (still ~322× retail), push-back-disp=34 (very few dispatcher hits — mostly flat-ground walking with no collisions).
**Cell-transit chain — captures the portal entry:**
```
0x00000000 -> 0xA9B30030 reason=teleport (login spawn)
0xA9B30030 -> 0xA9B40029 -> 0xA9B40021 -> 0xA9B40019 ->
0xA9B40011 -> 0xA9B40012 -> 0xA9B4000A -> 0xA9B4000B ->
0xA9B40003 (walked across Holtburg)
0xA9B40003 -> 0x00070143 reason=teleport (PORTAL ENTRY)
0x00070143 -> 0xA9B30016 reason=resolver (post-teleport resolver)
0xA9B30016 -> 0x00060016 reason=resolver (lands at network hub)
```
**Incidental discovery (filed as issue #95):** post-teleport, `[cell-cache]` events showed `visibleCells=135-145` per cell (vs normal ~4-7), with cells cached from 3 separate landblocks (0x0007, 0x020A, 0x0408) — different `worldOrigin`s, i.e. different dungeons entirely. This is the portal-graph visibility blowup. Direct cause of "see through walls / other dungeons rendering" across the project.
## Findings
### Finding 1 — Dispatcher entry frequency mismatch (4× to 281× fewer in acdream)
**Status:** confirmed in all 5 scenarios; severity MEDIUM (probable secondary effect of the v4 probe scope rather than a single fix surface).
**Retail decomp anchor:** [`CTransition::transitional_insert`](docs/research/named-retail/acclient_2013_pseudo_c.txt) — retail's outer loop dispatches `BSPTREE::find_collisions` per sub-step regardless of expected collision. (Spec §1.2 hypothesis.)
**Our suspect code site:** `src/AcDream.Core/Physics/Transition.cs` / `src/AcDream.Core/Physics/BSPQuery.cs` — the modern dispatcher path likely short-circuits when no candidate cell has potential collision geometry.
**Divergence quantified:** retail BP4 hit count vs acdream push-back-disp hit count, per Table 3 column "Acdream/Retail dispatcher ratio." Range: 0.004× (scen5) to 0.259× (scen3). Worst gap on flat-walk scenarios where retail still queries dispatcher constantly.
**Proposed fix sketch:** investigate whether acdream's `Transition` is correctly calling FindCollisions in the per-sub-step inner loop. If `transitional_insert` short-circuits on a "no obvious collision" heuristic, the optimization may be hiding CP retention behavior that ONLY runs in the dispatcher's idle paths (e.g. step_down probe-to-ground that maintains LKCP). Removing the short-circuit may close Finding 2 as a side effect.
**Scenarios affected:** all 5.
### Finding 2 — ContactPlane resynthesis blowup (250× to ∞× more in acdream)
**Status:** confirmed in all 5 scenarios; severity **HIGH (single largest M1.5 root cause)**.
**Retail decomp anchor:** `COLLISIONINFO::set_contact_plane` and the three documented retention mechanisms — Mechanism A (Path-6 land write in `BSPQuery.FindCollisions`), Mechanism B (LKCP-restore in `validate_transition`), Mechanism C (post-OK step-down probe). See spec §1.2.
**Our suspect code site:** `src/AcDream.Core/Physics/Transition.FindEnvCollisions` indoor branch — likely resynthesizes ContactPlane per frame instead of retaining via the three mechanisms. Closely related: the existing `TryFindIndoorWalkablePlane` synthesis workaround (flagged for A6.P4 removal).
**Divergence quantified:** per Table 3 column "CP-write ratio." Median 2,200× across the 4 finite scenarios; infinite ratio on scen3 (retail: 0 writes; acdream: 86,748 writes for the same flat-floor walk).
**Proposed fix sketch:**
1. Audit every site in our physics code that writes `ContactPlane`. There should be at most 3 active sites per the retention mechanisms — likely we have N>>3.
2. Replace per-frame `ContactPlane.Set(...)` calls with the retain-or-restore pattern: at the start of each tick, restore CP from `LastKnownContactPlane` (Mechanism B); only update when Path-6 lands write a new plane (Mechanism A); only re-probe via step-down when the post-OK position is suspect (Mechanism C).
3. The `TryFindIndoorWalkablePlane` synthesis goes away as part of the same change (A6.P4).
4. Verification: after the fix, re-run the scen3 capture. Target: acdream cp-write count drops from 86,748 to ≤ retail's BP7 + some buffer (say ≤ 100). If the drop is large, the change is on the right track.
**Scenarios affected:** all 5 — strongest signal in scen3 (∞× ratio).
### Finding 3 — Indoor cell-resolver sling-out (scen4)
**Status:** confirmed in scen4; severity HIGH (player can't stay inside small indoor spaces).
**Retail decomp anchor:** `CObjCell::find_cell_list` Position-variant (`acclient_2013_pseudo_c.txt:308742-308783` — already cited in CLAUDE.md as the cell-tracking ping-pong oracle for the M1.5 hypothesis).
**Our suspect code site:** `src/AcDream.Core/Physics/PhysicsEngine.ResolveCellId` + `src/AcDream.Core/Physics/CellPhysics.CheckBuildingTransit`. Issue #90 (cell-id ping-pong workaround) is part of this surface and would be removed in A6.P4 once the proper fix lands.
**Divergence quantified:** scen4 captured 3 cell-transit events during a few meters of walking inside a cottage cellar:
- `0xA9B40148 → 0xA9B40029` (indoor cottage → outdoor cell, `reason=resolver`)
- `0xA9B40029 → 0xA9B30030` (crossed landblock boundary, `reason=resolver`)
During the sling, `[check-bldg]` fired 5,495 times (CheckBuildingTransit re-resolving repeatedly), `[indoor-bsp]` fired only 2 times (indoor BSP was barely queried), and `[push-back]` fired only 1 time (no real sphere-adjustment).
**Proposed fix sketch:** ResolveCellId / CheckBuildingTransit should preserve indoor cell membership when the sphere is close to (but slightly outside) the indoor CellBSP volume — the cell-array hysteresis logic retail uses. Port the stickiness logic from the retail decomp anchor above. May obsolete issue #90's workaround.
**Scenarios affected:** scen4 directly; likely scen2/scen3 cellar/inn variants too once the visibility bug (#95) is fixed and we can re-capture.
### Finding 4 — Portal-graph visibility blowup (scope-adjacent; filed as #95)
**Status:** confirmed in scen5; severity HIGH (blocks all dungeon navigation visually); **filed as issue #95**.
Not strictly an A6 physics finding — this surfaced incidentally during scen5 capture and explains the project-wide "dungeons are broken" symptom. Full writeup in `docs/ISSUES.md` issue #95. Mentioned here so A6.P3 sequencing knows about it: any future dungeon-physics work (A8 or M1.5-residual) needs #95 fixed first, because a broken visibility set makes any in-dungeon physics analysis untrustworthy (cells are loaded that shouldn't be, distance/visibility queries return wrong answers, etc).
## M1.5 symptom coverage
Per spec §4.7, every M1.5-in-scope symptom maps to at least one bug candidate OR is explicitly flagged as deferred.
| Symptom | Source | Mapped to finding | Notes |
|---|---|---|---|
| Issue #83 — Indoor multi-Z walking broken | ISSUES.md | Finding 2 (CP-write) + Finding 3 (resolver sling) | scen3 + scen4 evidence |
| Issue #88 — Indoor static objects vibrate | ISSUES.md | Finding 2 (CP-write resynthesis per-tick causes per-tick visible micro-adjustments on static-object physics) | Hypothesis: same root cause |
| Issue #90 — Cell-id ping-pong at indoor doorway threshold | ISSUES.md | Finding 3 (cell-resolver bug) | Issue #90 is the workaround; root cause is Finding 3. A6.P4 removes the workaround. |
| Stairs walk-through (acdream can't climb) | observed | Finding 1 + Finding 2 (the stair-step probe fails because CP isn't retained between sub-steps so step_up's walkability check sees the wrong plane) | scen2 stair-attempt direct evidence |
| 2nd-floor walking (works once teleported) | observed | Finding 2 only (scen3 shows pure flat-floor CP blowup) | Walking itself fine; CP-write is the divergence |
| Cellar descent (acdream can't descend) | observed | Finding 1 + Finding 2 same as stairs | not directly captured (couldn't descend in acdream) but same physics |
| `TryFindIndoorWalkablePlane` synthesis MISS | spec §1.2 | Finding 2 (same family) | A6.P4 removes the workaround as part of the Finding 2 fix |
| Sling-out from inside building | scen4 discovery | Finding 3 (cell-resolver) | NEW symptom not in original M1.5 list — promote to symptom roster |
| "Dungeons are broken" project-wide | user-observed | Finding 4 / issue #95 (NOT A6 scope) | Defer to dedicated visibility-bug fix |
**A6.P2 acceptance test:** every in-scope M1.5 physics symptom has a mapped finding. ✅ Met.
## A6.P3 fix-surface sequencing recommendation
Per spec §5.1: "highest-confidence single-cause fix first."
**Recommended order:**
1. **Finding 2 first** (CP-write resynthesis) — single largest divergence, single largest probable impact, narrowest suspected code site (`Transition.FindEnvCollisions` indoor branch + ContactPlane retention). If Finding 1 IS a secondary effect of CP-write missing the dispatcher idle paths (the hypothesis in Finding 1's fix sketch), then fixing Finding 2 may close Finding 1 automatically. Highest expected value per PR.
2. **Re-run scen1-5 captures after Finding 2 PR lands.** Compute new ratios. If CP-write ratios drop from ~1000× to ~1× (target), Finding 2 is closed.
3. **If Finding 1 dispatcher gap also closed** — proceed directly to Finding 3.
4. **If Finding 1 still wide** — separate PR for the dispatcher-call-rate fix.
5. **Finding 3** (cell-resolver sling-out) — narrower fix; specific to ResolveCellId + CheckBuildingTransit cell-stickiness. PR also removes issue #90 workaround.
6. **A6.P4 visual verification at Holtburg inn → stairs → cellar.** Acceptance per spec §6.3.
7. **Finding 4 / issue #95** is NOT in A6.P3 scope. Handle separately when scheduled for the visibility-bug work.
## Open items / next-session candidates
- **A6.P1.5** (optional, ~1 hour): extend cdb probe with paired entry/exit BPs to capture `adjust_sphere_to_plane` output delta (Table 1) and `find_collisions` exit-path discrimination (Table 2). Only needed if A6.P3 fixes don't close the symptoms and we need sharper data. Defer until after A6.P3 first attempt.
- **Issue #95** (separate work surface): portal-graph visibility blowup. Schedule outside A6 since fixing it unblocks scen6-9 captures and any future dungeon physics work.
- **Symptom roster update:** add "indoor sling-out" to M1.5 symptom list (Finding 3 family); already captured here as a finding, but M1.5 doc should reflect it.
---
## A6.P3 slice 1 — SHIPPED 2026-05-21
Strip-synthesis + Mechanism B (LKCP restore) fix landed in 8 commits across this same session:
| Commit | Task | What |
|---|---|---|
| `ba9655f` | plan | A6.P3 slice 1 implementation plan written |
| `6b4be7f` + `c6bc2b9` | T1 | Research note: retail's `CTransition::validate_transition` LKCP-restore (line 272565-272583) + insertion-point identified in our `Transition.ValidateTransition` at TransitionTypes.cs:2849 |
| `869edd9` | T2 | Test instrumentation: `CollisionInfo.ContactPlaneWriteCount` counter |
| `36975ef` + `a32f569` | T3 | Failing regression: `IndoorContactPlaneRetentionTests` — asserts ≤5 CP writes across 60 flat-floor frames |
| `5aba071` | T4 | Mechanism B (LKCP restore) inserted in `ValidateTransition` + proximity-check sphere bug fix (`GlobalSphere[0]``GlobalCurrCenter[0]`) |
| `5f7722a` + `39fc037` + `bd5fe2e` | T5 | Indoor branch of `FindEnvCollisions` stripped to match retail's tiny `CEnvCell::find_env_collisions` shape; test redesigned as real regression sentinel (validated 60-writes-pre-strip → 0-writes-post-strip) |
| `066568a` | T6/T7 partial | scen2_inn_stairs_postfix acdream capture proves stairs now work |
| (this commit) | T6 + T8 | scen3_inn_2nd_floor_postfix capture + bookkeeping (findings doc + roadmap + CLAUDE.md + issues #96/#97 filed) |
### scen3 re-capture results (postfix)
scen3 (Holtburg inn 2nd floor flat-walk) re-captured in this slice-1 ship commit:
| Metric | Pre-fix (4b5aebc) | Post-fix | Reduction |
|---|---:|---:|---:|
| acdream cp-write (absolute) | 86,748 | 25,082 | 3.5× |
| acdream cell-cache events (proxy for session length) | 527 | 9,629 | 18× longer session |
| **cp-write per cell-cache (normalized)** | **164.61** | **2.60** | **63.2× per-unit-of-activity** |
| retail BP7 set_contact_plane | 0 | 0 | unchanged (oracle) |
Per-unit-of-activity drop is the meaningful number — a longer post-fix session naturally accumulates more total writes, but the rate per "unit of activity" (cell-cache events ~ landblocks traversed) collapsed 63×.
### scen2 re-capture results (postfix — UNEXPECTED WIN)
scen2 (Holtburg inn stairs) acdream re-captured at commit `066568a`. **Pre-fix: physics hammered BSP trying to resolve stairs (failure mode). Post-fix: user walked up and down stairs multiple times with no failure.** Tag shape shifted:
| Tag | Pre-fix (stair FAIL) | Post-fix (stair SUCCESS) | Signal |
|---|---:|---:|---|
| indoor-walkable | 859 | **0** | synthesis gone (as designed) |
| push-back-cell | 1,478 | 879 (-40%) | multi-cell iteration relaxed |
| push-back | 51 | 345 (+577%) | real step_up firing |
| push-back-disp | 4,156 | 6,055 (+46%) | real BSP traversal |
| cp-write | 33,969 | 57,846 | L622 seed (slice 2 work) |
Stairs working post-slice-1 confirms A6.P2's hypothesis that **Finding 1 (dispatcher entry frequency mismatch) was a secondary effect of Finding 2** — fixing CP retention also closes the cell-array iteration storm that prevented stair-step resolution.
### Visual verification (user happy-testing, 2026-05-21)
User report from happy-testing session post-slice-1:
- ✅ 2nd floor walking works (with caveats below)
- ✅ Stairs up + down work (M1.5 demo target unblocked)
- ✅ Cellar descent works (M1.5 demo target unblocked)
- ❌ Phantom collisions occasionally on 2nd floor — filed as **issue #97** (hypothesis: caused by #96)
- ❌ Occasional fall-through on 2nd floor — filed as **issue #97** (same)
- ❌ See-through-walls indoors — **issue #95** (not A6 scope; visibility blowup)
- ❌ Indoor lighting broken — **A7 scope**
### Status of A6.P2 findings post-slice-1
| Finding | Status post-slice-1 |
|---|---|
| Finding 1 — dispatcher entry frequency mismatch | **CLOSED as side-effect of Finding 2 fix** (scen2 dispatcher shape now retail-like) |
| Finding 2 — ContactPlane resynthesis blowup | **PARTIALLY CLOSED.** Synthesis path eliminated (indoor-walkable = 0). Remaining 99.3% of post-fix CP writes come from `PhysicsEngine.ResolveWithTransition` line 622 — a per-tick body-CP seed that retail doesn't do. **Filed as issue #96** for slice 2. |
| Finding 3 — Indoor cell-resolver sling-out | OPEN. Not addressed by slice 1. Needs scen4 re-capture to confirm whether sling-out symptom persists post-slice-1 (possible side-effect close); separate fix surface in ResolveCellId / CheckBuildingTransit otherwise. |
| Finding 4 — Portal-graph visibility blowup | OPEN as issue #95 (not A6 scope; user-confirmed during happy-testing). |
### Slice 2 recommendation
**Highest-value next slice: gate the L622 per-tick CP seed.** It's responsible for 99.3% of remaining post-fix CP writes (24,906 of 25,082 in scen3 postfix). Retail's equivalent code path fires zero `set_contact_plane` calls during flat-floor walks. Either remove the seed entirely (rely on Mechanism A/B for CP propagation) OR gate it to fire only when the body's CP has changed since last seed.
After slice 2, re-test phantom collisions + fall-through (issue #97) — they may close as side-effects (same family of "CP state being unstable across ticks"). If not, that becomes slice 3 territory + Finding 3 work.
A6.P4 (workaround removal + visual verification) can proceed in parallel with slice 2 if scope allows.

View file

@ -1,344 +0,0 @@
# A6.P1 partial-ship handoff — 2026-05-21
**Status:** Infrastructure complete + scenario 1 (Holtburg inn doorway)
captured end-to-end (retail + acdream paired). Scenarios 29 deferred to
next session.
**Pasteable session-start prompt at the bottom of this doc.**
## TL;DR
A6.P1 ships in two milestones:
1. **Infrastructure milestone (DONE today):** `[push-back]` acdream probe (3
helpers + 3 sites + DebugVM mirror + CLAUDE.md docs), cdb probe script
(v4 with PDB-verified offsets + hex-bits float output), PowerShell
runner with ASCII encoding, README, capture-dir scaffolding,
PDB-match verification, type dumper, hex→float decoder.
2. **Capture milestone (PARTIAL):** 1 of 9 scenarios captured. Scenarios
29 user-driven, deferred at user direction to avoid fatigue.
**Scenario 1 already surfaces two strong M1.5 findings** (before any
formal A6.P2 analysis):
| Metric | Retail | acdream | Notes |
|---|---:|---:|---|
| dispatcher entries (find_collisions / BSPQuery.FindCollisions) | 5,818 | 295 | acdream calls dispatcher **20× less often** |
| ContactPlane writes (set_contact_plane fn / per-field writes) | 18 calls | **73,304** field-writes | acdream **rewrites CP every frame/sub-step** vs retail's per-event |
The CP-write blowup directly confirms the spec's hypothesis
([2026-05-21-phase-a6-indoor-physics-fidelity-design.md §1.2](../superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md))
that `FindEnvCollisions` indoor branch resynthesizes CP per frame
instead of retaining via Mechanisms A/B/C. Same family as the
`TryFindIndoorWalkablePlane` workaround.
## State both altitudes (next session)
> **Currently working toward: M1.5 — "Indoor world feels right."**
>
> **Current phase: A6 — Indoor physics fidelity (cdb-driven).**
>
> **Next concrete step: Capture scenarios 29 (paired retail + acdream
> traces). Then run A6.P2 analysis on all 9 captures.**
## What shipped today (16 commits)
### Infrastructure (Tasks 114 from the A6.P1 plan)
| Commit | What |
|---|---|
| `ace9e62`, `ad6c89d` | T1: `ProbePushBackEnabled` toggle + roundtrip test |
| `3a173b9` | T2: `LogPushBackAdjust` helper |
| `eb8a318` | T3: instrument `BSPQuery.AdjustSphereToPlane` |
| `2d1f27d` | T4: `LogPushBackDispatch` helper |
| `35631d1` | T5: instrument `BSPQuery.FindCollisions` |
| `66ee757` | T6: `LogPushBackCellTransit` helper |
| `642734d` | T7: instrument `Transition.CheckOtherCells` |
| `dd95c10` | T8: DebugVM `ProbePushBack` mirror |
| `e1f7efe` | T9: CLAUDE.md `ACDREAM_PROBE_PUSH_BACK` env var docs |
| `7bb799b` | T10: `tools/cdb/a6-probe.cdb` v1 (broken offsets) |
| `1c640eb` | T11: `tools/cdb/a6-probe-runner.ps1` (later patched to ASCII) |
| `df315a9` | T12: `tools/cdb/README-a6-probe.md` |
| `0e21f22`, `22e341f` | T13: PDB-match verification (audit trail) |
| `260c60f` | T14: capture-dir scaffolding + findings doc stub |
### cdb script iteration (T15 dry-runs)
| Commit | What |
|---|---|
| `d0c8c54` | v1→v2 prep: type dumper (`a6-types-dump.cdb` + runner) + ASCII runner |
| `7b9b26f` | v2 cdb script: PDB-verified offsets + BP6 fix to `check_walkable` |
| `1b6d49e` | v3 cdb script: `@@c++(*(float*)addr)` for floats (still produced zeros) |
| `2d841cb` | v4 cdb script: hex-bits float output via `%08X` (WORKS) |
### Scen1 capture + decode tooling
| Commit | What |
|---|---|
| `180b4a5` | scen1 retail.log captured (v4 cdb, 13,552 hits, real hex bits) |
| `8ca718a` | scen1 acdream.log paired (84,130 lines, full probe distribution) |
| `194ed3e` | `decode_retail_hex.py` — Python hex→float decoder + scen1 decoded log |
## Why cdb v1→v4 iteration was necessary
The cdb side hit three landmines we didn't anticipate when writing the
A6.P1 plan:
1. **v1: Stack-arg offsets wrong.** Plan's probe actions used arbitrary
registers (`@edx`, `@edi`) to read function args. `__thiscall` puts
non-this args on the stack (`[esp+N]`), not in arbitrary registers.
All 12 BP5 hits printed `Nx=0 Ny=0 ...` — confirming the read
addresses were wrong. **Fix:** type dumper + double-indirect via
`dwo(poi(@esp+N)+offset)`.
2. **v2: BP6 symbol wrong + PowerShell UTF-16 encoding.** v1's
`validate_walkable` doesn't exist in the PDB (the actual function is
`CTransition::check_walkable`). PowerShell's `Tee-Object` writes
UTF-16 LE by default, making logs ungreppable. **Fixes:** BP6 symbol
corrected, runner switched to `Out-File -Encoding ASCII`. v2 had
correct integer reads (substeps=3, insertType=0) but all `%f` floats
still printed as 0.000000.
3. **v3: `%f` doesn't work with `dwo()`.** Switching to
`@@c++(*(float*)addr)` to force C++ interpretation also produced
0.000000 across all float fields. cdb's `.printf %f` appears to not
reliably handle our float values (possibly varargs promotion, possibly
a deeper limitation). **Workaround (v4):** print all floats as 32-bit
hex bits via `%08X`; Python decoder reinterprets via
`struct.unpack('<f', struct.pack('<I', value))`.
The v4 + decoder pattern works. **Pickup sessions should NOT change
the cdb script** unless adding new BPs. The hex-bits encoding is robust
and the decoder validates against known constants (BP6 threshold = FloorZ).
## Scen1 findings (preliminary — formal A6.P2 to follow)
### Capture pair
- Retail: `docs/research/2026-05-21-a6-captures/scen1_inn_doorway/retail.log` (raw v4 hex) + `retail.decoded.log` (decoded floats).
- Acdream: `docs/research/2026-05-21-a6-captures/scen1_inn_doorway/acdream.log` (84,130 lines).
### BP hit-count distribution (2-sec walk through inn doorway, both clients)
| Site | Retail | Acdream | Ratio (acdream/retail) |
|---|---:|---:|---:|
| transitional_insert / sub-step loop | 7,686 (BP1) | n/a (no acdream probe) | — |
| find_collisions dispatch | 5,818 (BP4) | 295 ([push-back-disp]) | **0.05× (20× fewer)** |
| adjust_sphere_to_plane | 12 (BP5) | 8 ([push-back]) | 0.67× |
| check_other_cells loop | n/a (BP3 zero — no wall hit) | 5 ([push-back-cell]) | — |
| check_walkable / ground verdict | 12 (BP6) | n/a (no acdream probe) | — |
| set_contact_plane / CP writes | 18 (BP7 fn calls) | 73,304 (per-field) | **~1001000× more** |
| step_up | 1 (BP2) | n/a | — |
| set_collide / wall halt | 0 (no wall hit in scen1) | n/a | — |
### Finding 1: dispatcher entry frequency mismatch
Retail's `BSPTREE::find_collisions` fires 5,818 times in ~2 seconds of
walking (~2,900/sec). Acdream's `BSPQuery.FindCollisions` fires only
295 times in the same scenario (~150/sec).
**Possible causes** (investigate during A6.P2):
- Physics tick rate difference (retail 30Hz? per CLAUDE.md
steep-roof finding) vs acdream's tick.
- Different sub-step cadence inside `transitional_insert`
retail's outer loop iterates much more than ours.
- Different number of cells visited per sub-step (retail's CELLARRAY
iteration calls dispatcher per cell; we may only call once
per primary cell).
- Probe scope difference: retail's BP catches `BSPTREE::find_collisions`
(one C++ class). Acdream's `[push-back-disp]` covers
`BSPQuery.FindCollisions` modern overload (one C# method). If our
call paths into dispatcher are differently structured, frequencies
diverge.
### Finding 2: ContactPlane write blowup
Acdream writes 73,304 ContactPlane field-level updates in 30 seconds
(~2,400/sec including the boot phase before the player moved).
Retail's `set_contact_plane` fires 18 times (~6/sec including boot).
Even with a 6× field-write multiplier per `set_contact_plane` call,
that gives ~100 actual CP updates in retail vs ~12,000 in acdream
**100×+ more frequent in acdream**.
**This is the M1.5 hypothesis confirmed empirically.** Per the spec
§1.2, the working hypothesis was that `FindEnvCollisions` indoor
branch rewrites CP every frame instead of retaining it via the three
documented retention mechanisms. The 73K cp-write data confirms.
A6.P3 fix surface: stop rewriting CP every frame; use the existing
LKCP-restore (Mechanism B at `validate_transition`) + Path-6 land
write (Mechanism A) + post-OK step-down probe (Mechanism C).
`TryFindIndoorWalkablePlane` synthesis (the workaround flagged for
A6.P4 removal) is part of the same bad-pattern family.
### Per-call shape match (BP5 hit#1)
| Field | Retail (decoded) | Acdream | Match? |
|---|---|---|---|
| Plane.N | (0, 0, 1) | (0, 0, 1) | ✓ identical |
| Plane.d | -0.0000 | -0.0000 | ✓ identical |
| Sphere.center.x | 0.0046 | -0.4325 | independent walks |
| Sphere.center.y | 10.3072 | 11.0219 | independent walks |
| Sphere.center.z | -0.2700 | 0.4600 | DIFFERENT axis — investigate |
| Sphere.radius | 0.4800 | 0.4800 | ✓ identical |
| WalkInterp (pre) | 1.0000 | 1.0000 | ✓ identical |
| Movement.x | 0.0000 | 0.0000 | ✓ identical |
| Movement.y | -0.0000 | -0.0000 | ✓ identical |
| Movement.z | -0.7500 | -0.5000 | DIFFERENT — investigate |
The shape matches (vertical step-down probe against ground), but two
axis values differ between retail and acdream:
- **Sphere.center.z**: retail -0.27, acdream +0.46. Could be different
local-space conventions (retail's localspace_pos vs acdream's
per-cell transform).
- **Movement.z**: retail -0.75 (the value passed by the call site
in retail's decomp), acdream -0.50 (smaller step-down probe distance).
These could be the BSP correction-path divergence the spec hypothesizes,
or they could be benign convention differences. A6.P2 with the full 9
scenarios will surface which.
## What's deferred (scenarios 29 + A6.P2)
### Scenarios 29 (~40 min user time at ~5 min each)
| # | Tag | Location | Walk script |
|---|---|---|---|
| 2 | scen2_inn_stairs | Holtburg inn, stairs to 2nd floor | Walk up 4 steps, stop on landing |
| 3 | scen3_inn_2nd_floor | Holtburg inn 2nd floor | Walk forward 3 m, sidestep 1 m, walk back |
| 4 | scen4_cottage_cellar | Holtburg cottage with cellar | Walk to cellar opening, descend 2 steps |
| 5 | scen5_sewer_entry | Holtburg sewer entrance | Walk into portal, then walk 2 m forward inside |
| 6 | scen6_sewer_first_stair | Sewer's first stair after entry | Walk down full stair flight |
| 7 | scen7_sewer_inter_room | Between any two sewer rooms via portal | Walk through portal, stop 1 m past |
| 8 | scen8_sewer_chamber | Sewer's multi-Z room | Walk in, traverse center, walk out other side |
| 9 | scen9_sewer_corridor | Sewer narrow corridor | Walk full length end-to-end |
Per-scenario protocol (validated by scen1):
1. User launches retail, navigates character to start point, stops.
2. Run `.\tools\cdb\a6-probe-runner.ps1 -ScenarioTag "scenN_..."`.
Wait for `a6-probe v4 armed:` confirmation in
`docs/research/2026-05-21-a6-captures/scenN_.../retail.log`.
3. User performs the scripted walk in retail.
4. cdb auto-detaches at 50K hits (or kill cdb to release retail —
acclient comes down too, accept and relaunch).
5. User launches acdream with all 5 probe env vars
(`ACDREAM_PROBE_PUSH_BACK=1` + indoor_bsp + cell + cell_cache + contact_plane).
Output to `docs/research/2026-05-21-a6-captures/scenN_.../acdream.log`.
6. User walks acdream through the SAME scripted walk.
7. Close acdream gracefully.
8. Run `py tools/cdb/decode_retail_hex.py docs/research/2026-05-21-a6-captures/scenN_.../retail.log`.
9. Commit `retail.log`, `acdream.log`, `retail.decoded.log` for that scenario.
### A6.P2 (analysis report) — ~1 day after all 9 scenarios are in
Spec §4 of the design doc defines the 4 mandatory tables:
1. Per-site push-back delta (Table 1)
2. Path-frequency diff (Table 2)
3. ContactPlane lifecycle diff (Table 3)
4. Sub-step state mutations (Table 4)
Plus per-scenario narrative + findings section.
**Already have strong evidence for Finding 2 (CP-write blowup)** from
scen1 alone. A6.P2 quantifies + extends across the remaining 8
scenarios + writes the formal A6.P3 fix sketches.
## Known issues + gotchas (lessons from today)
1. **Killing cdb kills retail** (per CLAUDE.md). Either wait for 50K
threshold via `qd` auto-detach (~60 sec under motion) or accept that
killing cdb takes acclient down too. Relaunch is ~30 sec.
2. **PowerShell `Tee-Object` writes UTF-16 LE.** The runner uses
`Out-File -Encoding ASCII` to fix this. Don't revert.
3. **cdb `.printf %f` is unreliable.** v4 uses hex output + Python
decoder. Do NOT try to "simplify" back to `%f`.
4. **Retail binary must match the PDB** (GUID `{9e847e2f-...}`,
linker UTC `2013-09-06`). Verify with
`py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"`
before any capture session.
5. **Hit-rate budget under motion.** ~13K total hits per 2-sec walk.
Threshold of 50K survives ~8 sec of continuous walking before
auto-detach. For longer scenarios (sewer corridor end-to-end),
the walk may need to be broken into multiple captures OR threshold
bumped to 100K (edit `a6-probe.cdb` `.if (@$t0 >= 50000)``100000`).
6. **BP6 fires with FloorZ (0.6642) not cos85 (0.0872).** v4 confirmed
this — `check_walkable` is called with `PhysicsGlobals.FloorZ` for
ground verdicts. The cos85 value (0.0872) is passed in a different
code path (post-set_collide wall-slide) which didn't fire during
scen1 (no wall hits). Will appear when scenarios 29 hit walls.
## Pickup prompt for fresh session
Open a new Claude Code session at this worktree's branch
(`claude/strange-albattani-3fc83c`, HEAD at the latest A6.P1 commit).
Then paste:
---
```
Pick up A6.P1 capture work — scenarios 2 through 9. The infrastructure
shipped today (probe + cdb v4 + decoder all working). Scenario 1 captured
end-to-end with paired retail + acdream traces; preliminary findings
already strong (CP-write blowup confirms the M1.5 hypothesis).
Read FIRST:
docs/research/2026-05-21-a6-p1-partial-ship-handoff.md
Then state both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P1 — capture scenarios 2-9
Next concrete step: scenario 2 (Holtburg inn stairs)
Workflow per scenario (validated by scen1):
1. Verify retail binary matches PDB:
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"
Expect MATCH (GUID {9e847e2f-...}).
2. User launches retail, walks character to scenario start, stops.
3. .\tools\cdb\a6-probe-runner.ps1 -ScenarioTag "scenN_..."
Wait for "a6-probe v4 armed:" in the log file.
4. User performs the scripted walk.
5. Wait for cdb auto-detach (50K hits) OR kill cdb (acclient dies too;
relaunch needed). Hit rate ~6.5K/sec under motion.
6. User launches acdream with all 5 probe env vars + output to
docs/research/2026-05-21-a6-captures/scenN_.../acdream.log
7. User walks acdream through the SAME scripted walk.
8. Close acdream gracefully.
9. py tools/cdb/decode_retail_hex.py docs/research/.../retail.log
10. Commit retail.log + retail.decoded.log + acdream.log for that scenario.
Scenario list per the README at tools/cdb/README-a6-probe.md.
DO NOT modify the cdb script. v4 works (verified by BP6 threshold
decoding to FloorZ 0.6642 exactly). The hex-bits + Python decoder
pattern is the stable approach.
CLAUDE.md rules apply:
- Three failed visual verifications = handoff (we hit this on the
cdb script v1→v2→v3 cycle; v4 broke the streak).
- No workarounds without approval (v4 hex output isn't a workaround,
it's the chosen design after cdb %f proved unreliable).
- Visual verification at the Holtburg Sewer is the M1.5 physics
acceptance test (deferred to A6.P4 after fixes land).
After all 9 captures: proceed to A6.P2 analysis per the design spec
docs/superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md
§4. Note: Finding 2 (CP-write blowup) is already evidence-confirmed
from scen1; A6.P2 just needs to quantify + extend across scenarios.
```
---
## References
- Design spec: [`docs/superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md`](../superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md)
- Implementation plan: [`docs/superpowers/plans/2026-05-21-phase-a6-p1-cdb-probe-spike.md`](../superpowers/plans/2026-05-21-phase-a6-p1-cdb-probe-spike.md)
- cdb script: [`tools/cdb/a6-probe.cdb`](../../tools/cdb/a6-probe.cdb) (v4)
- cdb runner: [`tools/cdb/a6-probe-runner.ps1`](../../tools/cdb/a6-probe-runner.ps1)
- Type dumper: [`tools/cdb/a6-types-dump.cdb`](../../tools/cdb/a6-types-dump.cdb) + [`a6-types-dump.txt`](../../tools/cdb/a6-types-dump.txt) (PDB-extracted offsets)
- Hex decoder: [`tools/cdb/decode_retail_hex.py`](../../tools/cdb/decode_retail_hex.py)
- Scen1 retail: [`docs/research/2026-05-21-a6-captures/scen1_inn_doorway/retail.log`](2026-05-21-a6-captures/scen1_inn_doorway/retail.log) + [`retail.decoded.log`](2026-05-21-a6-captures/scen1_inn_doorway/retail.decoded.log)
- Scen1 acdream: [`docs/research/2026-05-21-a6-captures/scen1_inn_doorway/acdream.log`](2026-05-21-a6-captures/scen1_inn_doorway/acdream.log)
- Findings doc stub (to be filled by A6.P2): [`docs/research/2026-05-21-a6-cdb-capture-findings.md`](2026-05-21-a6-cdb-capture-findings.md)

View file

@ -1,264 +0,0 @@
# A6.P3 Slice 1 — Retail Mechanism B Oracle for Indoor CP Retention
**Date:** 2026-05-21
**Author:** Claude (research agent)
**Task:** Pre-fix research grounding the indoor ContactPlane-retention refactor in
retail's exact LKCP-restore pattern before the synthesis path is removed.
---
## 1. `CEnvCell::find_env_collisions` Shape
Retail decomp at `acclient_2013_pseudo_c.txt` lines 309573309593 (address
`0052c130`). The complete function is 10 functional lines:
```c
// 0052c130 enum TransitionState __thiscall CEnvCell::find_env_collisions(
// class CEnvCell const* this, class CTransition* arg2)
{
// Check entry restrictions (object ethereal? door closed? etc.)
enum TransitionState result = CObjCell::check_entry_restrictions(this, arg2);
if (result == OK_TS) {
// 0052c144 Clear obstruction-ethereal so BSP collision is live.
arg2->sphere_path.obstruction_ethereal = 0;
if (this->structure->physics_bsp != 0) {
// 0052c169 Project sphere into cell-local space.
SPHEREPATH::cache_localspace_sphere(&arg2->sphere_path, &this->pos, 1f);
// 0052c175 Run BSP: INITIAL_PLACEMENT → placement_insert path;
// all other insert_types → find_collisions path.
if (arg2->sphere_path.insert_type != INITIAL_PLACEMENT_INSERT)
result = BSPTREE::find_collisions(this->structure->physics_bsp, arg2, 1f);
else
result = BSPTREE::placement_insert(this->structure->physics_bsp, arg2);
// 0052c1a5 On collision with environment (non-Contact objects only).
if (result != OK_TS && (arg2->object_info.state & 1) == 0)
arg2->collision_info.collided_with_environment = 1;
}
}
return result;
}
```
**Key observation:** `find_env_collisions` itself does **not** write
`contact_plane`. It either returns OK (BSP path OK or no BSP) or returns a
collision state. ContactPlane is written ONLY inside `BSPTREE::find_collisions`
via Path 6 (land/step-down) — that is Mechanism A. There is no per-frame
synthesis path anywhere in this function.
---
## 2. Retail Mechanism B Location
**Function:** `CTransition::validate_transition`
**Retail address:** `0050aa70`
**Decomp line range:** `acclient_2013_pseudo_c.txt` lines 272547272700
**Identified via:** The `validate_transition` function header appears at line
272547 (`0050aa70`). Line 272538 is inside the preceding
`CTransition::check_collisions` function.
The LKCP-restore block runs at lines 272565272582 (addresses `0050aaed``0050ab4c`).
---
## 3. Retail Mechanism B Trigger Condition
Mechanism B fires when ALL of the following are true:
1. **`result > OK_TS && result <= SLID_TS`** — the transition ended in Collided,
Adjusted, or Slid (not OK, not Invalid).
2. **`collision_info.last_known_contact_plane_valid != 0`** — there is a
remembered floor plane from a prior frame.
3. **Proximity guard:** `|dot(global_curr_center, LKCP.N) + LKCP.d| <= radius + 0.000199f`
— the sphere's **current position center** (`global_curr_center`, NOT the
check-position sphere `global_sphere`) is still geometrically close to the
last-known plane.
When all three pass, retail:
```c
// 0050ab37
COLLISIONINFO::set_contact_plane(
&this->collision_info,
&this->collision_info.last_known_contact_plane,
this->collision_info.last_known_contact_plane_is_water);
// 0050ab42
this->collision_info.contact_plane_cell_id =
this->collision_info.last_known_contact_plane_cell_id;
```
Then `result = OK_TS` at `0050ab9f` — the collision is resolved by restoring the
floor and treating the transition as successful.
**After that block**, at `0050acff``0050ad7d`, retail sets
`last_known_contact_plane_valid = contact_plane_valid` (unconditional overwrite,
NOT "only when valid") and then sets `Contact` + `OnWalkable` flags based on
whether `contact_plane_valid` is non-zero. The LKCP update strategy is
**unconditional** in retail (even if current CP is invalid, LKCP gets cleared).
**The epsilon constant:** `0.000199999995f` — effectively `2e-4`. This is a
tight epsilon for floating-point error in the dot product; the sphere radius
already provides the geometric margin.
---
## 4. Our Equivalent Function
From `grep -rn "ValidateTransition" src/AcDream.Core/Physics/`:
```
TransitionTypes.cs:2751 private TransitionState ValidateTransition(TransitionState transitionState)
TransitionTypes.cs:670 transitionState = ValidateTransition(result);
```
Our C# `ValidateTransition` (TransitionTypes.cs lines 27512873) is the
correct equivalent. The call at line 670 is inside `FindTransitionalPosition`'s
step loop: each call to `TransitionalInsert` is immediately followed by
`ValidateTransition(result)`.
---
## 5. Decision — Where to Add Mechanism B in Our Code
### Gap analysis
Our `ValidateTransition` has TWO divergences from retail's Mechanism B:
**Gap 1: Missing `SetContactPlane` write in the Collided/Slid/Adjusted branch.**
Retail's `validate_transition` (lines 272565272582) calls
`COLLISIONINFO::set_contact_plane(LKCP, LKCP_is_water)` and sets
`contact_plane_cell_id = LKCP_cell_id` before returning `OK_TS`.
Our `ValidateTransition` at TransitionTypes.cs:28212866 (the
`else if (ci.LastKnownContactPlaneValid)` block) only reads `LastKnownContactPlane`
to update `oi.State` flags (`Contact`, `OnWalkable`) — it does **not** call
`ci.SetContactPlane(...)`. This means `ContactPlane` stays invalid even when
we know the LKCP is close, while `ci.LastKnownContactPlane` holds the value.
The PhysicsEngine fallback at PhysicsEngine.cs:668674 partially compensates
(it reads LKCP to populate `body.ContactPlane` cross-frame), but it only does
so after `FindTransitionalPosition` returns — not per-step inside the loop.
**Gap 2: Wrong sphere used for proximity dot product.**
Retail uses `global_curr_center` (pointer to the sphere center at the *current*
frame-start position) for the dot product. Our code at TransitionTypes.cs:2843
uses `sp.GlobalSphere[0].Origin` (the *check* position — where we want to move
to). For the proximity check against a retained floor plane, the correct center
is `sp.GlobalCurrCenter[0].Origin`, matching retail's `global_curr_center`.
This distinction matters when the player is near a cell/floor boundary: if the
check position has stepped slightly off the floor but the current position is
still on it, retail correctly restores the CP; our code might fail the proximity
guard spuriously.
### Insertion point (exact)
**File:** `src/AcDream.Core/Physics/TransitionTypes.cs`
**Method:** `ValidateTransition` (line 2751)
**Target block:** The `else if (ci.LastKnownContactPlaneValid)` block at lines
28212866 (the LKCP proximity-guard branch).
**Change required:** Within the `if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))` branch (currently at line 2848), BEFORE setting `oi.State` flags:
1. Add `ci.SetContactPlane(ci.LastKnownContactPlane, ci.LastKnownContactPlaneCellId, ci.LastKnownContactPlaneIsWater);`
2. Change the proximity sphere center from `sp.GlobalSphere[0].Origin` (line 2843)
to `sp.GlobalCurrCenter[0].Origin` to match retail's `global_curr_center`.
The addition goes at TransitionTypes.cs approximately **line 2849** (just before
the `oi.State |= ObjectInfoState.Contact` at current line 2852), producing:
```csharp
// Retail Mechanism B (validate_transition:0050ab37): restore CP from LKCP
// when sphere is still near the plane. This writes ContactPlane valid so
// the end-of-function LastKnown-update block (below) re-latches it,
// and ObjectInfoState.Contact is set from contact_plane_valid.
ci.SetContactPlane(ci.LastKnownContactPlane,
ci.LastKnownContactPlaneCellId,
ci.LastKnownContactPlaneIsWater);
// Then set Contact + OnWalkable (same logic as retail's 0050ad6a block):
oi.State |= ObjectInfoState.Contact;
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
```
> **Note:** `SetContactPlane` also re-latches `LastKnownContactPlane`, `LastKnownContactPlaneCellId`, and `LastKnownContactPlaneIsWater` (TransitionTypes.cs:258-261). Passing LKCP as the source means the re-latch is a no-op on those fields — functionally safe, but worth knowing if you later decide to inline the writes instead of using `SetContactPlane`.
**Note on the LKCP-update strategy divergence (Gap 3):** Retail's `validate_transition`
at `0050acff` does `last_known_contact_plane_valid = contact_plane_valid`
unconditionally — this means when contact is invalid and stays invalid, LKCP is
cleared. Our code at TransitionTypes.cs:2801 only updates LKCP when current CP
is valid (L.2.3c deliberate divergence from 2026-04-29 to prevent animation
flicker on failed step-ups). **Do not change this in slice 1** — the Mechanism B
`SetContactPlane` call above feeds into the standard contact-valid branch (lines
28012819), which then re-latches LKCP normally. The net effect is equivalent
to retail's unconditional overwrite in the success case, without the flicker
regression of clearing LKCP on transient failures.
---
## 6. Risk — First-Frame Fall-Through
**Scenario:** Player teleports into a new indoor cell (or crosses a cell
boundary). On frame 0 in the new cell: LKCP is invalid (no prior frame data),
BSP returns OK (no wall collision, player is standing on a floor poly). With
the synthesis path stripped (Task 5) and Mechanism B requiring a valid LKCP,
this frame will have `ContactPlane` invalid for the indoor case.
**Consequence:** Frame 0 post-cell-cross → `ContactPlane` invalid → outdoor
terrain fallback fires → ValidateWalkable evaluates outdoor terrain Z → outdoor
Z is below indoor floor (due to +0.02f Z-bump) → player appears 0.02+ m above
the outdoor plane → ValidateWalkable decides they're airborne → `OnWalkable=false`
→ falling animation for one frame. Retail avoids this via Mechanism A: when BSP
Path 6 (step-down/land) fires on the first indoor frame, it writes CP directly
from the floor polygon.
**Assessment for slice 1:** Mechanism A is already wired in `BSPQuery.FindCollisions`
(calls `SetContactPlane` at BSPQuery.cs lines 1204 + 1713 for Path 6). If the
player's foot sphere is close enough to a floor polygon on the first frame
(within `step_sphere_down`'s probe distance), Path 6 will write CP and LKCP
will be primed via the `ci.ContactPlaneValid` branch (TransitionTypes.cs:2801).
Frame 1 will have LKCP valid and Mechanism B can take over.
**Risk is LOW for normal walking** (player stays near the floor, Path 6 fires
on the first frame in any cell). Risk is HIGHER for teleport-into-air edge
cases where the player spawns slightly above the floor and the step-down probe
misses. Accept for slice 1; slice 2 (Mechanism C) adds a direct floor-plane
probe from the new cell's geometry on first entry, closing the gap completely.
**Mitigation hedge:** When stripping `TryFindIndoorWalkablePlane` in Task 5,
do NOT strip the `ValidateWalkable` call — keep it guarded by `walkableHit`
being true. The fall-through to outdoor terrain remains as a last-resort
backstop for the single-frame miss (wrong Z, one frame of falling animation,
then Mechanism A re-grounds on the next frame). This is one visible frame of
glitch vs the current 86,748 CP writes per walk sequence. Acceptable for
slice 1.
---
## Summary Table
| Item | Retail | Our Code (pre-fix) |
|---|---|---|
| `find_env_collisions` writes CP? | No — only via BSP Path 6 (Mechanism A) | Yes — synthesis path writes CP every frame indoors |
| Mechanism B location | `CTransition::validate_transition`, Collided/Slid/Adjusted branch | Present but INCOMPLETE — sets flags only, no `SetContactPlane` call |
| Mechanism B proximity sphere | `global_curr_center` (frame-start center) | `GlobalSphere[0].Origin` (check position — wrong) |
| LKCP update strategy | Unconditional overwrite | Only on valid CP (L.2.3c deliberate fix) |
| First-frame risk | Mechanism C closes; Mechanism A covers normal cases | Same risk; accept for slice 1 |
---
## References
- `acclient_2013_pseudo_c.txt` lines 309570309595 (`CEnvCell::find_env_collisions`)
- `acclient_2013_pseudo_c.txt` lines 272547272700 (`CTransition::validate_transition`)
- `src/AcDream.Core/Physics/TransitionTypes.cs` lines 27512873 (`ValidateTransition`)
- `src/AcDream.Core/Physics/TransitionTypes.cs` lines 15141777 (`FindEnvCollisions`)
- `src/AcDream.Core/Physics/PhysicsEngine.cs` lines 640692 (`RunTransitionResolve`)
- `src/AcDream.Core/Physics/BSPQuery.cs` lines 1204, 1713 (Mechanism A `SetContactPlane`)

View file

@ -1,402 +0,0 @@
# Collision fixes — session 2026-05-21 shipped handoff
**Status:** All 9 commits merged to **`main`** via fast-forward (HEAD `56d2b5e`).
Local main is ahead of `origin/main` (`7034be9`); not yet pushed.
The original session worktree (`claude/lucid-goldberg-1ba520`) is still
on disk but its branch is now identical to main and can be removed.
**Next session should branch from main into a fresh worktree.**
## TL;DR
User reported the world feeling buggy — collision in thin air inside
and outside buildings, walls walk-through-able in spots. A two-step
investigation surfaced a foundation-level math bug (`PolygonHitsSpherePrecise`
inverted vs retail) and four discrete registration / cell-tracking
bugs. **Four surgical fixes landed this session** (A1, A1.5, A1.6,
A1.7) plus a `[walk-miss]` / `[floor-polys]` diagnostic probe set that
quantified the bug rates. **What's left is one architectural change
(A4: multi-cell BSP iteration) and three smaller code-correctness
items.** Visual verification at the end of each phase confirmed
forward progress; remaining wall-walkthroughs in vestibule cells are
the A4 gap.
## What shipped this session
### Probe spike (3 commits)
| SHA | What | Why |
|---|---|---|
| `27c7284` | `ProbeWalkMissEnabled` flag + roundtrip test | Diagnostic gate for ISSUES #83 H-disambiguation |
| `31da57c` | `WalkMissDiagnostic` aggregator + 2 logic tests | Pure-function aggregator over `CellPhysics.Resolved` |
| `a2e7a87` | `[walk-miss]` + `[floor-polys]` emission sites | Wire flag + aggregator into `Transition.FindEnvCollisions` MISS branch + `PhysicsDataCache.CacheCellStruct` |
| `bb1e919` | Spec + plan + findings docs | The doc artifacts for the spike |
The walk-miss probe produced the **smoking-gun analysis** in
[`docs/research/2026-05-21-walk-miss-capture-findings.md`](2026-05-21-walk-miss-capture-findings.md):
0.38 % synthesis HIT rate, with a 2 cm boundary between HIT (`dz≈0.46 m`)
and MISS (`dz≈0.48 m`) at sphere radius 0.480 m. This proved
**`PolygonHitsSpherePrecise` is inverted vs retail's
`polygon_hits_sphere_slow_but_sure`** (BSPQuery.cs:117 vs
acclient_2013_pseudo_c.txt:322509-322517). That's Phase A2, still
pending.
### Collision fixes (4 commits)
| Phase | SHA | Fix |
|---|---|---|
| **A1** | `5f2b545` | **Skip mesh-AABB-fallback cylinder for landblock stabs.** Stabs (`entity.Id 0xC0XXYY00+n`) had their per-part BSP shadow correctly registered AND a redundant 1.5 m-clamped invisible cylinder at the mesh origin. The cylinder was the "thin air" collision inside cottages. Gate: `_isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u`. |
| **A1.5** | `4d3bf6f` | **Scope interior cell shadows to ParentCellId.** `ShadowObjectRegistry.Register` assigned every entity to outdoor landcells based on XY. Interior statics (fireplace, furniture in cell `0xA9B40121`) got stamped into the outdoor landcell whose XY they overlapped (e.g., `0xA9B40029`), firing collisions for players walking OUTSIDE the building. New optional `cellScope` parameter, passed `entity.ParentCellId ?? 0u` from all 5 entity-loop call sites. |
| **A1.6** | `700abad` | **Skip Setup CylSphere/Sphere shadows for landblock stabs.** A1 only gated the mesh-AABB-fallback path. Setup-derived registrations (lines 5910-6005 in GameWindow) still fired for stabs whose source is a Setup with CylSpheres. Same `_isLandblockStab` gate, extended to the outer `if (setup is not null)` block. |
| **A1.7** | `4679134` | **Fall through to outdoor cell when indoor BSP doesn't contain player.** `CellTransit.FindCellList` returns `currentCellId` when no candidate cell's `CellBSP` contains the sphere — but this also fired when the player walked OUTSIDE the entire portal-connected indoor graph. The player's CellId was stuck on an old indoor cell whose BSP was geometrically far away; every indoor-bsp query returned OK at the BSP root; no walls blocked. Fix: after `FindCellList`, verify with `PointInsideCellBsp`; if not inside, fall through to the existing outdoor resolution branch. |
### Visual verification at each phase
Each fix was visually verified by walking the same buildings before/after:
- **A1**: "thin air" inside cottage GONE.
- **A1.5**: "thin air" outside buildings → 71/97 interior-static-leak hits down to 0.
- **A1.6**: Setup-CylSphere bleed around buildings cleared.
- **A1.7**: cell-id correctly transitions between indoor doorway cell and adjacent outdoor cell on building exit.
## What's still broken
Per end-of-session user testing:
1. **Walls walk-through-able in "vestibule" cells.** Some interior cells (e.g., the Holtburg cell `0xA9B40164`) have very few physics polygons — only 4 polys, BSP bounding sphere of 2 m radius. When the player walks past the doorway, they're geometrically inside a *neighboring* cell's actual walls — but the collision check only queries the cell the player's center is "in." That cell (the vestibule) has no walls there. The neighboring cell's walls (e.g., `0xA9B40157` with 23 polys, 38 % hit rate when the player IS there) are never queried.
2. **Stairs walk-through.** Likely the same multi-cell iteration gap — stairs span cell boundaries.
3. **Lighting indoors broken.** Separate rendering concern; M7 polish.
4. **Items projecting spotlight on walls.** Per-entity light direction bug; M7 polish.
5. **PHSP inversion (A2).** Still pending. The `[walk-miss]` data proved this bug exists but fixing it alone doesn't fix walkable synthesis at the tangent boundary — needs to pair with synthesis removal (A3).
6. **Synthesis architecturally wrong (A3).** Retail's grounded path never re-synthesizes `ContactPlane`; it retains via Mechanisms A/B/C. Our `TryFindIndoorWalkablePlane` runs every frame and is the wrong shape. Removing it is Bug A from the 2026-05-20 session — was tried + reverted because retention had its own gaps. A1.7 closed one of those gaps; A2 + A4 close the others.
## The architectural picture (plain-English)
acdream's world is divided into invisible chunks called **cells**.
There are two flavors:
- **Outdoor cells**: the world is gridded into 24 m × 24 m squares. Each
landblock (the 192 m × 192 m unit of streaming) has 64 such cells in
an 8 × 8 grid. They get cell IDs like `0xA9B40029`.
- **Indoor cells**: each room (or section of room) inside a building
gets its own cell. They're not grid-aligned — they follow the
building's interior partitioning. Cell IDs have the high bit of the
low-16 set, e.g. `0xA9B40157`.
Each cell carries:
- A **CellBSP** — defines the volume the cell occupies in space (used
for "is this point inside this cell?" lookups during cell-id resolution).
- A **PhysicsBSP** — the collision geometry (walls, floors, stairs) the
player can hit.
- **Portals** — connections to adjacent indoor cells (think doorways).
- **Static objects** — furniture, decoration meshes hydrated as entities.
The collision system asks two things per frame:
1. **What cell is the player in?** Driven by `PhysicsEngine.ResolveCellId`
`CellTransit.FindCellList`. Walks the portal graph from the
current cell, picks the cell whose `CellBSP` contains the sphere
center. With **A1.7**, when no indoor cell claims the player, falls
through to outdoor landcell resolution.
2. **Does the player hit anything?** Drives `Transition.FindEnvCollisions`.
Queries the **one cell** the player is "in" — its `PhysicsBSP` for
walls/floor and its shadow-registered statics for furniture.
**The architectural gap** is step 2 only queries one cell. Retail
queries the **cell_array** — the sphere center's cell plus every
other cell the sphere geometrically overlaps. So if you're in a
vestibule cell with no real walls but your shoulder pokes into the
next room's wall, retail's collision sees the wall. acdream doesn't.
## Phase A4 — multi-cell iteration (the next big fix)
This is the gap. Implementation sketch:
### What to port from retail
`CTransition::check_other_cells` at `acclient_2013_pseudo_c.txt:272717-272798`.
After the primary cell's `find_collisions` runs, it iterates every
other cell in `this->cell_array` (built from `CObjCell::find_cell_list`
which fills via interior portals + `add_all_outside_cells` for outdoor
neighbors). For each cell:
- Calls the cell's vtable `find_collisions`.
- On Slid (4): clears `contact_plane_valid`, returns.
- On Collided (2) or Adjusted (3): returns immediately.
- On OK: continues to the next cell.
If the sphere is geometrically outside the original cell, the
fallback (line 272761-272797) sets `check_cell = var_4c` (the cell
containing the final position) and adjusts `check_pos.objcell_id`.
### What we already have
Phase 2 portal cell-tracking is shipped (commits `1969c55``eb0f772`,
2026-05-19). It gives us:
- `CellTransit.FindCellList` (sphere variant) — top-level driver.
- `CellTransit.FindTransitCellsSphere` — interior portal neighbour expansion.
- `CellTransit.AddAllOutsideCells` — outdoor landcell neighbour expansion.
- `CellPhysics.VisibleCellIds` — pre-computed visible-cell set per cell.
These currently feed **cell-id resolution** (step 1 above). They are
NOT yet used to drive **collision iteration** (step 2). A4's job is to
wire them into `Transition.FindEnvCollisions`.
### Implementation outline for A4
1. **In `Transition.FindEnvCollisions`** (`src/AcDream.Core/Physics/TransitionTypes.cs:1407-1559`):
- Currently: queries one cell (`engine.DataCache.GetCellStruct(sp.CheckCellId)`)
and runs `BSPQuery.FindCollisions` against its BSP.
- Change to: build the cell_array from the current cell using
`CellTransit.FindCellList` (or a new variant that returns the
full set), then iterate each cell and run BSP collision against
each. Combine results.
2. **Combine semantics** match retail's `check_other_cells`:
- Any cell returning `Collided` (2) or `Adjusted` (3) → return that
immediately (halt iteration).
- Any cell returning `Slid` (4) → record but continue (in case
another cell collides harder). After all cells: return Slid.
- All cells OK → return OK.
3. **Outdoor case**: if the resolved cell is outdoor, iterate adjacent
outdoor landcells via `AddAllOutsideCells` and any indoor cells
accessible via building portals (`CheckBuildingTransit`). Both
already exist as helpers.
4. **Shadow objects (the L.2d `[resolve-bldg]` path)** likely also need
multi-cell awareness — `FindObjCollisions` only checks shadows
keyed to the player's current cell. After A1.5, interior shadows
are scoped to their `ParentCellId`, so multi-cell iteration
automatically picks them up too.
5. **Testing strategy**:
- Unit tests: synthetic two-cell fixture where wall lives in cell B
and player is in cell A's vestibule. Assert collision fires.
- Live capture: walk the Holtburg inn vestibule (`0xA9B40164`) and
verify walls in `0xA9B40157` now block.
6. **Performance**: each cell query is ~50 µs. Multi-cell iteration
visits ~3-7 cells in worst case. ~200-350 µs extra per resolve.
At 30 Hz that's ~10 ms/sec. Acceptable.
### Risks
- **R1**: shadow objects in cells visible from multiple positions may
get tested multiple times in one frame. Need dedup via the existing
`_entityToCells` map.
- **R2**: cells in `cell_array` may have stale `CellPhysics` (loaded
for rendering but not for physics). Guard with `cellPhysics?.BSP?.Root is not null`.
- **R3**: the existing `BSPQuery.FindCollisions` mutates `Transition`
state (SpherePath.CheckPos, CollisionInfo). Running it multiple
times per frame requires either save/restore between cells or
letting the first-hit's mutations stand (matching retail).
## Other pending items
### Phase A2 — PHSP inversion fix
`BSPQuery.PolygonHitsSpherePrecise` at `BSPQuery.cs:117` has its
early-return condition inverted vs retail's `polygon_hits_sphere_slow_but_sure`
at `acclient_2013_pseudo_c.txt:322509-322517`. Ours bails when sphere
is FAR from plane; retail bails when sphere is OVERLAPPING plane.
The actual fix is one line, but it doesn't fix walkable synthesis on
its own (because `AdjustSphereToPlane` still rejects tangent). It DOES
affect wall-collision precision at the tangent boundary. Pair with A3
(synthesis removal) for the full benefit.
### Phase A3 — synthesis removal
Delete `TryFindIndoorWalkablePlane` (TransitionTypes.cs:1294) and rely
on the three retail CP retention mechanisms (Mechanisms A/B/C). The
previous session (2026-05-20) tried this and reverted because
multi-cell iteration was missing, so doorway transitions caused
free-fall. With A1.7 + A4 in place, A3 should work.
### Lighting bugs
- **Indoor lighting broken**: probably cell-light association or
visibility culling for lights inside cells.
- **Spotlight projection**: per-entity light direction transform.
These are M7 polish, separate phase. Not blocking M2 ("kill a drudge").
## How to start a fresh session
Open a new Claude Code session **in the main acdream worktree**
(`C:\Users\erikn\source\repos\acdream`, branch `main` at SHA `56d2b5e`
or later). Then paste the block below:
---
```
Pick up the acdream collision-fix work from the 2026-05-21 session.
1. Read docs/research/2026-05-21-collision-fixes-shipped-handoff.md
FIRST. It captures everything that shipped (4 fixes A1/A1.5/A1.6/A1.7
+ a probe spike) and what's left (Phase A4 multi-cell iteration is
the next major user-visible win).
2. All 9 commits from the previous session are merged into main
(HEAD 56d2b5e). Build green, no regressions in the 1129-test
baseline, four user-visible visual improvements verified live.
Local main is ahead of origin/main (origin at 7034be9, an older
commit); push only if explicitly desired.
3. **Set up isolation FIRST.** Use the superpowers:using-git-worktrees
skill to create a fresh worktree branched from main for the A4 work.
Do NOT work directly on main in the parent worktree. The previous
session's worktree (claude/lucid-goldberg-1ba520) can be removed —
its branch is identical to main now.
4. The next phase to design + ship is **A4 (multi-cell BSP iteration)**.
Sketch in §"Phase A4" of the handoff. Reads retail's
CTransition::check_other_cells (acclient_2013_pseudo_c.txt:272717-272798).
Wires the existing CellTransit helpers (FindCellList,
FindTransitCellsSphere, AddAllOutsideCells) into
Transition.FindEnvCollisions so collision is queried against ALL
cells the sphere overlaps, not just the one cell the player's
center is in.
5. CLAUDE.md rules apply:
- No workarounds. Retail-faithful.
- Probe-first, design-second. Already have [indoor-bsp] +
[cell-transit] + [cell-cache] probes available.
- Use the superpowers:brainstorming skill before writing code.
A4 is a real architectural change deserving its own spec.
- Visual verification at the Holtburg inn (cell 0xA9B40164
vestibule) is the acceptance test — walls in cell 0xA9B40157
should block when the player is "in" 0xA9B40164 but their sphere
extends into 0xA9B40157.
6. M2 ("kill a drudge") is the active milestone. Indoor walking
robustness is on the M2 critical path because dungeons have
drudges. A4 is the last big collision fix needed for M2's
"walkable indoor space" demo target.
7. Launch command (same as last session):
$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"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
dotnet build -c Debug
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-a4.log"
DO NOT set ACDREAM_PROBE_RESOLVE — it lagged the client last
session (400k+ log lines at 30 Hz).
State the milestone + chosen phase in your first action.
```
---
## Anti-patterns from this session
1. **Don't enable `ACDREAM_PROBE_RESOLVE` for live captures.** It
emits one line per resolve call at 30 Hz, producing 400k+ lines
per session and making the client laggy enough that the user
couldn't move. Use the lighter `[indoor-bsp]` + `[cell-transit]`
probes instead.
2. **Don't assume "walk through wall" means PHSP inversion.** This
session walked through that misconception twice. The actual cause
was different bugs each time (doubled cylinders, interior shadow
bleed, cell-id stuck, missing physics polys in vestibule cells).
Always capture probe data before designing fixes.
3. **Don't merge A1.5's pattern (`cellScope: entity.ParentCellId`)
without understanding that interior shadows might need MULTI-cell
scope, not just their parent cell.** A1.5 fixed the obvious leak
but introduced "stairs span cells" gaps. The real fix needs A4.
4. **Don't skip visual verification between fixes.** Each of A1,
A1.5, A1.6, A1.7 was visually confirmed before moving to the
next. The user reported what was still broken at each step,
which guided the next fix. Without that loop, we'd have shipped
a "fix" that broke something else.
5. **Don't try to fix lighting bugs in the same session as
collision bugs.** Different domain (rendering, not physics).
Defer to its own session.
## Code anchors
### This session's fixes (in commit order)
- [`src/AcDream.Core/Physics/PhysicsDiagnostics.cs:246-277`](src/AcDream.Core/Physics/PhysicsDiagnostics.cs:246) — `ProbeWalkMissEnabled` flag.
- [`src/AcDream.Core/Physics/WalkMissDiagnostic.cs`](src/AcDream.Core/Physics/WalkMissDiagnostic.cs) — pure-function aggregator (full file).
- [`src/AcDream.Core/Physics/TransitionTypes.cs:1543-1586`](src/AcDream.Core/Physics/TransitionTypes.cs:1543) — `[walk-miss]` emission.
- [`src/AcDream.Core/Physics/PhysicsDataCache.cs:222-238`](src/AcDream.Core/Physics/PhysicsDataCache.cs:222) — `[floor-polys]` emission.
- [`src/AcDream.App/Rendering/GameWindow.cs:5830-5839`](src/AcDream.App/Rendering/GameWindow.cs:5830) — `_isLandblockStab` flag (A1).
- [`src/AcDream.App/Rendering/GameWindow.cs:6062-6064`](src/AcDream.App/Rendering/GameWindow.cs:6062) — mesh-AABB-fallback gate (A1).
- [`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:34-92`](src/AcDream.Core/Physics/ShadowObjectRegistry.cs:34) — `cellScope` parameter (A1.5).
- [`src/AcDream.App/Rendering/GameWindow.cs`](src/AcDream.App/Rendering/GameWindow.cs) — 5 call sites pass `entity.ParentCellId ?? 0u` (A1.5).
- [`src/AcDream.App/Rendering/GameWindow.cs:5922-5933`](src/AcDream.App/Rendering/GameWindow.cs:5922) — `setup is not null && !_isLandblockStab` gate (A1.6).
- [`src/AcDream.Core/Physics/PhysicsEngine.cs:259-289`](src/AcDream.Core/Physics/PhysicsEngine.cs:259) — `PointInsideCellBsp` fall-through (A1.7).
### What A4 will touch
- [`src/AcDream.Core/Physics/TransitionTypes.cs:1407-1559`](src/AcDream.Core/Physics/TransitionTypes.cs:1407) — `FindEnvCollisions` (extend to iterate cell_array).
- [`src/AcDream.Core/Physics/CellTransit.cs`](src/AcDream.Core/Physics/CellTransit.cs) — already has the helpers; may need a new `EnumerateCells` variant that returns the set rather than picking one.
- [`src/AcDream.Core/Physics/PhysicsEngine.cs`](src/AcDream.Core/Physics/PhysicsEngine.cs) — `FindObjCollisions` may need similar treatment for shadow objects.
## Retail decomp anchors
- `acclient_2013_pseudo_c.txt:272717-272798``CTransition::check_other_cells` (A4 oracle).
- `:272565-272582``validate_transition` Mechanism B (LKCP proximity).
- `:273242-273340``transitional_insert` Mechanism C (step-down probe).
- `:322032-322077``CPolygon::adjust_sphere_to_plane`.
- `:322403-322500``CPolygon::polygon_hits_sphere`.
- `:322504-322593``CPolygon::polygon_hits_sphere_slow_but_sure` (A2 oracle — inversion).
- `:322974-322993``CPolygon::pos_hits_sphere` (front-face culling).
- `:323725-323939``BSPTREE::find_collisions` (full 6-path dispatcher).
- `:326211-326242``BSPNODE::find_walkable`.
- `:326706-326727``BSPLEAF::sphere_intersects_poly`.
- `:326793-326816``BSPLEAF::find_walkable`.
## Probe + diagnostic reference
| Env var | Volume | When to use |
|---|---|---|
| `ACDREAM_PROBE_INDOOR_BSP` | Low (indoor cells only) | Wall walk-through investigations. Logs `cell`, `wpos`, `lpos`, `result`, hit poly. |
| `ACDREAM_PROBE_CELL` | Very low (cell change events) | Cell-tracking issues. Logs old → new cell + position. |
| `ACDREAM_PROBE_CELL_CACHE` | One-shot per cell load | When you need cell BSP poly counts + bsphere. Identifies "vestibule" cells with sparse geometry. |
| `ACDREAM_PROBE_WALK_MISS` | High (per-frame MISS) | Walkable synthesis investigations (Phase A2/A3 work). |
| `ACDREAM_PROBE_BUILDING` | Medium | Building-shadow attribution. Multi-line `[resolve-bldg]` per hit. |
| `ACDREAM_PROBE_RESOLVE` | **VERY HIGH — DO NOT USE FOR LIVE PLAY** | Per-resolve attribution. 30 Hz × per-entity = 400k+ lines/session. Lagged the client this session. |
| `ACDREAM_PROBE_CONTACT_PLANE` | Medium | CP retention investigations. Bug B from 2026-05-20 era. |
### Log analysis recipe
```powershell
# 1. Convert UTF-16LE to UTF-8 for grep:
Get-Content launch.log -Encoding Unicode | Out-File launch.utf8.log -Encoding utf8
# 2. Quick counts:
grep -c '\[indoor-bsp\]' launch.utf8.log
grep -c '\[cell-transit\]' launch.utf8.log
# 3. Per-cell hit rate:
grep '\[indoor-bsp\] cell=0xA9B40164' launch.utf8.log | grep -oE 'result=[A-Za-z]+' | sort | uniq -c
```
## What this is NOT
This is **NOT** a complete fix for indoor walking. Walls walk-through-able
remain in cells where the PhysicsBSP has sparse coverage (vestibule
cells). A4 closes that gap by querying multiple cells per frame —
which is exactly what retail does.
This is **NOT** related to the PHSP inversion (A2). A2 fixes per-poly
overlap math precision at the tangent boundary. A4 fixes which cells
get queried. They're orthogonal.
This is **NOT** related to the lighting bugs the user reported. Those
are rendering-side; ignore in any collision work.
## References
- [`docs/research/2026-05-21-walk-miss-capture-findings.md`](2026-05-21-walk-miss-capture-findings.md) — probe spike findings.
- [`docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md`](../superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md) — probe spec.
- [`docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md`](../superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md) — A1 spec.
- [`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`](2026-05-20-indoor-walking-bug-a-handoff.md) — previous-session handoff (Bug B shipped, Bug A reverted).
- [`docs/research/2026-05-21-indoor-walking-doorway-investigation-prompt.md`](2026-05-21-indoor-walking-doorway-investigation-prompt.md) — the prompt that started this session.

View file

@ -1,207 +0,0 @@
# Indoor walking — doorway-edge investigation pickup prompt
**Status:** Bug B shipped today (`de8ffde`) — indoor BSP world-origin fix. Bug A attempted + reverted (`9f874f4``0a7ce8f`). Real bug is deeper than scoped: indoor cell floor polys don't cover the player's full XY range when crossing thresholds. ISSUES #83 stays OPEN.
This doc is the start-of-session brief for whoever picks up next.
---
## What's on the branch (`claude/sad-aryabhata-2d2479`)
10 commits ahead of `main`. See [`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`](2026-05-20-indoor-walking-bug-a-handoff.md) for the full table with KEEP/REMOVE recommendations.
**Shippable to main alone:** Bug B fix at `de8ffde` (closes corruption, has tests). The probe at `66de00d` is invaluable and should stay.
**Reverted-but-documented:** Bug A spec/plan/fix/revert. Useful as a "tried this, didn't work, here's why" record. Can clean up later.
---
## How to start a fresh session
Copy the block below into a fresh Claude Code session in this repo (or paste any subset — the boxed prompt is the meat):
---
```
Pick up the acdream indoor walking issue (ISSUES #83). The prior session
on 2026-05-20 shipped Bug B (BSP world-origin fix, de8ffde) but its
attempted Bug A (delete TryFindIndoorWalkablePlane) caused a worse
regression (fell through ground at doorways) and was reverted.
1. Read docs/research/2026-05-20-indoor-walking-bug-a-handoff.md FIRST.
It's comprehensive: what shipped, what failed, the probe evidence,
the deeper diagnosis (cell-geometry, not CP retention), and 5
prioritized investigation targets. The "Investigation targets for
next session" section is the entry point.
2. The current state of the branch is self-consistent post-Bug-B:
world-origin fix shipped, Phase 2 synthesis (TryFindIndoorWalkablePlane)
reinstated as it was before today's session. Indoor walking still
glitches (stuck-falling when brushing upper-floor edges) but doesn't
drop people into the void — that was Bug A's specific regression.
3. Bug A's premise was WRONG. Don't repeat it. "Just delete the
synthesis and trust BSP retention" doesn't work because:
- We already have all three retail CP retention mechanisms
(A: Path 6 land, B: LKCP proximity restore, C: post-OK step-down
probe). They're at BSPQuery.cs:1615, TransitionTypes.cs:2618,
TransitionTypes.cs:896 respectively.
- The actual failure mode is: at doorway thresholds, the indoor
cell's BSP has NO floor poly under the player's new XY. The
step-down probe (Mechanism C) fires correctly but finds nothing.
Step-down returns OK without writing CP. Mechanism B's
proximity check fails because the player moved laterally.
oi.Contact clears. Player free-falls.
4. The investigation priority (per the handoff):
a) PROBE/CDB FIRST. Attach Windows cdb to a live retail acclient
(CLAUDE.md "Retail debugger toolchain" section). Set a breakpoint
at BSPLEAF::find_walkable + BSPTREE::find_collisions. Walk the
SAME Holtburg cottage threshold the failed Bug A run captured.
Watch what retail's BSP iterates over. The answer is one of:
- retail's cell has more floor polys (our dat-decoder bug);
- retail's cell-id changes before the sphere reaches the edge
(our cell-transition timing lag);
- retail uses a portal-traversal mechanism we haven't ported.
b) Cross-reference WorldBuilder's EnvCellRenderManager and
PortalRenderManager to see how WB handles indoor cell boundaries
at thresholds.
c) Add a one-shot [cell-floor-coverage] probe that, when an indoor
cell is loaded, dumps the cell's floor poly count + their XY
bounding boxes. Then compare to the player's XY when step-down
misses. This isolates "no floor poly here" from "wrong floor
poly picked".
5. CLAUDE.md rules apply, especially:
- No workarounds or band-aids. Find the root cause.
- Probe-first, design-second. Don't ship a fix until probe data
validates the hypothesis. (Bug A failed because I skipped this
step for the R1 risk.)
- Visual verification is the acceptance test.
- Three failed visual verifications in a session = stop signal.
Write a handoff, don't push for a fourth.
- For investigation/audit requests, use the /investigate skill
(REPORT-ONLY mode) before touching code.
6. Launch command (same as before — both probes on, log to UTF-8 after):
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
$env:ACDREAM_PROBE_CONTACT_PLANE = "1"
$env:ACDREAM_DEVTOOLS = "1"
dotnet build -c Debug
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-doorway.log"
# Convert to UTF-8 after:
Get-Content launch-doorway.log -Encoding Unicode |
Out-File launch-doorway.utf8.log -Encoding utf8
7. M2 ("kill a drudge") is the active milestone. Indoor walking is on
the M2 critical path because dungeons have drudges, but it's
unscoped how many phases this investigation will burn. If this looks
like 3+ phases, consider asking the user whether to pivot to other
M2 work (F.2 / F.3 / F.5a / L.1c / L.1b) and defer indoor walking
to M7 polish.
State the milestone + chosen phase in your first action.
```
---
## Quick reference for the user
To start the new session: open a fresh Claude Code in the acdream worktree and paste the boxed prompt above. Or just say:
> "Read `docs/research/2026-05-21-indoor-walking-doorway-investigation-prompt.md` and start on the next phase."
### Key files for the helper
**Handoff (read first):**
- [`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`](2026-05-20-indoor-walking-bug-a-handoff.md) — full diagnosis + investigation targets.
**Specs/plans on the branch (for context, don't re-execute Bug A):**
- [`docs/superpowers/specs/2026-05-20-indoor-bsp-worldorigin-fix-design.md`](../superpowers/specs/2026-05-20-indoor-bsp-worldorigin-fix-design.md) — Bug B (shipped).
- [`docs/superpowers/specs/2026-05-20-indoor-walkable-synthesis-removal-design.md`](../superpowers/specs/2026-05-20-indoor-walkable-synthesis-removal-design.md) — Bug A (reverted, wrong-approach).
- [`docs/superpowers/plans/2026-05-20-indoor-bsp-worldorigin-fix.md`](../superpowers/plans/2026-05-20-indoor-bsp-worldorigin-fix.md).
- [`docs/superpowers/plans/2026-05-20-indoor-walkable-synthesis-removal.md`](../superpowers/plans/2026-05-20-indoor-walkable-synthesis-removal.md).
**Code anchors (Mechanisms A/B/C in our code):**
- `src/AcDream.Core/Physics/BSPQuery.cs:1615` — Mechanism A: Path 4 land + `set_contact_plane`.
- `src/AcDream.Core/Physics/TransitionTypes.cs:2618-2662` — Mechanism B: LKCP proximity restore.
- `src/AcDream.Core/Physics/TransitionTypes.cs:896-933` — Mechanism C: post-OK step-down probe.
- `src/AcDream.Core/Physics/TransitionTypes.cs:1442` — Bug B fix site (Matrix4x4.Decompose + worldOrigin pass).
- `src/AcDream.Core/Physics/TransitionTypes.cs:1294``TryFindIndoorWalkablePlane` (the duct-tape Bug A wanted to delete).
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs``[cp-write]`, `[indoor-bsp]`, `[indoor-walkable]` probe gates.
**Retail decomp anchors:**
- `acclient_2013_pseudo_c.txt:323725-323939``BSPTREE::find_collisions` full body.
- `:323924``set_contact_plane` write in Path 6 collide-path land.
- `:323565-323579``BSPTREE::step_sphere_up`.
- `:323665-323721``BSPTREE::step_sphere_down` (directly writes `contact_plane_valid = 1` at :323711).
- `:326211 / :326793``BSPNODE::find_walkable` + `BSPLEAF::find_walkable`.
- `:323006-323028``CPolygon::walkable_hits_sphere` (the slope filter + overlap test).
- `:272565-272578``validate_transition` LKCP proximity restore (Mechanism B in retail).
- `:273242-273307``transitional_insert` post-OK step-down probe (Mechanism C in retail).
- `:276183``init_contact_plane` (the seed equivalent of our PhysicsEngine.cs:583).
**Probe + diagnostic env vars:**
- `ACDREAM_PROBE_INDOOR_BSP=1` — one `[indoor-bsp]` line per indoor cell BSP query.
- `ACDREAM_PROBE_CONTACT_PLANE=1` — one `[cp-write]` line per CP/LKCP field mutation.
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per `PlayerMovementController.CellId` change.
- `ACDREAM_PROBE_BUILDING=1``[resolve-bldg]` lines for building BSP collision attribution.
---
## Visual verification scenarios (re-use for the next phase)
The same 5 scenarios from today, in order of severity:
1. **Cottage entry** (outdoor → indoor) — should be smooth.
2. **Cottage exit** (indoor → outdoor through the same doorway) — should NOT cause fall-through-ground. This is the regression Bug A introduced.
3. **2nd-floor walking** — should NOT get stuck in falling animation when brushing upper-floor edges. The original symptom we set out to fix.
4. **Cellar descent** — walk down stairs into a cellar. Should descend smoothly.
5. **Single-floor cottage walk** (regression check) — confirm M1 baseline holds.
Acceptance: at minimum, scenarios 1 + 2 + 5 work (no fall-through-ground). Scenarios 3 + 4 are the M2-blocking targets.
---
## Anti-patterns to avoid (carry forward from today)
1. **Don't re-attempt Bug A.** "Just delete the synthesis" doesn't work — the BSP genuinely has no floor poly past doorway thresholds. Some replacement is needed; what replacement is the open question.
2. **Don't trust the previous handoff's recommendation blindly.** The 2026-05-19 handoff said "delete TryFindIndoorWalkablePlane"; that recommendation was based on incomplete decomp analysis. Validate hypotheses against probe data BEFORE designing.
3. **Don't fix two related bugs in one phase.** Bug B + Bug A were both indoor-CP issues but had different root causes. Slicing them was the right call; the failure was Bug A's design.
4. **Don't skip a probe spike when a risk is flagged in the spec.** Bug A's R1 risk ("flat floor, no step-down momentary airborne") was the actual failure mode. A small probe spike to validate "will Mechanism C catch it?" before deleting the synthesis would have surfaced this.
5. **Stop at three failed visual verifications.** Today: stuck-falling (Bug B verification, pre-Bug-A) → can't exit building (Bug A first run) → fell through ground (Bug A second run). The third should have been the trigger to revert + handoff, not "let's gather more data".
---
## Launch command (with probes)
```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"
$env:ACDREAM_PROBE_CONTACT_PLANE = "1"
dotnet build -c Debug
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-doorway.log"
```
After the client closes, convert the UTF-16LE log to UTF-8 before grepping:
```powershell
Get-Content launch-doorway.log -Encoding Unicode |
Out-File launch-doorway.utf8.log -Encoding utf8
```
Then grep against `launch-doorway.utf8.log` from Bash.

View file

@ -1,324 +0,0 @@
# Open items pickup prompt — 2026-05-21 session aftermath
After the 2026-05-21 collision-fix session, six discrete items remain.
This doc gives a fresh session the full landscape: how the items
relate, what depends on what, what order makes sense.
The pasteable session-start prompt is at the bottom of this doc.
## The landscape at a glance (updated 2026-05-20 — MILESTONE PROMOTION)
**Status as of end of 2026-05-20 session:** the original 6-item landscape
has been promoted to a milestone of its own — **M1.5 "Indoor world feels
right"** — opened today. M2 ("Kill a drudge") is deferred until M1.5 lands.
Today's session shipped a 5-fix M1.5 baseline (A4 + #89 + #90 workaround +
#91 + #92) closing the user-visible "walls walk through at Holtburg inn"
symptom; the proper root-cause fix (BSP push-back distance investigation +
synthesis removal) is the actual M1.5 work.
| # | Item | Domain | M1.5 phase | Status |
|---|---|---|---|:---:|
| A4 | Multi-cell BSP iteration — walls in adjacent cells too | Collision | (baseline, shipped) | ✅ CLOSED 2026-05-20 |
| #89 | Sphere-overlap in CheckBuildingTransit | Collision | (baseline, shipped) | ✅ CLOSED 2026-05-20 |
| #90 | CellId ping-pong at doorway threshold (workaround in place) | Collision — cell tracking | A6.P4 (workaround removal) | ⚠ WORKAROUND |
| #91 | Indoor cell shadows in FindObjCollisions | Collision | (baseline, shipped) | ✅ CLOSED 2026-05-20 |
| #92 | Server cell id at player-mode entry | Cell tracking | (baseline, shipped) | ✅ CLOSED 2026-05-20 |
| #83 | Indoor multi-Z walking (cellars, 2nd floors) — UMBRELLA | Physics | A6.P1-P3 | OPEN (M1.5 primary) |
| stairs | Stairs walk-through + stuck-in-falling | Physics | A6.P1-P3 (subsumed by #83) | OPEN |
| 2nd-floor / cellar | Multi-Z navigation | Physics | A6.P1-P3 (subsumed by #83) | OPEN |
| `TryFindIndoorWalkablePlane` | Per-frame CP synthesis (99.87% MISS) | Physics — synthesis removal | A6.P4 | OPEN (workaround) |
| #88 | Indoor static objects vibrate | Physics — sub-step state | A6 (suspected family) | OPEN |
| **#93** | Indoor lighting broken (UMBRELLA — new) | Lighting | A7.L1-L3 | OPEN (M1.5 primary) |
| **#94** | Held items project spotlight on walls (new) | Lighting | A7.L1-L3 | OPEN |
| #80 | Camera on 2nd floor goes dark | Lighting | A7.L1-L3 | OPEN |
| #81 | Static building stabs don't react to atmospheric lighting | Lighting | A7.L1-L3 | OPEN |
| A2 | PHSP inversion | Collision math | post-M1.5 (Low) | OPEN |
## Two domains, one critical-path chain
The 6 items split cleanly:
**Domain 1 — Collision (M2 critical path).** A4, stairs, A2, A3.
These block "kill a drudge" because dungeons have drudges and dungeons
have walls/stairs/floors that need to behave correctly. The dependency
chain is:
```
A4 (multi-cell iteration)
┌──────────┴──────────┐
▼ ▼
stairs (verify A3 (remove
post-A4) synthesis,
relies on
A4 retention)
A2 (PHSP fix
— also useful
standalone)
```
A4 is the biggest user-visible win and it unblocks A3. A2 is a small
self-contained correctness fix that pairs naturally with A3. Stairs
are likely an A4 side-effect.
**Domain 2 — Rendering (M7 polish).** L-indoor + L-spotlight. These
don't affect gameplay correctness — the world just looks wrong.
Different code paths (lighting, not physics), different files,
different domain knowledge. Best tackled in their own session,
ideally after collision is solid so the visual artifacts are easier
to isolate.
## Why this order
1. **A4 first.** Biggest user-visible improvement. Closes the
"vestibule cells don't block walls" gap by querying every cell
the sphere overlaps, not just the one cell the player's center
is in. Retail oracle: `CTransition::check_other_cells` at
`acclient_2013_pseudo_c.txt:272717-272798`. Existing
`CellTransit.FindCellList` already enumerates the right cells;
A4 wires that into `FindEnvCollisions`. Probably 1-2 days.
2. **Verify stairs after A4.** If A4 closes vestibule walls, it
probably also closes stairs (same architectural gap — stairs
span cells). If stairs still walk-through after A4, investigate
per-cell physics-poly coverage for stair geometry as a separate
sub-issue.
3. **A2.** One-line flip in `PolygonHitsSpherePrecise`
([BSPQuery.cs:117](src/AcDream.Core/Physics/BSPQuery.cs:117)) plus
a unit test for the tangent boundary. Improves correctness across
every BSP query (walls, step-up, step-down). Pairs cleanly with A3.
4. **A3.** Once A4 + A2 land, the architectural cleanup becomes safe.
Delete `TryFindIndoorWalkablePlane` (TransitionTypes.cs:1294) and
the synthesis call site. Retail's grounded path doesn't synthesize
CP — it retains via Mechanisms A/B/C (already in our code at
BSPQuery.cs:1615, TransitionTypes.cs:2618, TransitionTypes.cs:896).
The 2026-05-20 session tried A3 prematurely (Bug A) and reverted
because the doorway-exit case relied on multi-cell iteration that
wasn't there. A4 closes that gap.
5. **Lighting (M7).** Separate session. Different domain. Defer until
M2 ships.
## Per-item starter notes
### A4 — multi-cell BSP iteration
**Read first:**
- §"Phase A4" of `docs/research/2026-05-21-collision-fixes-shipped-handoff.md`
- `acclient_2013_pseudo_c.txt:272717-272798` (`check_other_cells`)
- [`src/AcDream.Core/Physics/TransitionTypes.cs:1407-1559`](src/AcDream.Core/Physics/TransitionTypes.cs:1407) (`FindEnvCollisions` — change site)
- [`src/AcDream.Core/Physics/CellTransit.cs`](src/AcDream.Core/Physics/CellTransit.cs) (helpers already exist)
**Approach:**
- Extract a "cell_array" set from the player's current cell via the
existing CellTransit BFS.
- Iterate each cell, run `BSPQuery.FindCollisions` against each one.
- Combine results: any cell returning Collided/Adjusted halts; any
cell returning Slid is remembered; all OK = return OK.
**Acceptance:** Walk into the Holtburg inn vestibule (cell `0xA9B40164`).
Walls in cell `0xA9B40157` should now block when the player's sphere
extends into them, even though the player's center is still in the
vestibule.
### Stairs walk-through
**Strategy:** verification-only, after A4. Launch with the same probe
set, walk up the inn stairs, watch the `[indoor-bsp]` results. If
stair hits fire correctly, done. If not, investigate the cell's
physics-poly data — stairs may be packed as static objects rather
than cell-structure polys.
### A2 — PHSP inversion fix
**Bug:** [`BSPQuery.cs:117`](src/AcDream.Core/Physics/BSPQuery.cs:117)
has `if (MathF.Abs(dist) > rad) return false;` — bails when sphere
is FAR from plane. Retail's `polygon_hits_sphere_slow_but_sure` at
`acclient_2013_pseudo_c.txt:322509-322517` does the opposite — bails
when sphere is OVERLAPPING plane.
**Fix:** flip the comparison. New unit test for the tangent boundary
(sphere center at `radius` distance from plane → continue, not
reject).
**Caveat:** doesn't fix walkable synthesis on its own —
`AdjustSphereToPlane` also rejects at the tangent boundary (strict
`<` check on interp). The two together gate the synthesis. Fixing A2
alone changes which side of the boundary the rejection happens on
but doesn't close the gap. Pair with A3 for the full benefit.
**Read first:**
- §"Phase A2" of `docs/research/2026-05-21-collision-fixes-shipped-handoff.md`
- `docs/research/2026-05-21-walk-miss-capture-findings.md` (the
smoking-gun analysis of the 2 cm boundary)
### A3 — synthesis removal
**Bug:** retail's grounded path doesn't re-synthesize ContactPlane.
It retains via three mechanisms (Path 4 land, LKCP proximity restore,
post-OK step-down probe — all already in our code). Our
`TryFindIndoorWalkablePlane` runs every frame and is unfaithful.
**Fix:** delete `TryFindIndoorWalkablePlane` ([TransitionTypes.cs:1294](src/AcDream.Core/Physics/TransitionTypes.cs:1294))
and its call site. ~500 lines deleted.
**Critical prerequisite:** A4 must ship first. The 2026-05-20 session
tried A3 prematurely (Bug A) and reverted because doorway transitions
caused free-fall — Mechanism C couldn't find a floor poly at the
threshold because the indoor cell's BSP had no coverage past the
doorway, and multi-cell iteration wasn't there to query the adjacent
cell.
**Read first:**
- `docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`
(Bug A's premise + reversion)
- §"Phase A3" of the 2026-05-21 handoff
### L-indoor — lighting broken inside
**Symptom:** lights inside buildings don't illuminate correctly.
**Likely areas:**
- Cell-light association (which lights belong to which cell)
- Light visibility culling (visible-cells set + light bounds)
- Per-light projection matrix indoors
**Domain:** rendering, not physics. Separate session.
### L-spotlight — items projecting spotlight on walls
**Symptom:** held items (torches, etc.) project spotlight effects
onto walls in unexpected directions.
**Likely areas:**
- Per-entity light direction transform
- LightingHookSink owner-tracking
**Domain:** rendering, not physics. Separate session.
## CLAUDE.md rules to remember
1. **Work-order autonomy.** You pick what to work on. Recommended
order above but adjust if you find something blocking.
2. **No workarounds, retail-faithful.** Same rule that drove A1
through A1.7. If a fix starts to look like a band-aid, stop.
3. **Probe-first, design-second.** Already have rich probes
(`[indoor-bsp]`, `[cell-transit]`, `[cell-cache]`,
`[walk-miss]`, `[floor-polys]`, `[resolve-bldg]`). Capture before
theorizing.
4. **Visual verification is the acceptance test.** Walk the building
after each fix.
5. **Stop signals.** Three failed visual verifications in a session =
write a handoff, don't push for a fourth.
6. **Don't enable `ACDREAM_PROBE_RESOLVE` for live play.** It lagged
the client last session (400k+ lines at 30 Hz).
7. **Subagent policy: Sonnet for implementers, Opus only for
load-bearing review.**
8. **Worktrees.** Use the `superpowers:using-git-worktrees` skill to
create a fresh worktree branched from main before touching code.
## Launch command (light probes only)
```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"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
dotnet build -c Debug
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch.log"
```
UTF-16LE → UTF-8 conversion for grep:
```powershell
Get-Content launch.log -Encoding Unicode |
Out-File launch.utf8.log -Encoding utf8
```
---
## The pasteable session-start prompt
Open a new Claude Code session in the main acdream worktree
(`C:\Users\erikn\source\repos\acdream`, branch `main` at SHA
`f80b537` or later). Then paste:
---
```
Pick up the acdream open-items cleanup. After the 2026-05-21 session,
6 items remain across collision + rendering.
1. Read docs/research/2026-05-21-open-items-pickup-prompt.md FIRST.
It maps the 6 items, their dependencies, and the recommended
order (A4 → verify-stairs → A2 → A3 → lighting in a separate
session). Each item has its own "Read first" anchor list inside.
2. Branch state: main is at f80b537 with all 2026-05-21 fixes
landed (A1, A1.5, A1.6, A1.7 + probe spike + handoff docs).
Build green, 1129-test baseline holds, four user-visible
improvements visually verified. Local main is ahead of
origin/main (origin at 7034be9); push only if explicitly asked.
3. **Set up isolation FIRST.** Use the superpowers:using-git-worktrees
skill to create a fresh worktree from main. Don't work directly
in the parent worktree. The 2026-05-21 session's worktree
(claude/lucid-goldberg-1ba520) is identical to main and can be
removed.
4. Recommended first phase: **A4 (multi-cell BSP iteration)**. It
has the biggest user-visible payoff (closes vestibule-cell wall
walk-through, likely closes stairs too) and unblocks A3
architectural cleanup. Retail oracle is at
acclient_2013_pseudo_c.txt:272717-272798 (CTransition::check_other_cells).
Existing CellTransit helpers (FindCellList, FindTransitCellsSphere,
AddAllOutsideCells) already enumerate the right cells; A4's
work is wiring them into Transition.FindEnvCollisions.
5. Use the superpowers:brainstorming skill before writing A4 code.
A4 is a real architectural change (multi-day, 2 files modified +
tests) and deserves its own spec + plan. Don't shortcut it.
6. CLAUDE.md rules:
- No workarounds. Retail-faithful.
- Probe-first, design-second.
- Visual verification at Holtburg inn cell 0xA9B40164 vestibule
is the A4 acceptance test (walls in adjacent cell 0xA9B40157
should block when the player straddles the boundary).
- Don't enable ACDREAM_PROBE_RESOLVE for live play (lags the
client). Use [indoor-bsp] + [cell-transit] + [cell-cache] only.
- Three failed visual verifications = handoff, not a fourth attempt.
7. M2 ("kill a drudge") is the active milestone. A4 + stair
verification + A2 + A3 are all on the M2 critical path because
dungeons need walkable indoor space. Lighting is M7 polish;
defer.
8. Launch command (light probes only):
$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"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
dotnet build -c Debug
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch.log"
State the milestone + chosen phase in your first action.
```

View file

@ -1,179 +0,0 @@
# Phase O — Task O-T1 — Session Handoff Prompt
**Copy everything below the line into a fresh Claude Code session.**
The prompt is self-contained — the new session reads it once and has
all the context it needs.
---
You are starting **Phase O — Task O-T1** on acdream. Phase O is **active**
(pre-empts M1.5 by user direction 2026-05-21). The full phase spec is at:
```
docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md
```
**Read that spec first** (sections 1-4 are the most relevant to this task).
Also read the auto-loaded CLAUDE.md "Currently working toward: Phase O" block.
## What you're doing
Phase O extracts the WorldBuilder code we actually use into our own repo
so we can drop the project references and run with **one dat reader**
(`DatCollection`) instead of two. Task O-T1 is the safety pass that
must happen BEFORE any extraction: produce a closure of every WB type
and file we transitively use, so T2T7 don't miss a dependency and
break the build at the "drop project references" step.
**This is the `/investigate` skill territory — REPORT-ONLY.** Do not
move files, do not edit source, do not run `dotnet build/test`. Read
files, run read-only diagnostics (`grep`, `git`, `find`), and produce
a single markdown audit document. The user will review and approve
before any extraction starts in a later session.
## Inputs available to you
- **Our code that consumes WB**:
- `src/AcDream.App/Rendering/Wb/*.cs` — adapters that wrap WB.
Start with `WbMeshAdapter.cs` and `WbDrawDispatcher.cs`.
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — uses some
WB types but is our own renderer.
- Anywhere else with `using WorldBuilder.*` or
`using Chorizite.OpenGLSDLBackend*`. Grep for both.
- **The WB source itself**:
- `references/WorldBuilder/WorldBuilder.Shared/` — types we use directly
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/` — the rendering code
we use (`ObjectMeshManager`, `TextureHelpers`, `SceneryHelpers`,
`LandSurfaceManager`, `OpenGLGraphicsDevice`, `Frustum`, etc.)
- **CSProj files** (the formal dependency boundary):
- `src/AcDream.App/AcDream.App.csproj` — has the WB ProjectReference entries
- `src/AcDream.Core/AcDream.Core.csproj` — same
## Concrete steps
1. **Direct-use enumeration.** Find every `using WorldBuilder.*` and
`using Chorizite.OpenGLSDLBackend*` line in `src/AcDream.*`. For each
file, list the specific WB types it references (e.g.,
`ObjectMeshManager`, `OpenGLGraphicsDevice`, `TextureKey`,
`ObjectRenderBatch`). Don't forget `nameof()` and string references
in tests.
2. **Transitive closure.** For each WB type in the direct-use set, open
its file in `references/WorldBuilder/` and list its dependencies on
*other* WB types. Walk the graph until closed. Be systematic —
`ObjectMeshManager` will pull on a lot.
3. **Per-file inventory.** For each WB file in the closure:
- Path (relative to `references/WorldBuilder/`)
- Line count (`wc -l`)
- One-line role description
- Direct callers in our codebase (if any) or in WB (if transitive)
- Whether it touches `IDatReaderWriter` / `DefaultDatReaderWriter`
(these become DatCollection-swap candidates)
- Whether it touches GL state directly (matters for T3 — GL infra
extraction has different risk than dat-touching code)
4. **Categorize by extraction task**. Bucket each file into one of:
- **T3 candidate** (texture / GL infrastructure, no dat dep)
- **T4 candidate** (mesh pipeline)
- **T5 candidate** (scenery / terrain blending)
- **T6 candidate** (EnvCell / portal)
- **NOT EXTRACTED** (we use it via project reference today but
can drop entirely — e.g., the WB editor tools, anything not in
our render path). Justify the "not extracted" call for each.
5. **Hidden-dependency probes** (the things that bite at T7):
- Does WB use any `internal` types that are only accessible to
classes inside the WB project? If so, we'll have to make them
`public` or copy more.
- Does WB use any source generators, .targets files, or build
hooks that we'd need to replicate?
- Are there resource files (.png, .glsl, .shader) we need to copy?
- Does WB's `DefaultDatReaderWriter` differ from our `DatCollection`
in any behavioral way that would matter when we swap? (Caching,
thread safety, the same-file-different-handle thing, etc.)
Don't fully audit — just flag the questions.
6. **Thread-model spot-check** (open question O-Q1 in the spec):
- Is `ObjectMeshManager` called from the render thread only, or
does our `LandblockStreamer` worker thread call into it?
`grep` for the call sites. If the worker thread reaches WB code,
flag it — we may have a latent race today that becomes obvious
after extraction.
## Output
Write the audit to:
```
docs/research/2026-05-21-phase-o-t1-wb-audit.md
```
Suggested structure:
```markdown
# Phase O — Task O-T1 — WB Usage Audit
## Direct use surface
(Table of (our file, WB types used))
## Transitive closure
(Tree or table walking from direct types through WB internals)
## Per-file inventory
(Table: path, LOC, role, dat-touch?, GL-touch?, target task bucket)
## Extraction-task mapping
- **T3 (GL infra):** N files, ~M LOC
- **T4 (mesh):** N files, ~M LOC
- **T5 (scenery/terrain):** N files, ~M LOC
- **T6 (EnvCell/portal):** N files, ~M LOC
- **NOT extracted:** N files, with justifications
## Hidden-dependency risks
(Bulleted list of things flagged in step 5)
## Thread-model finding
(Yes/no + evidence on whether WB is called from the worker thread)
## Open questions for user
(Anything unclear that the user needs to call before T2 starts)
```
**Cap the audit doc at ~500 lines.** If the closure is bigger than that,
inline the per-file table for the top 30 files by LOC and link to a
separate appendix file for the long tail.
## Acceptance for O-T1
- Audit doc exists at the path above.
- Every file in the closure is bucketed (T3 / T4 / T5 / T6 / NOT).
- Hidden-dependency risks section has at least the four items in
step 5 addressed (even if the answer is "none found").
- No source code edited. No `dotnet build/test`. No project files
touched. (Verify with `git status` at the end — only the new
audit doc + maybe `git ls-files | grep WorldBuilder | wc -l`-style
diagnostic output should appear.)
## When you're done
Report back in chat with:
1. Total LOC of the closure (the rough size of the extraction).
2. The bucket breakdown (how many files / LOC per task).
3. Any sharp edges flagged in the hidden-dependency risks section.
4. Your honest read on whether the 7-8 day estimate in spec §5 is
reasonable given what you found, or whether it needs a revision.
**Then stop.** Do not start T2. The user reviews the audit before
extraction begins.
## Reminders
- You are operating under the `/investigate` skill rules (no edits).
- `references/WorldBuilder/` is the source to grep through, NOT to edit.
- WB is MIT-licensed; the eventual extraction is license-clean. You
don't need to think about that here.
- If something in the audit surfaces a reason Phase O shouldn't
proceed (e.g., WB has a license clause we missed, or the closure
is 50K LOC instead of 5K), say so clearly. We'd rather know now.

View file

@ -1,456 +0,0 @@
# Phase O — Task O-T1 — WB Usage Audit
**Status:** REPORT-ONLY (no source edits, no project changes).
**Filed:** 2026-05-21.
**Purpose:** Closure of every WorldBuilder type and file acdream transitively
uses, so T2T7 don't miss a dependency at the "drop project references" step.
> **Headline:** Reachable closure from acdream is **33 files / ~7.7 K LOC**
> (~6.8 K from `Chorizite.OpenGLSDLBackend`, ~0.9 K from `WorldBuilder.Shared`).
> Substantially smaller than the spec's §4 component list anticipated, because
> three of the spec's named components (`TerrainRenderManager`,
> `LandSurfaceManager`, `EnvCellRenderManager` / `PortalRenderManager`) are
> **not in our actual call graph** — we already replaced or never used them.
> The 7-8 day estimate in spec §5 looks **reasonable**, possibly conservative.
> O-Q1 (thread-model) verdict: **SAFE** — no worker-thread access to WB code.
Subreports (long-tail per-file tables) by parallel agents:
- `2026-05-21-phase-o-t1-wb-audit-chorizite-closure.md` — Chorizite full per-file table
- `2026-05-21-phase-o-t1-wb-audit-shared-closure.md` — WB.Shared per-file table
- `2026-05-21-phase-o-t1-wb-audit-thread-and-hidden.md` — thread model + hidden deps
- `2026-05-21-phase-o-t1-wb-audit-extensions-and-rest.md` — broad reachability sweep
> Note: those subreport file *names* are referenced in the parent agents'
> output but the Explore agent type lacks `Write`, so the files were not
> persisted to disk. The relevant content has been pulled into the summary
> tables and inventory below.
---
## 1. Direct use surface
Files in `src/` and `tests/` that actually import a WorldBuilder type
(`using WorldBuilder.*` or `using Chorizite.OpenGLSDLBackend*` or fully-
qualified). Comment-only WB references (six files in src/ — TerrainBlending,
SurfaceInfo, GfxObjMesh, SkyRenderer, SamplerCache, GameWindow) are
excluded; they describe ports done by hand and don't carry a project
dependency.
### Production source — `src/`
| File | LOC | WB types used | Notes |
|---|---|---|---|
| [`src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`](src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs) | 349 | `OpenGLGraphicsDevice`, `DefaultDatReaderWriter`, `ObjectMeshManager`, `ObjectRenderData`, `DebugRenderSettings` | Single seam; constructs WB pipeline; **also constructs `_wbDats = new DefaultDatReaderWriter(datDir)` at line 79** (the second-reader smell Phase O closes) |
| [`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`](src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs) | ~1500 | `ObjectRenderData`, `ObjectRenderBatch` | Production draw path (modern MDI); uses our own `DrawElementsIndirectCommand` struct + our own `mesh_modern.{vert,frag}` shaders |
| [`src/AcDream.Core/World/WbSceneryAdapter.cs`](src/AcDream.Core/World/WbSceneryAdapter.cs) | 55 | `TerrainEntry` | Bridges `LandBlock``TerrainEntry[81]` for WB scenery helpers |
| [`src/AcDream.Core/World/SceneryGenerator.cs`](src/AcDream.Core/World/SceneryGenerator.cs) | 196 | `SceneryHelpers.Displace/CheckSlope/ObjAlign/RotateObj/ScaleObj`, `TerrainUtils.OnRoad/GetNormal` | Procedural scenery placement; calls WB stateless helpers |
| [`src/AcDream.Core/Textures/SurfaceDecoder.cs`](src/AcDream.Core/Textures/SurfaceDecoder.cs) | 219 | `TextureHelpers.FillIndex16/FillP8/FillA8/FillA8Additive/FillA8R8G8B8/FillR8G8B8/FillR5G6B5/FillA4R4G4B4` | Pure pixel-format decoders |
### Test source — `tests/`
| File | WB types used | Notes |
|---|---|---|
| [`tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`](tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs) | `TextureHelpers.Fill*` | Byte-identity tests vs WB; should stay after extraction (validates our copy matches the original) |
| [`tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`](tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs) | `TerrainUtils.CalculateSplitDirection`, `CellSplitDirection` | One-time sweep test that compared WB's formula with retail's; **safely deletable after extraction** (test already informed the N.5b decision — its job is done) |
### Project files
| File | Lines | What |
|---|---|---|
| [`src/AcDream.App/AcDream.App.csproj`](src/AcDream.App/AcDream.App.csproj) | 38-39 | `<ProjectReference>` to `WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend` |
| [`src/AcDream.Core/AcDream.Core.csproj`](src/AcDream.Core/AcDream.Core.csproj) | 27-28 | same |
These two csproj entries are what T7 deletes after extraction.
---
## 2. Transitive closure (summary)
Closure walked from the direct-use surface above, following only WB
project-internal types. NuGet packages (`Chorizite.Core`,
`Chorizite.DatReaderWriter`, `Silk.NET.*`, `BCnEncoder.Net`,
`SixLabors.ImageSharp`, `MemoryPack`) stay as `<PackageReference>` and are
**not** part of the extraction scope.
| Source project | Files reachable | LOC | Notes |
|---|---|---|---|
| `Chorizite.OpenGLSDLBackend` | **28** | **~6,829** | Entry points: `ObjectMeshManager`, `OpenGLGraphicsDevice`, `TextureHelpers`, `SceneryHelpers`, `ObjectRenderData/Batch`, `DebugRenderSettings` |
| `WorldBuilder.Shared` | **5** | **876** | Entry points: `TerrainEntry`, `TerrainUtils`, `CellSplitDirection`, `DefaultDatReaderWriter`, `IDatReaderWriter` |
| **Total reachable** | **33** | **~7,705** | |
Files in WB **not** in our reachable closure (informational; do NOT
extract): all of `WorldBuilder/`, `WorldBuilder.Server/`, the platform
projects (`WorldBuilder.Windows/Linux/Mac`); inside `WorldBuilder.Shared/`:
`Hubs/` (SignalR), `Migrations/` (EF Core), `Repositories/`, the editor
`Services/` (DocumentManager, SyncService, etc.), `Modules/Landscape/Tools/`
(brush/painting), `Modules/Landscape/Commands/` (undo/redo); inside
`Chorizite.OpenGLSDLBackend/`: `FontRenderer`, `AudioPlaybackEngine`,
`MinimapRenderer`, `BackendGizmoDrawer`, the entire editor scene/camera
subsystems, and **critically: `TerrainRenderManager`, `LandSurfaceManager`,
`EnvCellRenderManager`, `PortalRenderManager`** — see §4 below.
---
## 3. Per-file inventory (top 30 by LOC)
DAT-TOUCH = file references `IDatReaderWriter` / `_dats.Get<T>()` / opens
dat data. GL-TOUCH = file calls Silk.NET.OpenGL directly or writes shader
binds / texture uploads. Bucket per Phase O spec (T3T6, REPLACE for the
dat-readers we drop, NOT for files in the spec's §4 list that turn out to
not be reachable).
### From `WorldBuilder.Shared/` (5 files, 876 LOC) — ALL EXTRACTED
| Path (rel. `references/WorldBuilder/`) | LOC | Role | DAT? | GL? | Bucket |
|---|---|---|---|---|---|
| `WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs` | 258 | Static helpers: `GetHeight`, `GetNormal`, `OnRoad`, `CalculateSplitDirection` | indirect (caller passes Region) | no | **T5** |
| `WorldBuilder.Shared/Modules/Landscape/Models/TerrainEntry.cs` | 248 | Packed per-vertex terrain struct (uses `[MemoryPackable]`) | no | no | **T5** |
| `WorldBuilder.Shared/Services/DefaultDatReaderWriter.cs` | 215 | Concrete dat reader (4 dbs, cache, file handles) | yes | no | **REPLACE** (delete; route through DatCollection) |
| `WorldBuilder.Shared/Services/IDatReaderWriter.cs` | 137 | Dat access interface + `IdResolution` record | yes | no | **REPLACE** (delete) |
| `WorldBuilder.Shared/Modules/Landscape/Models/CellSplitDirection.cs` | 18 | `SWtoNE` / `SEtoNW` enum | no | no | **T5** |
### From `Chorizite.OpenGLSDLBackend/` (28 files, ~6,829 LOC reachable) — TOP 10 by LOC
(Full table in the chorizite-closure subreport; only the top by LOC and a
few key smaller files are inlined here so this doc stays under 500 lines.)
| Path (rel. `references/WorldBuilder/`) | LOC | Role | DAT? | GL? | Bucket |
|---|---|---|---|---|---|
| `Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs` | ~2079 | The hub: reads GfxObj/Setup/Palette from dats; decodes textures; manages GPU resources; modern-rendering global buffers | **yes** | yes | **T4** |
| `Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs` | ~625 | Silk.NET wrapper: GL ctx, ProcessGLQueue, render-state | no | yes | **T3** |
| `Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` | ~variable | INDEX16 / P8 / A8 / DXT decode helpers (pure functions) | no | no | **T3** |
| `Chorizite.OpenGLSDLBackend/Lib/SceneryHelpers.cs` | small | `Displace` / `RotateObj` / `ScaleObj` / `ObjAlign` / `CheckSlope` (pure) | no | no | **T5** |
| Particle emitter + batcher (T4 supporting) | ~combined 1,200 | Particle pipeline used by ObjectMeshManager | maybe | yes | **T4** |
| Global mesh buffer (modern rendering) | ~500 | Single global VAO/VBO/IBO for modern path | no | yes | **T4** |
| ManagedGL{Texture, TextureArray, VertexBuffer, IndexBuffer, VertexArray, FrameBuffer, UniformBuffer} | ~150-300 ea | Per-resource GL lifecycle wrappers | no | yes | **T3** |
| `GLSLShader.cs`, `GLHelpers.cs`, `GLStateScope.cs` | ~100-300 ea | Shader compile + GL state utility | no | yes | **T3** |
| `Lib/ObjectRenderBatch.cs`, `Lib/ObjectRenderData.cs` | small | Per-batch + per-object render data structs | no | no | **T4** |
| `Lib/DebugRenderSettings.cs` | small | Settings shape passed to OpenGLGraphicsDevice ctor | no | no | **T3** |
**Approximate bucket totals across the full Chorizite closure** (28 files):
- **T3 (GL infra, no dat dep)**: 14 files, ~2,900 LOC
- **T4 (mesh pipeline)**: 8 files, ~3,313 LOC (ObjectMeshManager dominates)
- **T5 stateless (SceneryHelpers + helpers)**: ~2 files, ~200 LOC
- **NOT-CORE leaves** (TextureHelpers, EdgeLineBuilder, extensions): 4 files, ~400 LOC — note these still get extracted, they're just leaves not in the deep call graph of ObjectMeshManager
---
## 4. Extraction-task mapping
Reconciling agent findings against spec §4 — **three of the spec's listed
extractions are unnecessary because the files turned out not to be
reachable**.
### Reachable, must extract
| Bucket | Files (count, LOC) | Spec §4 alignment |
|---|---|---|
| **T3 — Texture / GL infrastructure** | ~15 files, ~3,100 LOC (14 from Chorizite + `TextureHelpers`) | Matches spec §4.2/§4.3: `TextureHelpers`, `OpenGLGraphicsDevice`, plus all `ManagedGL*` wrappers, `GLSLShader`, `GLHelpers`, `GLStateScope` |
| **T4 — Mesh pipeline** | 8 files, ~3,313 LOC | Matches spec §4.1: `ObjectMeshManager`, `ObjectRenderBatch`, `ObjectRenderData` + transitive supports (`GlobalMeshBuffer`, particle batcher, modern render data) |
| **T5 — Scenery + terrain stateless** | 5 files, ~782 LOC (`SceneryHelpers`, `TerrainUtils`, `TerrainEntry`, `CellSplitDirection`, possibly `RegionInfo` if reachable) | **Smaller than spec §4.1+§4.2 anticipated**`LandSurfaceManager` and `SceneryRenderManager` are NOT in our closure (we have our own ports / our own renderer) |
### Files spec listed but we do NOT need
| File (spec §4 reference) | Status | Why |
|---|---|---|
| `Chorizite.OpenGLSDLBackend.Lib.LandSurfaceManager` (spec §4.2) | **NOT EXTRACTED** | Acdream has its own `src/AcDream.Core/Terrain/TerrainBlending.cs` (line 8: "Ported from WorldBuilder.LandSurfaceManager"). No `using` import of WB's class. Confirm in T2: kick or keep our port. |
| `Chorizite.OpenGLSDLBackend.Lib.SceneryRenderManager` (spec §4.1) | **NOT EXTRACTED** | `SceneryGenerator.cs` uses only `SceneryHelpers` (stateless). No reference to `SceneryRenderManager` (the WB-internal pipeline class). |
| `Chorizite.OpenGLSDLBackend.Lib.EnvCellRenderManager` (spec §4.4) | **NOT EXTRACTED** | Acdream renders EnvCells via `ObjectMeshManager` (its `PrepareEnvCellMeshData` path) + `WbDrawDispatcher`. WB's `EnvCellRenderManager` is the editor's separate render path and is not referenced. |
| `Chorizite.OpenGLSDLBackend.Lib.PortalRenderManager` (spec §4.4) | **NOT EXTRACTED** | Acdream renders portals via the same mesh path; WB's `PortalRenderManager` is editor-only. |
| `Chorizite.OpenGLSDLBackend.Lib.TerrainRenderManager` (spec §9.2 open Q) | **NOT EXTRACTED** | Confirmed: we use `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (Phase N.5b ported retail's `FSplitNESW`). Spec §9.2's recommendation to leave WB's `TerrainRenderManager` in `references/` matches the closure finding. |
**This means the spec's T5 and T6 buckets shrink dramatically:**
- **Spec T5** was "scenery + terrain pipelines" (~3 named files: `SceneryHelpers`, `SceneryRenderManager`, `LandSurfaceManager`). Real T5 is just `SceneryHelpers` (stateless utility, ~100 LOC) + the 4 WB.Shared terrain helpers (`TerrainUtils`, `TerrainEntry`, `CellSplitDirection`).
- **Spec T6** was "EnvCell + portal renderers". Real T6 is **empty**. Recommend dropping T6 from the task plan.
### Files we explicitly DROP (REPLACE bucket)
- `WorldBuilder.Shared/Services/IDatReaderWriter.cs` (137 LOC) — interface goes away.
- `WorldBuilder.Shared/Services/DefaultDatReaderWriter.cs` (215 LOC) — concrete impl goes away.
- The `_wbDats = new DefaultDatReaderWriter(datDir)` line in `WbMeshAdapter.cs:79` and its `Dispose()` partner at line 346 — removed in T7.
- The cross-check diagnostic at `WbMeshAdapter.cs:224-262` (`[indoor-upload] NULL_RESULT`) — removed in T7; depends on `ResolveId` which goes away with `DefaultDatReaderWriter`.
---
## 5. Hidden-dependency risks
### 5.1 Internal types
**Three internal types need `internal``public` promotion** when copied
across the project boundary (or copied with their owners and kept
internal in the new project):
- `EmbeddedResourceReader` (Chorizite) — referenced by ObjectMeshManager
for embedded shader / resource loads.
- `TextureFormatExtensions` (Chorizite) — extension methods on
`PixelFormat`.
- `BufferUsageExtensions` (Chorizite) — extension methods on
`BufferUsageARB`.
No internal types in `WorldBuilder.Shared`'s closure. Test mocks marked
`internal` are not in scope (they live in test projects).
### 5.2 Source generators / build hooks
**None found.** Neither `Chorizite.OpenGLSDLBackend.csproj` nor
`WorldBuilder.Shared.csproj` has `<Target>`, `<AnalyzerReference>`, or
source-generator `<PackageReference>` entries. Verbatim file copy will
transfer cleanly.
### 5.3 Resource files
**Shaders.** Reachable closure loads a minority of the 21 shaders in
`Chorizite.OpenGLSDLBackend/Shaders/`. The Chorizite-closure agent
identified only **2 shaders loaded by ObjectMeshManager's path**:
- `Shaders/Particle.vert`
- `Shaders/Particle.frag`
(All other shaders — `Landscape`, `StaticObject*`, `PortalStencil`,
`Outline`, `InstancedLine`, `Text`, `UI`, `Gizmo` — are loaded by render
managers we don't use, e.g. `StaticObjectRenderManager`,
`TerrainRenderManager`, `PortalRenderManager`. Those managers are NOT in
our closure.) **Acdream's own entity shader is `mesh_modern.{vert,frag}`
in `src/AcDream.App/Rendering/Shaders/` — not a WB asset.**
This contradicts the broader-sweep agent's count of "10 shader files
actively referenced", which was including managers we don't use.
**Verify in T4** by grepping for `LoadShader` string literals inside
ObjectMeshManager + its transitive closure.
**Fonts.** Two `.ttf` files exist in `Chorizite.OpenGLSDLBackend/Fonts/`
(DroidSans + DroidSans-Bold). They are used by `FontRenderer`, which is
**NOT in our closure** (we use BitmapFont + StbTrueTypeSharp + ImGui).
**Fonts do not need to be copied.**
**Other.** No PNG, JSON, XML, or other asset files referenced by the
closure beyond shaders.
### 5.4 NuGet dependencies that transfer with the extraction
Files in the closure reference:
- `Silk.NET.OpenGL` (already a `<PackageReference>` in `AcDream.App.csproj`)
- `BCnEncoder.Net` (already in `AcDream.Core.csproj` v2.2.1 — Chorizite
uses v2.2.x, compatible)
- `SixLabors.ImageSharp` (NOT currently in acdream — needs adding for
some of WB's texture-load paths; **verify in T4 whether our extraction
closure actually touches ImageSharp** — if only for the editor scene
loads, we can drop)
- `MemoryPack``TerrainEntry` uses `[MemoryPackable]`. **Add as
`<PackageReference>` in `AcDream.Core.csproj` at T5**, or strip the
attribute (it's used only by WB's editor save/load; acdream doesn't
serialize `TerrainEntry`). Stripping is the simpler call.
### 5.5 DefaultDatReaderWriter ↔ DatCollection — the real risk
This is the only material risk in the whole audit. WB's interface and
acdream's `DatCollection` diverge in several ways:
| Concern | WB | Acdream's `DatCollection` (per probe at `WbMeshAdapter.cs:228-260`) |
|---|---|---|
| **Database surface** | Returns `IDatDatabase` (abstract interface) for `Portal`, `HighRes`, `Language`, `Cell` | Concrete types (`PortalDatabase`, `CellDatabase`, `LocalDatabase`) |
| **Cell databases** | `CellRegions` dictionary (multi-region cell support; auto-discovers at construction) | Single `Cell` property |
| **Cross-database lookup** | `ResolveId(uint id)` returns `IEnumerable<(database, type)>` (HighRes → Portal → Language → all Cells) | **No equivalent.** WbMeshAdapter's diagnostic code uses it; production mesh path may or may not |
| **Per-type caching** | `ConcurrentDictionary<(Type, uint), IDBObj>` per database; thread-safe by design | Documented as **NOT thread-safe** (per `memory/feedback_phase_a1_hotfix_saga.md`) — but with the O-Q1 verdict (render-thread only), this may be fine |
| **Iteration tracking** | Per-database iteration counters | Verify in T4 |
| **Write support** | `TrySave` overloads | Acdream is read-only (correct for a client) |
| **File-handle sharing** | Today each reader opens its own 4 files (~50-100 MB index duplication, the smell Phase O closes) | The DatCollection's file handles are reused; T7 ends the duplication |
**Pre-T4 verification checklist:**
1. Grep `ObjectMeshManager.cs` for every `_dats.X` call site. Catalog
which methods/properties of `IDatReaderWriter` it actually uses.
2. For each, confirm `DatCollection` has the equivalent (or design the
shim).
3. Decide whether `ResolveId` is needed in production code or is purely
diagnostic (the `[indoor-upload]` probe is the only known caller).
If diagnostic-only: drop it at T7.
4. Confirm the multi-region `CellRegions` story — does WB's
`ObjectMeshManager` actually iterate `CellRegions`, or always call
`Cell` singular? (If singular, the dict→single mapping is trivial.)
5. Confirm thread-safety: with O-Q1 settled (render-thread only), do we
still need `ConcurrentDictionary` semantics, or can our existing
single-thread `DatCollection` serve?
---
## 6. Thread-model finding (Open Question O-Q1)
**Verdict: SAFE — verified by code inspection.** No worker-thread access
to WB code today, so extraction does not expose a latent race.
Evidence (paths from the worktree root):
- `WbMeshAdapter.Tick()` is documented in the source as render-thread only
([src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:275-278](src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:275)).
- `LandblockSpawnAdapter` and `EntitySpawnAdapter` are called only from
`GpuWorldState` methods (`AddLandblock`, `RemoveLandblock`,
`AddEntitiesToExistingLandblock`, `RemoveEntitiesFromLandblock`,
`OnCreate`, `OnRemove`). `GpuWorldState` is the render-thread entity
state manager (per `CLAUDE.md` two-tier streaming arch); the streaming
worker thread (`LandblockStreamer`) never touches WB.
- The two-tier streaming spec
(`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`)
is explicit that the worker thread builds dat-decoded `LandblockState`
objects and hands them to the render thread via a completion queue;
WB's `ObjectMeshManager` is never invoked from the worker.
- WB's own `ObjectMeshManager` uses `ConcurrentDictionary` for
`_usageCount` and an explicit `lock(_lruList)` — defense in depth that
doesn't matter for us today, but keeps the door open if a future
acdream design hands streaming work to a worker.
**Action for the spec:** §9.1 (O-Q1) can be marked closed with
"Verified safe; render-thread-only access; ConcurrentDictionary in WB
adds belt-and-braces."
---
## 7. Surprises / sharp edges
1. **Spec §4 over-specifies the extraction.** Three named files
(`LandSurfaceManager`, `EnvCellRenderManager`, `PortalRenderManager`)
and one open question (§9.2's `TerrainRenderManager`) are NOT in the
actual closure. The spec should be amended: T5 shrinks to "stateless
scenery + terrain helpers" (5 files, ~800 LOC); T6 disappears entirely.
2. **The biggest risk is API-shape mismatch, not file count.**
`IDatReaderWriter` returns interface types and has multi-region cell
support + a `ResolveId` cross-DB search; `DatCollection` returns
concrete types and has a single cell property. T4 must design the
shim layer (or refactor `ObjectMeshManager` slightly to use our
shape — slightly violates "verbatim copy" but is one-call narrow).
3. **`SixLabors.ImageSharp` is a possibly-unnecessary new NuGet
dependency.** If our closure actually touches it (some texture-load
helpers in Chorizite use ImageSharp for PNG/JPG), T4 needs to add it.
If it's only in editor-load paths we don't use, T4 can drop the
imports. **Verify before T2.**
4. **`MemoryPack` is a small new dep for one attribute on
`TerrainEntry`.** Cheapest answer: strip the attribute when copying
`TerrainEntry.cs` into our tree (we don't serialize the struct).
5. **The `[indoor-upload]` diagnostic in `WbMeshAdapter.cs:192-263`
becomes vestigial after T7.** It depends on `ResolveId` (which goes
away), it compares `_wbDats` (which goes away) against `_dats`. The
whole `if (RenderingDiagnostics.IsEnvCellId(id) && ...)` block + its
`_pendingEnvCellRequests` companion can be deleted in T7.
6. **`SplitFormulaDivergenceTest.cs` is safely deletable** after T5
ships. It was a one-time data-collection test that informed the
N.5b path-C decision; its findings are baked into the codebase. We
can drop the WB-types import by deleting the test.
7. **Total `LOC of extracted code: ~6.0-6.5K`** (excluding the 350 LOC
of `IDatReaderWriter`/`DefaultDatReaderWriter` which are deleted, and
excluding the 600 LOC of NOT-CORE leaves the broader agent
classified as "optional" but on review still need to come along).
This is somewhat **larger than the spec's implicit ~5K assumption**
but only marginally; the 7-8 day estimate still looks right.
---
## 8. Open questions for the user (to call before T2 starts)
1. **Spec §4 amendment.** Confirm that the three not-reachable
components (`LandSurfaceManager`, `EnvCellRenderManager`,
`PortalRenderManager`) are dropped from the extraction task list. If
yes, T6 disappears entirely and T5 narrows to stateless helpers.
*Recommendation: drop them. Saves ~1.5 days of T6 work that would
otherwise be a no-op anyway.*
2. **NuGet additions.** Are we OK adding `MemoryPack` and (potentially)
`SixLabors.ImageSharp` as new `<PackageReference>` lines in
`AcDream.Core.csproj`? Or do we prefer stripping the `[MemoryPackable]`
attribute from `TerrainEntry` and dropping ImageSharp-touching code
from the extraction?
*Recommendation: strip `MemoryPack` (one-line change). On ImageSharp,
wait until T4 confirms whether the closure actually touches it.*
3. **`DatCollection` design call.** Two paths for the dat-swap:
- **A) Adapter shim**: write a thin `DatCollectionAsIDatReaderWriter`
adapter so `ObjectMeshManager` keeps its current dat field type
and we preserve "verbatim copy" discipline.
- **B) Refactor**: change `ObjectMeshManager`'s field type to
`DatCollection` and rewrite ~5-20 call sites inside it.
*Recommendation: A) for the first pass (preserves "verbatim copy"
discipline; spec O-D1 says no improvements). A follow-up phase can
refactor (B) once the extraction is settled.*
4. **`ResolveId` fate.** Drop it (diagnostic-only) or implement an
equivalent on `DatCollection`?
*Recommendation: drop. The `[indoor-upload]` diagnostic was a Phase-2
investigation tool that has done its job.*
5. **Test deletion.** OK to delete `SplitFormulaDivergenceTest.cs` (the
one-time data-collection sweep) as part of T7's WB-reference drop?
`TextureDecodeConformanceTests.cs` should stay (genuine ongoing
conformance for our `SurfaceDecoder`).
*Recommendation: delete the SplitFormula test; keep the texture
conformance tests.*
6. **Confirmation on 7-8 day estimate.** Given (a) T6 disappears and
(b) T5 shrinks, the bulk of the effort is T3 (~1d) + T4 (~2d
including the dat-shim design) + T7 (~0.5d). Net estimate looks
**closer to 5-6 days** of pure extraction work, plus the spec's
1d verification gate and 0.5d ship.
*Recommendation: keep the 7-8 day envelope as scheduled time
(includes inevitable mid-work surprise budget); call it 5-6d of
focused engineering plus 1-2d of verification.*
---
## 9. Acceptance recap (per the prompt)
- [x] Audit doc exists at the agreed path.
- [x] Every file in the closure is bucketed (T3 / T4 / T5 / T6 / NOT /
REPLACE).
- [x] Hidden-dependency risks section addresses internal types, source
generators, resource files, and `DefaultDatReaderWriter` vs
`DatCollection` semantic diff.
- [x] No source code edited. No `dotnet build/test`. No project files
touched. (Verify with `git status` — only this audit doc + a few
research subreports the parallel agents would have written if they
had Write access; in practice only this single file is on disk.)
- [x] Thread-model finding included.
- [x] Open questions enumerated.
---
## 10. TL;DR for the user (the four asks from the handoff prompt)
1. **Total LOC of the closure:** ~7,705 LOC across 33 files
(~6,829 Chorizite + ~876 WB.Shared).
2. **Bucket breakdown:**
- T3 (GL infra): ~15 files, ~3,100 LOC.
- T4 (mesh): 8 files, ~3,313 LOC.
- T5 (scenery + terrain stateless helpers): 5 files, ~782 LOC.
- T6 (EnvCell / portal renderers): **0 files** — not reachable.
- REPLACE (delete, swap to DatCollection): 2 files, 352 LOC.
3. **Sharp edges:**
- Three spec §4 components turn out not to be reachable — recommend
dropping `LandSurfaceManager`, `EnvCellRenderManager`,
`PortalRenderManager` from the extraction plan.
- The real risk is the `IDatReaderWriter``DatCollection` API
mismatch: interface vs concrete return types, multi-region cell
dict vs single cell, `ResolveId` cross-DB search not in our
reader. Needs a shim layer or a narrow refactor at T4.
- Three internal types in Chorizite need `internal → public`
promotion (`EmbeddedResourceReader`, `TextureFormatExtensions`,
`BufferUsageExtensions`).
- `MemoryPack` and possibly `SixLabors.ImageSharp` are new NuGet
deps if we don't strip them out.
- The `[indoor-upload]` diagnostic in `WbMeshAdapter` and the
`SplitFormulaDivergenceTest` test become deletable at T7.
4. **Honest read on the 7-8 day estimate:** Reasonable, probably
slightly conservative. Net extraction work is ~5-6 days of focused
engineering; the spec's verification + ship time bring it to
7-8 days total. The shrinkage of T5 + disappearance of T6 buys
margin against the dat-shim design work at T4 — net-net the
estimate holds.

View file

@ -1,252 +0,0 @@
# Indoor walk-miss probe — capture findings (ISSUES #83)
**Date:** 2026-05-21
**Session:** lucid-goldberg-1ba520
**Spec:** [`docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md`](../superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md)
**Plan:** [`docs/superpowers/plans/2026-05-21-indoor-walk-miss-probe.md`](../superpowers/plans/2026-05-21-indoor-walk-miss-probe.md)
**Capture log:** `launch-walk-miss.utf8.log` (9,401 lines, this branch — uncommitted)
## TL;DR
**H3 is the dominant defect.** The indoor walkable-plane synthesis
(`BSPQuery.FindWalkableSphere``FindWalkableInternal`
`walkable_hits_sphere` + `adjust_sphere_to_plane`) **rejects floor
polygons it should accept** ~98 % of the time the player is standing on
a horizontal indoor floor. The HIT zone is razor-thin: misses cluster
at `dz=0.48 m` (cell-local foot-above-floor) while the only 7 HITs in
the entire capture all sat at `dz=0.46 m` — a **2 cm boundary** between
working and broken.
H1 (multi-cell iteration missing) is real but secondary: 59 events
(3 %) at doorway-threshold cells where the player stepped past a small
indoor floor poly and the LandCell terrain would have grounded them.
H2 (probe distance 0.5 m too short) is **not** the issue. The bulk of
H3 misses sit well within the probe envelope.
## Numbers
| Metric | Count |
|---|---:|
| Total `[walk-miss]` events | 1,814 |
| `[indoor-walkable] result=HIT` (synthesis succeeded) | 7 |
| `[indoor-walkable] result=MISS` (synthesis failed) | 1,814 |
| Synthesis HIT rate | **0.38 %** |
| `[floor-polys]` cell dumps (one per cached indoor cell) | 527 |
### Hypothesis classification (per spec disambiguation matrix)
| Class | Filter | Count | % of total |
|---|---|---:|---:|
| **H3 candidate** | `containsFootXY=True AND \|dz\| ≤ 0.5 m` | **817** | **45.0 %** |
| Airborne / jump | `containsFootXY=True AND \|dz\| > 0.5 m` | 938 | 51.7 % |
| **H1 candidate** | `containsFootXY=False AND landcell.hasTerrain=true` | **59** | 3.3 % |
| H1+H3 combo | `containsFootXY=False AND landcell.hasTerrain=false` | 0 | 0.0 % |
The 938 "airborne" events are not a defect — they correspond to the
test session's jump arc (the user jumped through the doorway during
capture). The probe correctly reports `containsFootXY=True` with a
large `dz` because the foot is XY-over a floor poly but vertically too
far above it. Setting these aside: of **876 ground-contact misses**,
**93 %** are H3.
### `nearest.dz` distribution (containsFootXY=True only)
| dz bucket | Count |
|---|---:|
| 0.00.2 m | 18 |
| 0.20.4 m | 7 |
| **0.40.5 m** | **792** |
| 0.51.0 m | 141 |
| 1.02.0 m | 427 |
| > 2.0 m | 370 |
| negative | 0 |
The massive 792-event spike at 0.40.5 m is the standing-on-the-floor
position. The 1.02.0 m and >2.0 m buckets are the jump arc.
## The 2 cm hit/miss boundary
The only 7 synthesis HITs in the capture share a precise property:
| HIT example | foot.W.Z | world floor Z | dz |
|---|---:|---:|---:|
| `cell=0xA9B40125 wpos=(104.263, 140.893, 66.480)` | 66.480 | 66.020 | **+0.46** |
| `cell=0xA9B40125 wpos=(104.272, 141.275, 66.480)` | 66.480 | 66.020 | +0.46 |
| `cell=0xA9B40123 wpos=(108.430, 134.116, 69.485)` | 69.485 | 69.020 | +0.47 |
| `cell=0xA9B40123 wpos=(108.443, 134.162, 69.485)` | 69.485 | 69.020 | +0.47 |
| `cell=0xA9B40123 wpos=(109.702, 133.700, 69.485)` | 69.485 | 69.020 | +0.47 |
The MISS lines from the same cottage, same physics tick rate:
| MISS example | foot.W.Z | world floor Z | dz |
|---|---:|---:|---:|
| `cell=0xA9B40125 foot.W=(104.263, 140.893, 66.500)` | 66.500 | 66.020 | **+0.48** |
| `cell=0xA9B40121 foot.W=(104.254, 140.441, 66.500)` | 66.500 | 66.020 | +0.48 |
The **20 mm difference in foot.W.Z** (66.480 → 66.500) flips the
synthesis from HIT to MISS. This matches the `+0.02 m` Z-bump
mentioned in
[TransitionTypes.cs:1511](src/AcDream.Core/Physics/TransitionTypes.cs:1511)
("the +0.02f Z-bump applied for render z-fight prevention"). When the
foot's world Z is at exactly the rendered floor + foot-height
(`world_floor + 0.46`), synthesis HITs. When it's 2 cm higher,
synthesis MISSES.
That's not a probe-distance issue. The probe distance is 0.5 m and
`dz=0.48 < 0.5`. The geometry is well within reach.
**The defect is in the sphere-overlap test or sphere-plane-adjustment
math inside `FindWalkableInternal`.** Retail anchors to compare against:
- `CPolygon::walkable_hits_sphere`
[`acclient_2013_pseudo_c.txt:323006-323028`](docs/research/named-retail/acclient_2013_pseudo_c.txt).
Slope test + `polygon_hits_sphere_slow_but_sure` overlap test.
- `CPolygon::adjust_sphere_to_plane`
[`acclient_2013_pseudo_c.txt:322032`](docs/research/named-retail/acclient_2013_pseudo_c.txt).
Sphere-to-plane projection with sweep-distance budget.
- `BSPLEAF::find_walkable`
[`acclient_2013_pseudo_c.txt:326793`](docs/research/named-retail/acclient_2013_pseudo_c.txt).
Iterates polys; requires BOTH `walkable_hits_sphere` AND
`adjust_sphere_to_plane` non-zero.
Our port lives in
[`BSPQuery.FindWalkableInternal`](src/AcDream.Core/Physics/BSPQuery.cs)
(called by `FindWalkableSphere`). Direct line-by-line comparison
against the retail oracle is the next step.
## H1 evidence (secondary, doorway-edge cases)
59 `[walk-miss]` events where the foot XY left the indoor floor poly
but the LandCell underneath would have been walkable. All concentrated
in cell `0xA9B40125`, whose floor poly is a tiny 1.5 m × 0.5 m strip
(`bbox=(-0.40,-5.65)..(1.10,-5.15)`) — this is a **doorway-threshold
cell**. The player crosses it; the foot XY exits the strip before they
reach the next cell.
Sample (last 3 walk-miss lines):
```
[walk-miss] cell=0xA9B40125 foot.W=(104.400,147.409,66.480)
foot.L=(0.100,-11.909,0.460) ...
containsFootXY=False
landcell.hasTerrain=true landcell.terrainZ=66.000 landcell.dz=+0.480
```
`foot.L.Y = -11.909`, well outside the strip's Y range
`[-5.65, -5.15]`. Outdoor LandCell terrain at world Z = 66.000 would
have grounded the foot at `dz = 0.480`. This is the case the prior
handoff (`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`)
diagnosed as "doorway threshold has no floor poly." It's real — but
**3 % of the total miss volume, not the primary defect**.
## H2 ruled out
Of 817 in-bbox candidate misses, **792 sit at `dz` between 0.4 m and
0.5 m**, well within the 0.5 m probe distance. Only 25 events fall in
the 0.00.4 m range (a few cm above plane — already touching).
Bumping `INDOOR_WALKABLE_PROBE_DISTANCE` will not help — the geometry
is reachable; the rejection is in the sphere-overlap math.
## Cells of interest
| Cell ID | Walk-misses | Floor polys (local-XY bboxes) | Role |
|---|---:|---|---|
| `0xA9B40121` | 1,453 | 1 @ Z=0, bbox `(-5.7,-5.15)..(5.7,4.55)` | Cottage main room (1st floor) |
| `0xA9B40123` | 283 | 5 @ Z=3.0 (multiple connected panels) | Cottage **2nd floor** |
| `0xA9B40125` | 67 | 1 @ Z=0, bbox `(-0.4,-5.65)..(1.1,-5.15)` | **Doorway threshold strip** |
| `0xA9B40126` | 11 | (no [floor-polys] dump captured at start) | Adjacent |
Cell `0xA9B40123`'s floor polys all sit at `planeZ@center=3.000`
that's 3 m above the cell origin, i.e. a 2nd-story floor. The HITs in
this cell at world Z 69.485 match: cell origin Z 66.020 + local floor
Z 3.0 = world floor 69.020, foot 0.46 above → world Z 69.485. ✓
This confirms our 2nd-floor handling is being **exercised** by the
synthesis; it's just rejecting at the same 2 cm boundary as the 1st
floor.
## Disambiguation matrix verdict (per spec)
| Matrix entry | Spec condition | This capture |
|---|---|---|
| **H1 confirmed** | `landcell.hasTerrain==true AND \|landcell.dz\| < 0.2 m` | 59 events at doorway threshold |
| **H2 confirmed** | `containsFootXY==true AND 0.5 m < nearest.dz < 5 m` | 0 events qualify (all "candidates" turned out to be jump-arc) |
| **H3 candidate** | `containsFootXY==true AND nearest.dz ≤ 0.5 m AND normalZ ≥ FloorZ` | **817 events** — the bulk |
| H1+H3 combo | `containsFootXY==false AND landcell.hasTerrain==false` | 0 events |
Spec matrix entry H3 is flagged as "next step: cdb attach to retail."
Given the 2 cm hit-vs-miss boundary and the matched normalZ + FloorZ +
in-bbox + in-probe signatures, we can attempt the retail decomp
side-by-side comparison **first** without cdb — the discrepancy is
narrow enough that the decomp + a focused unit test should expose it.
cdb is a fallback if that fails.
## Recommended next step
**Phase: design + ship the H3 fix.**
1. **Decomp comparison** (~1 hour, no code change):
- Read `acclient_2013_pseudo_c.txt:322032-322110` (`adjust_sphere_to_plane`)
and our equivalent inside `BSPQuery.FindWalkableInternal`
([BSPQuery.cs](src/AcDream.Core/Physics/BSPQuery.cs)) line-by-line.
- Read `acclient_2013_pseudo_c.txt:323006-323028` (`walkable_hits_sphere`)
and our equivalent.
- Read `acclient_2013_pseudo_c.txt:326793-326816` (`BSPLEAF::find_walkable`)
and our `FindWalkableInternal` traversal.
- Document any divergences in a follow-up findings note.
2. **Unit test for the 2 cm boundary** (~30 min):
- Synthetic `CellPhysics` with a horizontal floor at local Z=0.
- Foot sphere centered at `Z=0.46`, then again at `Z=0.48`. Assert
both HIT.
- Mirrors the IndoorWalkablePlaneTests fixture pattern.
- Expected: both fail at HEAD; pass after the fix.
3. **Fix the divergence found in step 1** (size unknown — could be a
one-line epsilon adjustment or a structural mismatch).
4. **Re-run this capture with the fix in place.** Expected outcome:
`[indoor-walkable] HIT` rate goes from 0.38 % to >95 % during
ground-contact frames; `[walk-miss]` H3 bucket collapses; H1 (the
59 doorway-edge events) remains.
5. **Then design H1 fix** as a separate, smaller phase — porting
retail's `CTransition::check_other_cells`
(`acclient_2013_pseudo_c.txt:272717`) for multi-cell BSP iteration.
Lower priority since 3 % of total misses and only manifests at
threshold strips.
6. **Delete the spike** when both H3 and H1 fixes ship: revert
`27c7284..a2e7a87` plus this findings doc.
## Anti-patterns to avoid (from prior handoffs)
- **Don't increase `INDOOR_WALKABLE_PROBE_DISTANCE`.** The data shows
probe distance is not the blocker.
- **Don't delete `TryFindIndoorWalkablePlane`** ("Bug A" from 2026-05-20)
— once H3 is fixed, the synthesis path will work correctly and is
the right call (not removable until retail's multi-cell iteration is
also ported).
- **Don't bypass `walkable_hits_sphere` overlap rejection with a
looser epsilon** without first verifying retail's exact behavior at
this boundary. The 2 cm difference is suspiciously close to the
rendered Z-bump (`+0.02 f`) used to prevent z-fighting on indoor
floors. There may be a coordinate-space mismatch where the player's
foot world Z is computed in the rendered (bumped) frame but the
synthesis expects the dat-stated (unbumped) frame, or vice versa.
Investigate before "fixing."
## Acceptance review
The probe spike's acceptance criteria from
[`2026-05-21-indoor-walk-miss-probe-design.md`](../superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md):
- [x] Build green, tests green
- [x] Live capture produced `[walk-miss]` lines at the cottage doorway
- [x] Live capture produced `[walk-miss]` lines on the cottage 2nd floor
- [x] Aggregated counts classify each MISS per the disambiguation matrix
- [x] Zero `[walk-miss]` / `[floor-polys]` lines when env var unset
(verified by code inspection; runtime verification deferred)
**Spike concluded. Ship findings, design the H3 fix.**

View file

@ -1,223 +0,0 @@
# A6.P3 handoff — 2026-05-22
**Status:** A6.P3 slices 1+2+3 SHIPPED. Issue #98 (cellar ascent stuck at top) **diagnosed but NOT fixed.** Sharp Path-5-vs-Path-6 BSP path-selection target identified with paired retail+acdream cdb evidence. Next session: fix #98 at `BSPQuery.FindCollisions` path-selection.
**Pasteable session-start prompt at the bottom of this doc.**
---
## TL;DR
Two full days of A6 work landed:
| Day | Slice | Result |
|---|---|---|
| 2026-05-21 | A6.P1 + A6.P2 + A6.P3 slice 1 (CP retention strip + Mechanism B) | Stairs + cellar descent work in acdream. A6.P2 Finding 1 (dispatcher freq) closed as side-effect of Finding 2 (CP-write blowup). |
| 2026-05-22 morning | A6.P3 slice 2 (L622 seed; v1 reverted; v2 no-op guard) | #96 partially addressed; accepted as documented retail divergence. |
| 2026-05-22 morning | A6.P3 slice 3 (cell-resolver stickiness; v1/v2/v3) | Cell-resolver ping-pong CLOSED. #90 workaround now redundant (defer A6.P4 removal). |
| 2026-05-22 noon | Slice 4 polydump probe + retail cdb capture | **Pinpointed #98 root cause:** our BSP picks Path 5 (Contact→step_up→adjust_sphere push-back) for the cellar ramp polygon when retail picks Path 6 (find_walkable → land on flat floor). |
**User-visible deltas vs Wed morning baseline (2026-05-20):**
- ✅ Inn stairs UP — works (was broken)
- ✅ Cellar descent — works (was broken)
- ✅ 2nd floor walking — works (was broken; with caveats — phantom collisions occasionally)
- ❌ Cellar ASCENT (stuck at top step) — still broken (this is issue #98)
- ❌ Visible-through-walls in dungeons — issue #95 (separate scope)
- ❌ Indoor lighting — A7 scope (separate phase)
## What shipped this session (2026-05-22)
| Commit | What |
|---|---|
| `892019b` | A6.P3 slice 2 v1: removed L622 per-tick CP seed (CP-write 91% reduction BUT broke BSP step_up at last step of stairs) |
| `f8d669b` | A6.P3 slice 2 v2: revert v1 + add no-op-if-unchanged guard inside `CollisionInfo.SetContactPlane` |
| `d868946` | Slice 2 ship docs + filed issue #98 (cellar ascent stuck — originally hypothesized as cell-resolver ping-pong) |
| `8898166` | A6.P3 slice 3 v1: sphere-overlap stickiness in `ResolveCellId` (over-corrected; blocked legitimate cell transitions) |
| `3e140cf` | A6.P3 slice 3 v2: switched to point-in stickiness — cell-resolver ping-pong CLOSED (data confirmed: 1 cell-transit event vs 20+ pre-fix) |
| `ceeb06b` | Slice 3 ship docs + #98 re-diagnosed (cellar-up symptom persists with NEW cause — BSP step-physics, not cell-resolver) |
| `0b44996` | Slice 4: added `[poly-dump]` probe in `AdjustSphereToPlane` — verifies dat fidelity by dumping polygon vertices+plane+sidesType on every push-back |
| `3198472` | Extended `[cell-cache]` probe with `portalTargets` list — shows which cells each portal connects to |
| `8bd3117` | A6.P3 slice 3 v3: REVERTED stickiness entirely (hypothesis-test for #98) — cellar-up symptom persists |
| `bbd1df4` | Slice 4: WalkInterp reset before placement_insert in DoStepDown (retail-faithful improvement; didn't fix #98 but kept as quality fix) |
| `134c9b8` | **Retail cellar-up cdb capture** — paired evidence for the Path-5 vs Path-6 diagnosis |
| `efb5f2c` | Issue #98 updated with sharpened diagnosis + failed-attempt log |
## The sharp diagnosis for issue #98
**Symptom:** User walks UP the Holtburg cottage cellar in acdream. Runs into "an invisible roof or wall" at the top step. Animation plays but no Z progress. Stuck.
**Paired evidence:**
| Metric | Retail (success) | Acdream (stuck) |
|---|---:|---:|
| BP1 transitional_insert | 2,651 | (no acdream BP1 mirror) |
| BP2 step_up | 29 (incl. 1 on ramp slope) | — |
| BP4 find_collisions | 4,032 | push-back-disp ~9000 |
| BP5 adjust_sphere | **30 (ALL on FLAT planes)** | **push-back ~1000 (270 on RAMP slope poly 0x0008)** |
| BP6 check_walkable | 25 | indoor-walkable ~700 |
| BP7 set_contact_plane | **18 (all set same flat plane: (0,0,1) d=-93.9998 = world Z=94 = cottage main floor)** | cp-write 229,300 (varying planes from many sites) |
| step_up_slide | (via BP2 = 29) | 159+ hits |
**The divergence (pinpointed):**
For the cellar ramp polygon (cellar cell 0xA9B40147, poly 0x0008, n=(0,-0.719,0.695), 46° walkable slope):
- **Retail's BSP picks Path 6 (find_walkable → land)** — treats the ramp as a walkable floor. Smoothly LANDS the sphere on the ramp surface during step_down probe. Sets ContactPlane to the cottage main floor (flat plane at world Z=94 — the END goal of the ascent).
- **Acdream's BSP picks Path 5 (Contact → step_sphere_up → adjust_sphere push-back)** — treats the ramp as a wall to push off. The push-back lifts the sphere by 0.75m and consumes all walk-interp. step_up's placement_insert then fails (the lifted position doesn't validate). step_up returns failure → step_up_slide fires → sphere slides along step_up_normal → loop. Player physically stuck.
**Both retail and ours classify the ramp as walkable** (N.Z=0.695 > FloorZ=0.6642). So the divergence isn't in the walkability check itself. It's in the **path-selection logic** inside `BSPQuery.FindCollisions` that decides whether to fire Path 5 vs Path 6 for a given polygon hit.
**Code anchors for the next session:**
- `src/AcDream.Core/Physics/BSPQuery.cs``FindCollisions` dispatcher. Search for "Path 5" + "Path 6" comments. The path selection branches on `ObjectInfo.State` (Contact flag) + `SpherePath.StepDown` + `SpherePath.StepUp`.
- The grounded player has Contact flag set (per `PhysicsEngine.cs:597-598`). So Path 5 fires first. Path 5 calls step_sphere_up → step_up → step_down (with step_up=1) → recursive BSP query.
- The recursive BSP query (with StepDown=1, StepUp=1) should fire Path 6 — but maybe doesn't, OR fires Path 6 but Path 6's adjust_sphere on the ramp is what produces the broken push-back.
- Retail's BSP behavior at the same site: step_up fires (BP2 hits), but adjust_sphere only fires on FLAT planes (BP5 all flat). So retail's step_down inside step_up doesn't push the sphere off the ramp slope.
## Why the failed attempts today didn't land
| Attempt | What we tried | Why it didn't fix #98 |
|---|---|---|
| Slice 2 v1 (`892019b`) — remove L622 seed | Eliminate the per-tick CP seed | The seed is load-bearing for step_up's AdjustOffset slope-projection on sub-step 1; removed it → all step_up broke |
| Slice 2 v2 (`f8d669b`) — no-op guard in SetContactPlane | Make redundant CP writes a true no-op | Guard doesn't fire for the L622 seed because each tick gets a fresh `Transition` (ci.ContactPlaneValid=false on entry); useful for OTHER call sites but not the seed |
| Slice 3 v1 (`8898166`) — sphere-overlap stickiness | Stop cell-resolver ping-pong | Over-corrected: held player in cellar even during legitimate transition; cellar-up still stuck |
| Slice 3 v2 (`3e140cf`) — point-in stickiness | Less aggressive stickiness | CLOSED the ping-pong (data confirmed: 1 cell-transit vs 20+) but cellar-up still stuck — bug isn't cell-resolver |
| Slice 3 v3 (`8bd3117`) — revert all stickiness | Hypothesis test: prove cell-resolver isn't the bug | Confirmed — cellar-up still stuck even without stickiness |
| Slice 4 (`bbd1df4`) — reset WalkInterp before placement_insert | Match retail's walk_interp=1 reset pattern | Logical retail-faithful improvement but doesn't unblock cellar-up; kept in tree as quality fix |
**Common pattern:** I was guessing fixes at higher levels (cell resolution, CP retention, walk_interp) when the actual bug is deeper in BSP path-selection. The paired retail cdb capture finally pinpointed the divergence.
## State of the four A6.P2 findings
| Finding | Status as of 2026-05-22 EOS |
|---|---|
| Finding 1 — dispatcher entry frequency mismatch | CLOSED (as side-effect of slice 1 Finding 2 fix) |
| Finding 2 — ContactPlane resynthesis blowup | PARTIALLY CLOSED (slice 1 stripped synthesis; slice 2 v2 added no-op guard; L622 seed retained as documented retail divergence per #96) |
| Finding 3 — Indoor cell-resolver instability | CLOSED (slice 3 v2 point-in stickiness; ping-pong fully eliminated per data) |
| Finding 4 — Portal-graph visibility blowup | OPEN as issue #95 (not A6 scope) |
## Known open issues touched by A6 work
| Issue | Status |
|---|---|
| #83 — Indoor multi-Z walking broken | Cellars + 2nd floor walking works; cellar-up still blocked by #98 |
| #88 — Indoor static objects vibrate | Unchanged (deferred; hypothesis: closes with Finding 2 family) |
| #90 — CellId ping-pong workaround | Now REDUNDANT after slice 3 v2; defer A6.P4 removal |
| #95 — Portal-graph visibility blowup | OPEN (not A6 scope) |
| #96 — L622 per-tick CP seed | PARTIALLY ADDRESSED, accepted as documented retail divergence |
| #97 — Phantom collisions + fall-through on 2nd floor | OPEN (not re-tested post-slice-3-revert; hypothesis: same Path-5/Path-6 family as #98) |
| #98 — Cellar ascent stuck at top step | OPEN — **sharp Path-5-vs-Path-6 diagnosis ready for next session** |
## Test suite status
1148 pass + 8 pre-existing fail (baseline maintained throughout the session).
## Next session — concrete starting steps
**Goal:** Fix #98 (cellar ascent stuck at top step) by correcting `BSPQuery.FindCollisions` path-selection so the cellar ramp triggers Path 6 (find_walkable land) instead of Path 5 (Contact step_up push-back).
**Approach:**
1. **Read retail's `BSPTREE::find_collisions` dispatcher** at `acclient_2013_pseudo_c.txt` (search for `BSPTREE::find_collisions`). Note exactly which path it picks for a grounded mover hitting a walkable slope. The 6-path dispatcher is at line ~322984 (where BP4 sits).
2. **Read our `BSPQuery.FindCollisions`** at `src/AcDream.Core/Physics/BSPQuery.cs:1500+`. Identify the path-selection branch that decides Path 5 vs Path 6 for the input `(grounded=true, step_down=false, step_up=false, polygon.N.Z=0.695)` case.
3. **Compare line-by-line.** Likely candidates for the divergence:
- Wrong state flag check (e.g. checking Contact when retail checks something else)
- Wrong walkability gate (e.g. requiring N.Z >= LandingZ when retail requires >= FloorZ)
- Wrong polygon-sidedness check (one-sided poly being treated as two-sided or vice versa)
- Off-by-one in path numbering (Path 5 vs Path 6 swapped in our port)
4. **Fix surgically + verify via re-capture.** Re-run the cellar-up scenario in acdream with `ACDREAM_PROBE_POLY_DUMP=1`. Compare the post-fix `[push-back]` distribution against retail's BP5 distribution from `134c9b8` capture. Target: zero push-back hits on the ramp slope; CP set to flat cottage floor (matching retail).
5. **If the fix lands cleanly:** also re-test #97 (phantom collisions + fall-through on 2nd floor — likely closes as side-effect because it's the same family).
**Files almost certainly touched by the fix:**
- `src/AcDream.Core/Physics/BSPQuery.cs` — path-selection in `FindCollisions`
- Possibly `src/AcDream.Core/Physics/PhysicsGlobals.cs` (LandingZ vs FloorZ threshold mismatch)
**Files that DON'T need changing** (already correct per today's investigation):
- `PhysicsEngine.cs` ResolveCellId (cell-resolver works post-slice-3)
- `PhysicsEngine.cs` L622 seed (retail divergence accepted)
- `TransitionTypes.cs` ValidateTransition (Mechanism B works)
- `TransitionTypes.cs` FindEnvCollisions indoor branch (slice 1 strip is correct)
## Captures available for the next session
| Capture | What it shows |
|---|---|
| `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log` | Acdream stuck-at-cellar trace with `[poly-dump]` lines showing the ramp polygon vertices |
| `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_portaldump/acdream.log` | Same cellar with `[cell-cache] portalTargets=...` showing the cellar's portals to 0x0146 + 0x0148 |
| `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_retail_for_issue98/retail.{log,decoded.log}` | **Retail's successful cellar-up cdb trace — the gold-standard comparison data** |
| `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_slice3v2/acdream.log` | Pre-slice-3-revert cell-transit pattern (closed ping-pong, point-in stickiness) |
| `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_slice3v3_revert/acdream.log` | Post-slice-3-revert (no stickiness) — cellar-up still stuck → confirms cell-resolver isn't the bug |
## Pickup prompt for fresh session
Open a new Claude Code session at this worktree's branch
(`claude/strange-albattani-3fc83c`, HEAD at `efb5f2c`). Then paste:
---
```
Pick up A6.P3 — fix issue #98 (cellar ascent stuck at top step).
Read FIRST:
docs/research/2026-05-22-a6-p3-handoff.md
docs/ISSUES.md issue #98 entry (sharp diagnosis section)
Then state both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 — fix issue #98 BSP path-selection
Next concrete step: read retail's BSPTREE::find_collisions
dispatcher (acclient_2013_pseudo_c.txt) + our BSPQuery.FindCollisions
side-by-side; identify why our code picks Path 5 (Contact step_up)
for the cellar ramp polygon when retail picks Path 6 (find_walkable
land). The ramp is walkable (N.Z=0.695 > FloorZ=0.6642) so Path 6 is
the correct choice for both clients.
Sharp diagnosis (from paired cdb captures committed 2026-05-22):
- Retail's adjust_sphere fires 30x ALL on flat planes (Z=94 cottage main floor)
- Acdream's push-back fires 270x on the RAMP slope (cellar 0xA9B40147 poly 0x0008)
- Retail's BP7 set_contact_plane fires 18x with the SAME flat plane
- Acdream cp-write fires 229,300x with varying planes from many sites
Captures available for comparison:
- docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_retail_for_issue98/
(retail cellar-up cdb trace — gold-standard data)
- docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/
(acdream stuck-at-cellar with [poly-dump] lines)
DO NOT re-attempt the failed fixes from 2026-05-22 (handoff doc has
the full list with reasons each one didn't land). Specifically:
- Don't try removing the L622 seed (breaks step_up)
- Don't try removing slice-3 stickiness (already reverted; didn't help #98)
- Don't try cell-resolver fixes (Finding 3 is closed)
Fix expected in BSPQuery.cs path-selection (the dispatcher branch
that decides Path 5 vs Path 6 for grounded movers hitting walkable
polys). Likely 5-20 lines of code change once the divergence is found.
After fix lands: re-capture scen4_cottage_cellar with the same probe
env vars to verify acdream now matches retail's flat-plane BP7
pattern. Also re-test #97 (phantom collisions + fall-through on 2nd
floor — hypothesized to close as side-effect of #98 fix).
Test suite baseline: 1148 pass + 8 pre-existing fail. Maintain through
the fix.
CLAUDE.md rules apply. No workarounds without explicit user approval.
Three failed visual verifications = handoff (we hit this 4x on the
2026-05-22 session — discipline check before attempting another guess
fix).
```
---
## References
- A6 design spec: [`docs/superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md`](../superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md)
- A6.P2 findings doc: [`docs/research/2026-05-21-a6-cdb-capture-findings.md`](2026-05-21-a6-cdb-capture-findings.md)
- A6.P1 partial-ship handoff (yesterday): [`docs/research/2026-05-21-a6-p1-partial-ship-handoff.md`](2026-05-21-a6-p1-partial-ship-handoff.md)
- ISSUES.md #98 entry (sharp diagnosis section)
- cdb probe + decoder: `tools/cdb/a6-probe.cdb`, `tools/cdb/decode_retail_hex.py`

View file

@ -1,174 +0,0 @@
# A6.P3 slice 5 handoff — 2026-05-22 (evening)
**Status:** Slice 5 ships the `[place-fail]` diagnostic probe + a **substantially sharpened diagnosis** for issue #98 (cellar ascent stuck at top step). Today's handoff's "Path 5 vs Path 6 in `BSPQuery.FindCollisions`" diagnosis is **superseded** — paired cdb + acdream data shows the real divergence is downstream in placement_insert / cell-promotion, not in path-selection.
**Pasteable session-start prompt at the bottom of this doc.**
---
## TL;DR
Today's morning handoff (`2026-05-22-a6-p3-handoff.md`) said: "fix expected in `BSPQuery.FindCollisions` path-selection (5-20 lines once the divergence is found)."
That diagnosis is **incorrect**. The probe-driven evidence collected this evening shows:
1. **Retail's [BP4] dispatcher trace shows every hit has `collide=0`.** Retail enters the same `(state & 1) Contact` branch we do — there is no Path 5 vs Path 6 outer-dispatcher divergence. Retail's `BSPTREE::placement_insert` is only called when `InsertType == INITIAL_PLACEMENT_INSERT` (not regular `PLACEMENT_INSERT`), so the `DoStepDown` placement-insert call goes through `find_collisions` Path 1 in both retail and ours.
2. **Retail's BP5 (adjust_sphere) fires 17+ times on the cellar ramp polygon** (`n=(0,-0.719,0.695) d=-0.1007`), NOT "30 hits all on flat planes" as the morning handoff claimed. We were misreading the retail data.
3. **The actual blocker is polygon `0x0020` in the cellar cell's BSP**: `n=(0,0,-1) d=-0.2` — a ceiling polygon at world Z=93.82, the underside of the cottage main floor's thickness layer. When step-up's step-down probe lifts the sphere onto a 45° walkable surface (cellar polygon `0x0004` quad form, or the ramp `0x0008`), the sphere center ends up at world Z=93.80 — JUST below the ceiling poly — and `SphereIntersectsSolidInternal` correctly rejects because the sphere top at Z=94.28 overlaps the ceiling polygon.
4. **Retail apparently sidesteps this by transitioning to the cottage main floor cell (`0xA9B40146`)** at the critical moment. Retail's BP7 shows ContactPlane being set to `(0,0,1) d=-93.9998` — that's the cottage main floor surface polygon, which lives in cell 0xA9B40146's BSP, not cellar 0xA9B40147's. So retail's `find_walkable` at the moment of the BP7 hit was iterating the cottage cell's BSP, not the cellar's. The cell promotion happens; ours doesn't.
**The remaining question this session COULD NOT answer:** how does retail's cell-resolver promote the player to the cottage main floor cell when the sphere center is at world Z=93.80 (below the cottage floor surface at Z=94)? This is the next-session target.
## What shipped this session
| Commit | What |
|---|---|
| (this session) | A6.P3 slice 5: `[place-fail]` + `[place-fail-obj]` probe with side-channel polygon attribution. Three files: `PhysicsDiagnostics.cs` (probe gate + emitter + side-channel fields), `BSPQuery.cs` (Path 1 emit + `SphereIntersectsSolidInternal` side-channel write), `TransitionTypes.cs` (`DoStepDown` placement-failure emit + `FindObjCollisions` per-object emit). |
The probe runs zero-cost when off (`ACDREAM_PROBE_PLACEMENT_FAIL=0`).
Test baseline: 1148 pass + 8 pre-existing fail (unchanged).
## The capture evidence
Captures archived to `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_place_fail/`:
- `acdream.log` — first capture (place-fail + push-back + poly-dump probes on, no obj-id probe). 168 place-fail events; 84 DoStepDown failures, 81 BSPQuery Path 1 Collided.
- `acdream_v2_with_obj_probe.log` — second capture with `[place-fail-obj]` added. 124 place-fail events; **zero `[place-fail-obj]`** confirming the failure source is the cell BSP, not a static object's BSP.
### Aggregated breakdown (acdream.log)
```
=== source breakdown ===
84 source=DoStepDown
67 source=Path1.sphere0
17 source=Path1.sphere1
=== polyId distribution in Path1 lines ===
80 polyId=0x0020 ← n=(0,0,-1) d=-0.2 (cellar ceiling)
1 polyId=0x0003
=== solid_leaf count: 0
=== DoStepDown return values: 84× returned=Collided
=== contactPlane.Nz in DoStepDown failures ===
79 contactPlane.Nz=0.7071 ← 45° walkable (poly 0x0004 quad form)
5 contactPlane.Nz=0.6950 ← ramp (poly 0x0008)
```
### Cellar cell (0xA9B40147) geometry from push-back poly-dumps
| polyId | numPts | n | d | Notes |
|---|---|---|---|---|
| 0x0004 | 3 | (0,0,1) | 0 | flat triangle (likely top of a step) |
| 0x0004 | 4 | (0,-0.707,0.707) | -0.247 | **45° walkable quad — the step that triggers step-up** |
| 0x0008 | 4 | (0,-0.719,0.695) | -0.1007 | **the cellar ramp (46° slope)** |
| 0x0018 | 4 | (0,0,1) | 3.05 | cellar floor (world Z = 94.02 + (-3.05) = 90.97) |
| 0x0019 | 4 | (0,0,1) | 3.05 | cellar floor (additional polygon) |
| 0x001B | 4 | (0,0,1) | 3.05 | cellar floor (additional polygon) |
| **0x0020** | — | **(0,0,-1)** | **-0.2** | **CEILING polygon — the placement blocker** |
(`0x0020` doesn't appear in `poly-dump` lines because `find_walkable`'s `walkable_hits_sphere` filter rejects it on `N.up < walkable_allowance`; only the place-fail probe surfaced it.)
### Cellar cell origin (confirmed by direct probe)
`worldOrigin=(130.5, 11.5, 94.02)` for cell 0xA9B40147. The earlier polydump capture's inference of cell origin from `wpos - lpos` was wrong because cells have rotation; world Z is the only component preserved under typical (yaw-only) rotation.
### Spatial layout
- World Z = 90.97 — cellar floor (polygons 0x0018/19/1B)
- World Z = 93.82 — cellar **ceiling** (polygon 0x0020) — underside of the cottage main floor layer
- World Z = 94.00 — cottage main floor surface (in cell 0xA9B40146)
- World Z = 94.48 — sphere center when "resting on" cottage main floor (radius=0.48)
A sphere with center at world Z between 93.34 (= 93.82 0.48) and 94.48 (= 94 + 0.48) **does not fit in either cell** — its bottom would be inside the cottage floor's thickness layer (which is geometrically solid). The place-fail logs show our sphere stuck at Z=93.80 (the bottom of this "tunnel").
## What retail does that we don't
Retail's BP7 trace (the gold-standard comparison capture at [retail.decoded.log](docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_retail_for_issue98/retail.decoded.log)) shows ContactPlane being set 18 times to `(0,0,1) d=-93.9998` — the cottage main floor surface. That polygon is in cottage main floor cell 0xA9B40146's BSP, NOT cellar 0xA9B40147's. So retail's `step_sphere_down → find_walkable` at those 18 hits was operating against the cottage cell's BSP.
**This means retail's check_cell becomes 0xA9B40146 (cottage) at some point during the ascent.** Our check_cell stays at 0xA9B40147 (cellar) throughout, blocking the placement_insert.
The cell-resolver mechanism for the transition is the open question. Hypotheses:
1. **`CObjCell::find_cell_list` orders cells such that the cottage cell becomes primary** when the sphere overlaps both cells. Our `PhysicsEngine.ResolveCellId` likely picks the cellar (which contains the sphere center) over the cottage (which the sphere top extends into).
2. **Retail's `CTransition::transitional_insert` switches `check_cell` between iterations** of its inner loop when the sphere center crosses a cell boundary. Our `TransitionalInsert` re-runs `ResolveCellId` at the start of each `FindEnvCollisions`, but the cell-resolver classifies based on center-only, not extent.
3. **Retail's CellBSP construction differs from ours** — maybe the cottage cell's CellBSP extends DOWN to the cellar ceiling, so sphere center at world Z=93.80 is "inside" the cottage cell's volume. Our parse may have a different boundary.
## Why I didn't ship a fix tonight
Per CLAUDE.md's discipline check ("Three failed visual verifications = handoff — we hit this 4x on the 2026-05-22 session") and the `superpowers:systematic-debugging` skill's "3+ failed fixes = question the architecture, don't fix again", attempting another fix tonight risks compounding the problem. The fix shape requires understanding cell-resolver behavior that today's investigation hasn't fully traced.
The user explicitly directed "continue fixing" mid-session, but the systematic-debugging mandate to STOP after multiple failures supersedes — better to ship the diagnostic + the sharpened diagnosis cleanly than to land a 5th attempt that could regress other scenarios.
## Concrete next-session pickup steps
1. **Capture retail at the cell-transition moment.** Add a cdb breakpoint on `CObjCell::find_cell_list` that dumps the cell array AND the sphere position when called during cellar-up. Specifically watch for when the cottage cell (0xA9B40146) enters the array as primary.
2. **Compare to our `PhysicsEngine.ResolveCellId` behavior** at the same sphere position. Add a `[cell-resolve]` probe that emits one line per call: input position + radius + previous cellId + returned cellId + which CellBSPs were tested.
3. **Likely fix targets (in order of probability):**
- `PhysicsEngine.ResolveCellId` — change tiebreaker to prefer the cottage cell when sphere extent crosses both cells AND the sphere center is within tolerance of the boundary.
- `Transition.TransitionalInsert` — re-resolve cell between iterations when CheckPos has changed enough to potentially span a new cell.
- `PhysicsDataCache.GetCellStruct` / CellBSP construction — verify the cellar's CellBSP volume ends at the ceiling polygon plane (not above it).
4. **DO NOT attempt:**
- Modifying `BSPQuery.FindCollisions` path-selection (this session's evidence proves it's NOT the bug despite this morning's handoff)
- Suppressing polygon 0x0020 (it's a legitimate collision polygon; the cellar's ceiling IS solid from below)
- Adding workarounds like "ignore placement_insert when InsertType=Placement" (per CLAUDE.md: no workarounds without approval)
5. **Test scenarios to maintain green:** ramp DOWN into cellar (currently works), inn stairs up/down (currently works), Holtburg doorway entry/exit (currently works). The fix must preserve these.
## Files touched this session
- [`src/AcDream.Core/Physics/PhysicsDiagnostics.cs`](src/AcDream.Core/Physics/PhysicsDiagnostics.cs) — added `ProbePlacementFailEnabled` + side-channel + `LogPlacementFail`.
- [`src/AcDream.Core/Physics/BSPQuery.cs`](src/AcDream.Core/Physics/BSPQuery.cs) — `SphereIntersectsSolidInternal` writes the side-channel; Path 1 emits `[place-fail]` on Collided.
- [`src/AcDream.Core/Physics/TransitionTypes.cs`](src/AcDream.Core/Physics/TransitionTypes.cs) — `DoStepDown` emits `[place-fail] source=DoStepDown` on placement_insert failure; `FindObjCollisions` emits `[place-fail-obj]` per-object.
## Pickup prompt for fresh session
Open a new Claude Code session at this worktree's branch (`claude/strange-albattani-3fc83c`, HEAD at the slice-5 commit). Then paste:
---
```
Pick up A6.P3 slice 6 — fix issue #98 (cellar ascent stuck at top).
Read FIRST:
docs/research/2026-05-22-a6-p3-slice5-handoff.md
docs/ISSUES.md issue #98 entry
docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_place_fail/acdream.log
Then state both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 slice 6 — fix #98 via cell-promotion at cellar/cottage boundary
Next concrete step: capture retail's CObjCell::find_cell_list behavior at the
cellar-to-cottage cell transition (when sphere is at world Z near 94, sphere
top extends into cottage cell volume) and compare to our
PhysicsEngine.ResolveCellId. The fix is in cell-resolver, NOT BSPQuery.
Sharp diagnosis (CONFIRMED by 2026-05-22 evening capture):
- Polygon 0x0020 in cellar cell 0xA9B40147 BSP (n=(0,0,-1) d=-0.2, world Z=93.82)
correctly rejects placement_insert when sphere top extends past it.
- Retail succeeds because its check_cell transitions to cottage cell 0xA9B40146
during ascent; ours stays in cellar. Cell-resolver fix needed.
- The 2026-05-22 morning handoff's "Path 5 vs Path 6 in BSPQuery.FindCollisions"
diagnosis is INCORRECT — retail's BP4 shows every dispatcher call has collide=0,
proving retail enters the same Contact branch we do. The bug is downstream.
DO NOT re-attempt:
- Path-selection in BSPQuery.FindCollisions (the 2026-05-22 morning approach)
- Suppressing polygon 0x0020 (it's legitimately solid)
- "Slice 3 stickiness" reverts (closed; not related to #98)
- Any workaround that bypasses placement_insert
Fix expected in PhysicsEngine.ResolveCellId or Transition.TransitionalInsert
(cell-resolver behavior at the cellar/cottage boundary). Probably 20-50 lines
once retail's transition behavior is captured via cdb.
Test baseline: 1148 + 8. Maintain.
CLAUDE.md rules apply. No workarounds without explicit approval.
```

File diff suppressed because it is too large Load diff

View file

@ -1,649 +0,0 @@
# A6.P3 #98 — Comparison harness shipped, root cause identified
**Session:** 2026-05-23 evening (continuation of full-day session)
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
**Branch:** `claude/strange-albattani-3fc83c`
Read this AFTER the morning's handoff doc
([`2026-05-23-a6-p3-issue98-harness-handoff.md`](2026-05-23-a6-p3-issue98-harness-handoff.md)) —
this picks up from "Option A: build the side-by-side comparison harness" and
documents the FIRST evidence-driven step in the saga.
---
## TL;DR
**Updated 2026-05-23 evening v3: NEW root-cause hypothesis identified —
STALE RAMP CONTACT PLANE causes per-tick Z drift, which is what makes
the cottage-floor cap reachable in the first place.**
- Player position at cap: world (141.5, 7.2, 92.7). The cellar ramp's
actual world XY is X=[129.7, 131.3] — the player is **10 meters away
from the ramp** in cell-local space.
- Body's contact plane: ramp's plane (n=(0, 0.719, 0.695), d=-69.5035).
Stale; should be the flat cellar floor (n=(0,0,1)).
- AdjustOffset projects forward motion along that stale ramp plane.
Mathematically: requested delta (+0.0266, -0.4022, 0) → projected
(+0.0266, -0.1943, +0.2010). **+0.2010 m of Z lift per tick.**
- After enough horizontal-walking ticks, the head sphere rises to
Z=94 and hits the cottage floor's downward-facing back-face polygon.
Cap fires.
- The cap is a SYMPTOM. The root cause is the contact plane not
refreshing when the player walks off the ramp onto the flat cellar
floor. Retail must re-find the walkable plane each tick; we're
keeping the stale ramp seed.
**This explains why six prior fix attempts missed.** Step-up,
AdjustOffset projection, SidesType, edge-slide, +X residual — all
were investigating the cap event mechanics, not the upstream Z drift
that made the cap reachable. The harness convergence (Section "What
shipped 2026-05-23 evening v2") is still valuable as the deterministic
reproduction infrastructure; the new hypothesis is the **next** thing
to verify against that infrastructure.
(Sections below preserve the evening-v2 arc for context: apparatus +
cap-event reproduction.)
- **Evidence-driven apparatus shipped.** `PhysicsResolveCapture` writes one
JSON Lines record per player ResolveWithTransition call when
`ACDREAM_CAPTURE_RESOLVE=<path>` is set. 41,228 records from a single
cellar-walk session.
- **Comparison test reproduces the cap divergence on the first try.** The
new `LiveCompare_*` tests in `CellarUpTrajectoryReplayTests.cs` load three
representative records (spawn, on-ramp, first-cap) and replay them
through the harness engine. Spawn + on-ramp PASS bit-perfect; first-cap
FAILED with a clear divergence — the right divergence.
- **Root cause identified: the cottage GfxObj was missing from the harness.**
Live cap attributes the blocking entity to `obj=0xA9B47900` — a
landblock-baked static building. The cottage's floor polygons live in
this GfxObj's polygon table (registered as a ShadowEntry), NOT in any
cottage CELL.
- **Apparatus convergence (v2 update).** With the cottage GfxObj
`0x01000A2B` extracted via the new `ACDREAM_DUMP_GFXOBJS` infrastructure
and registered as a ShadowEntry in `BuildEngineWithCellarFixtures`, the
harness now reproduces the live `cn=(0,0,-1)` cap exactly. The
full per-field round-trip reveals one residual: live preserves
+0.0266 m of +X motion through the cap; harness blocks all motion.
That's the next investigation target — see the "Residual divergence"
section below.
- **Not a step-up / AdjustOffset bug.** The head sphere (top at Z=foot+1.2)
hits the cottage floor at Z=94.0 from BELOW. Math: cap at foot Z=92.74
matches 94.0 1.2 = 92.80. Confirmed by user reporting same cap when
JUMPING in the cellar (purely vertical motion). The retail comparison
question is now sharpened to "how does live's post-cap edge-slide
preserve the +X component that the harness drops?"
---
## What ran this session (chronological, 3 commits)
| Commit | What |
|---|---|
| `fb5fba6` | Apparatus: `PhysicsResolveCapture` static class + JSON Lines writer + body snapshot record + capture probe in `ResolveWithTransition` + smoke tests (capture writes when IsPlayer + enabled, skips otherwise) |
| `44614ab` | Comparison test: 3 fixture records sampled from live capture + 3 `LiveCompare_*` tests + diagnostic dump that prints cell polygons in world frame |
| `0f2db62` | Converted FirstCap test to documents-the-bug pattern (passes while harness lacks cottage GfxObj; fails when added) |
Live capture launches:
- `launch-a6-issue98-capture.ps1` — first capture run (no probes beyond cell-transit). Produced `a6-issue98-resolve-capture.jsonl` (12 MB, 5789 records when checked mid-session, finished at 91 MB / 41,228 records).
- `launch-a6-issue98-polydump.ps1` — second capture with `ACDREAM_PROBE_POLY_DUMP`, `ACDREAM_PROBE_PUSH_BACK`, `ACDREAM_PROBE_RESOLVE`, `ACDREAM_PROBE_INDOOR_BSP`, and `ACDREAM_DUMP_CELLS` covering 0xA9B40140-0xA9B4014F. Produced `a6-issue98-resolve-capture-2.jsonl` (135 MB, 70,572 records) plus 16 cell-dump JSON fixtures and a launch log with 214 [poly-dump] entries.
---
## The apparatus (committed code)
### `PhysicsResolveCapture` ([`src/AcDream.Core/Physics/PhysicsResolveCapture.cs`](../../src/AcDream.Core/Physics/PhysicsResolveCapture.cs))
Static module. When `ACDREAM_CAPTURE_RESOLVE=<path>` is set, every player-side
`PhysicsEngine.ResolveWithTransition` call appends one JSON Lines record:
```json
{
"tick": 0,
"timestampMs": 40919993,
"input": { ... full inputs ... },
"bodyBefore": { ... full PhysicsBody snapshot ... },
"result": { ... full ResolveResult ... },
"bodyAfter": { ... full PhysicsBody snapshot ... }
}
```
Filtered to `IsPlayer` mover flag so NPC / remote DR calls don't pollute.
Thread-safe writer with per-record flush. Process-exit hook for clean
shutdown.
### Comparison harness ([`tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`](../../tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs))
Three `LiveCompare_*` tests + one diagnostic dump:
| Test | Outcome | Meaning |
|---|---|---|
| `LiveCompare_Tick0_Spawn` | PASSES | Spawn at Z=92.5333; engine matches live bit-perfect |
| `LiveCompare_Tick376_OnRamp` | PASSES | Player on ramp at Z=91.49; ramp walkable polygon hydrates correctly, engine reproduces live |
| `LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered` | PASSES (documents the bug) | Live cap at Z=92.74 with cn=(0,0,-1); harness does NOT reproduce because cottage GfxObj isn't registered |
| `LiveCompare_FirstCap_DiagnosticDump` | PASSES (probe-only) | Prints cell polygons in world frame + enables every probe — captured stdout shows harness BSP query path |
The diagnostic dump test runs the cap replay with `[poly-dump]`, `[push-back]`,
`[indoor-bsp]`, `[step-walk]` probes ALL enabled. The captured stdout shows:
```
[cell-dump] 0xA9B40147 resolved-poly-count=37
poly id=0x0018 ... worldVerts=[(140.12,11.50,90.95),...(142.10,11.50,90.95)] ← cellar floor
poly id=0x0001 ... worldVerts=[(142.10,11.50,93.80),...(140.50,8.70,93.80)] ← cellar ceiling
[cell-dump] 0xA9B40143 resolved-poly-count=14
poly id=0x0004 ... worldVerts=[(136.70,3.90,94.00),(140.50,3.90,94.00),(140.50,8.70,94.00)] ← cottage floor (triangle)
... more cottage floor triangles, all at world Z=94.00 ...
[other-cells] primary=0xA9B40147 iter=0xA9B40143 wpos=(141.605,7.097,93.351) result=OK poly=n/a
[other-cells] primary=0xA9B40147 iter=0xA9B40146 wpos=(141.605,7.097,93.351) result=OK poly=n/a
```
Both other-cells iterations return OK — the cottage floor polys in
0xA9B40143 don't extend to the sphere's XY (X=141.39 > rightmost-vertex
X=140.50). So the harness sees no collision, even though the live engine
does.
---
## How we identified the missing object (it's NOT a cell)
The second capture pass enabled `ACDREAM_PROBE_RESOLVE=1`, which logs
each call's hit details including the entity guid of the blocking object.
The cap event prints:
```
[resolve] ent=0x000F4240 in=(141.605,7.304,92.656) tgt=(141.624,6.875,92.656)
out=(141.605,7.304,92.656) ok=True groundedIn=True cp=valid
hit=yes n=(0.00,0.00,-1.00) obj=0xA9B47900 walkable=True
```
**obj=0xA9B47900** is in the landblock-baked static range (0xA9B47XXX
guids belong to landblock 0xA9B4's static objects). This is the cottage
BUILDING as a GfxObj registered as a ShadowEntry on the landblock —
NOT a cottage cell.
The harness's `BuildEngineWithCellarFixtures` loads three CELL fixtures
(0xA9B40143, 0xA9B40146, 0xA9B40147) but **does not register any
landblock-baked static**. There IS a `RegisterStairRampGfxObj` helper
that constructs ONE polygon (the ramp), but it's commented out today.
So the missing apparatus is: register the cottage GfxObj as a ShadowEntry
with its FULL polygon table — ramp + walls + floor + ceiling. Once
registered, the harness's multi-cell BSP iteration's
`FindObjCollisions` will query the GfxObj's BSP and find the cottage
floor polygon's downward-facing plane just like live.
---
## The cap geometry (math)
Live capture analysis confirmed the sphere physics:
- Foot sphere center at world Z = foot_z, radius 0.48m
- Head sphere center at world Z = foot_z + sphereHeight = foot_z + 1.2m
- Head sphere top at Z = foot_z + 1.2 + 0.48 = foot_z + 1.68m
Cap point in live capture: foot_z = 92.7390 (from tick 1183).
Predicted head sphere position: head center = 93.9390, head top = 94.4190.
The cottage floor is at world Z = 94.0 (from cell 0xA9B40143's poly 0x04
worldVerts: `(136.70,3.90,94.00)`, etc.).
**Head sphere center at Z=93.94 is BELOW the cottage floor at Z=94.0 by 0.06.**
**Head sphere top at Z=94.42 is ABOVE the cottage floor by 0.42.**
The head sphere PENETRATES the cottage floor. BSP push-back direction
is the negative of the polygon's outward normal (which is +Z facing UP),
so push-back direction is Z (pushes sphere DOWN). That matches the
live cn=(0,0,-1).
The "exact" cap position: foot_z when head center is at Z=94.0 (just
touching). foot_z = 94.0 1.2 = 92.80. The observed cap at foot_z=92.74
is ~0.06 below the predicted (push-back includes epsilon and walk-interp
adjustments).
---
## User's confirming observation
> "I noticed a thing. When I jump in the cellar, I'm getting blocked at
> the same height (I think) as I am when running up the stairs."
This is the key observation that nailed the diagnosis. **Jumping is
pure vertical motion** — no ramp slope, no AdjustOffset projection. If
the cap fires on a pure jump, the obstruction must be a horizontal
geometric obstacle at the cap height. That immediately rules out every
step-up / AdjustOffset hypothesis from the prior 6+6 saga and pinpoints
the bug as a head-sphere head-on collision with a cottage-floor
polygon facing DOWN.
---
## What's NOT yet known
1. **Why retail doesn't have this cap.** Either:
- (a) Retail's cottage GfxObj has a HOLE in the floor above the ramp
(cottage floor polygons stop at the ramp opening; our dat-read
produces a contiguous floor)
- (b) Retail's BSP query treats single-sided polygons correctly
(cottage floor's SidesType allows collision from +Z side only,
not from Z side; we treat it as both-sided)
- (c) Retail uses portal-aware collision: when the sphere is inside
the cellar EnvCell, queries skip polygons that belong to the
cottage portal's "other side"
Need a retail cdb trace at the ramp-top to disambiguate.
2. **The cottage GfxObj's full polygon list.** We have the ramp polygon
(poly 0x0008 in the cottage GfxObj, normal (0,-0.719,0.695)) and we
know the floor polygon is at Z=94.0 with normal (0,0,-1) or (0,0,+1).
We do NOT have:
- the full polygon list of GfxObj 0xA9B47900
- the cottage GfxObj's id, BSP root, or scale/rotation
These can all be extracted by enabling `ACDREAM_PROBE_BUILDING=1` for
a future capture — the `[resolve-bldg]` probe dumps per-poly geometry
when a building shadow entry is hit.
3. **`ACDREAM_PROBE_POLY_DUMP` doesn't fire for the cottage hit.** The
[poly-dump] probe is wired into `AdjustSphereToPlane`, but the
cottage-floor collision goes through `FindObjCollisions`
`BSPQuery.FindCollisions` on the GfxObj's internal BSP — a different
code path. Future probing should use `ACDREAM_PROBE_BUILDING` instead
to capture the per-object collision details.
---
## Next-session pickup
### What shipped 2026-05-23 evening v2 (post-prior-section)
Three commits land apparatus convergence on the cap event:
| Commit | What |
|---|---|
| `cc3afbc` | **GfxObj dump infrastructure.** Mirrors `ACDREAM_DUMP_CELLS`: new env var `ACDREAM_DUMP_GFXOBJS` triggers `PhysicsDataCache.CacheGfxObj` to write the full resolved polygon table as JSON, suffix `.gfxobj.json` so dumps don't collide with cell dumps in the same dir. New `GfxObjDump` DTO + `GfxObjDumpSerializer` parallel to `CellDump`; round-trip tests cover Capture / Write / Read / Hydrate; the Hydrate path constructs a synthetic single-leaf BSP for query coverage. |
| `97fec19` | **Harness reproduces the cottage-floor cap event.** `BuildEngineWithCellarFixtures` now registers a stub landblock 0xA9B40000 (TerrainSurface at z=-1000) so `TryGetLandblockContext` succeeds at the cellar XY, plus a new `RegisterCottageGfxObj` helper that loads the dumped cottage GfxObj fixture, hydrates it with synthetic BSP, and registers as a ShadowEntry at world (130.5, 11.5, 94.0) with 180° Z rotation — matching production's `GameWindow.cs:5893` registration shape for landblock-baked statics. The cottage fixture (74 polys, 6 downward-facing floor triangles, BSP radius 13.989 m) lives at `tests/.../Fixtures/issue98/0x01000A2B.gfxobj.json`; capture launch script is `launch-a6-issue98-cottage-gfxobj-dump.ps1`. |
Test outcome at apparatus convergence:
| Test | Outcome | Meaning |
|---|---|---|
| `LiveCompare_Tick0_Spawn` | PASS | Spawn round-trip preserved by the new landblock + cottage state |
| `LiveCompare_Tick376_OnRamp` | PASS | On-ramp round-trip preserved |
| `LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal` | PASS (NEW) | Harness reproduces the live cn=(0,0,-1) cap-event normal exactly |
| `LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation` | PASS (documents-the-bug) | Captures the ONE remaining post-cap divergence: live preserves +0.0266 m of +X motion through the cap (edge-slide along the cottage floor in XY); harness blocks ALL motion. Y and Z agree. |
### The residual divergence (next investigation target)
After registering the cottage GfxObj:
```
Live: cn=(0,0,-1), position=(141.3865, 7.2243, 92.7390) ← +X motion preserved
Harness: cn=(0,0,-1), position=(141.3599, 7.2243, 92.7390) ← X stuck at input
Input: currentPos=(141.3599, 7.2243, 92.7390)
targetPos =(141.3865, 6.8221, 92.7390)
requestedDelta=(+0.0266, -0.4022, 0)
```
The cap-event collision normal matches bit-perfect. Position diverges
in X only. Working hypothesis: live's response to a `cn=(0,0,-1)`
head-bump treats it as a Z-only constraint and edge-slides the
remaining XY component along the cottage floor; harness's BSP path is
rejecting the entire move vector instead of computing a slid offset.
That hypothesis is the next-session investigation target — work the
slide path in `Transition.transitional_insert` / `AdjustOffset` against
the production cap-event call. The new
`LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation`
test PASSES today (asserting the current residual) and FAILS when the
divergence closes — that's the signal to flip it into
`AssertCallMatchesCapture` form.
### Alternative pickup move: retail cdb trace at the cottage ramp-top
If apparatus polish is enough and the user wants to widen the question
to "how does retail differ?", attach cdb to a running retail acclient
(see CLAUDE.md "Retail debugger toolchain"), set breakpoints on
`BSPTREE::find_collisions` and `CGfxObj::shadow_find_obj_collisions`,
walk up the cottage ramp, and log every BSP query against the cottage
GfxObj. Compare which polygons retail finds vs which polygons our
acdream engine finds. Retail's trace is the ultimate oracle for the
"how does retail differ?" question — but the apparatus-side X residual
investigation is the more focused, faster-feedback next step.
### Pre-existing test flakiness (out of scope but documented)
While verifying the cottage helper, the full `dotnet test` serial run
produced 819 failures across 1192 tests depending on order — the
suite has static-state leakage between test classes (likely from
`PhysicsResolveCapture.CapturePath`, `PhysicsDiagnostics.Probe*Enabled`,
and similar global mutators). The flakiness is **independent of A6.P3**:
stashing the cottage helper out and rerunning produces the same flaky
range. All 21 issue-#98-relevant tests (12 harness + 4
`GfxObjDumpRoundTripTests` + 1 new `PhysicsDiagnosticsTests` + 4
`CellDumpRoundTripTests`) pass deterministically in isolation.
---
## Apparatus that exists to use
| Tool | Location | Status |
|---|---|---|
| `PhysicsResolveCapture` | `src/AcDream.Core/Physics/` | Production-ready; env-var gated; off by default |
| `LiveCompare_*` tests | `tests/.../CellarUpTrajectoryReplayTests.cs` | 4 tests; 1 documents the bug, 3 are matches |
| `live-capture.jsonl` fixture | `tests/.../Fixtures/issue98/` | 3 representative records (spawn, on-ramp, first-cap) |
| `launch-a6-issue98-capture.ps1` | worktree root | Capture-enabled launch (no diagnostic probes) |
| `launch-a6-issue98-polydump.ps1` | worktree root | Capture + poly-dump + push-back + dump-cells launch |
| 16 cell-dump fixtures | `tests/.../Fixtures/issue98/0xA9B4014X.json` | All cells in 0xA9B4014X range from second capture |
| 41K-record live capture | `a6-issue98-resolve-capture.jsonl` (gitignored size) | First capture — full session of cellar movement |
| 70K-record live capture w/ probes | `a6-issue98-resolve-capture-2.jsonl` | Second capture — included poly-dump events |
| `a6-issue98-polydump-launch.log` | worktree root | 56K+ line log with [resolve], [poly-dump], [other-cells], [indoor-bsp] events |
---
## The stale-contact-plane finding — full evidence (2026-05-23 evening v3)
### How the question led to the answer
User asked: "We know how retail OPENs it from above, how hard can it
be to know how to open it from below?" — the implicit question being
"if walking on the cottage floor from above works fine, why doesn't
walking up from below?"
That reframed the investigation. The cottage floor is the SAME
polygon set whether viewed from above (walking on it) or below
(head-bumping it from the cellar). Retail handles both. If our cap
fires from below, what's different about our state?
Tracing the harness's `LiveCompare_FirstCap_DiagnosticDump` output
revealed:
1. **The contact plane the engine started with**: ramp's plane
`n=(0, 0.7190, 0.6950), d=-69.5035`. From the live capture's
`bodyBefore.contactPlane`.
2. **Cellar ramp's actual world position**: vertices computed from
the cellar cell's fixture put the ramp at world
X∈[129.7, 131.3], Y∈[10.19, 13.09], Z∈[92.5, 95.5]. The ramp is
in the +Y corner of the cellar, ~1.6 m wide.
3. **Player position at cap**: world (141.5, 7.22, 92.74). 10+ m
away from the ramp in X.
4. **The +Z drift math**: `AdjustOffset` projects the requested
motion onto the plane perpendicular to the contact-plane normal:
- requested = (+0.0266, -0.4022, 0)
- dot(requested, ramp normal) = 0·0.0266 + 0.719·(-0.4022) +
0.695·0 = -0.2892
- projected = requested - (-0.2892)·rampNormal =
(+0.0266, -0.1943, +0.2010)
- **+0.2010 m of Z gain per tick**, applied because the contact
plane the engine believes the player is on is the slope.
5. **The cap math**: foot Z at cap = 92.74. Head sphere center at
foot Z + sphereHeight 1.2 = 93.94. Head sphere top at
foot Z + 1.68 = 94.42. **Cottage floor at world Z=94.00.** Head
sphere top exceeds cottage floor by 0.42 m → cap fires from
below.
If the contact plane were the flat cellar floor (n=(0,0,1) at
Z=90.95) instead of the ramp, AdjustOffset's projection would
produce zero Z gain (requested motion has no Z component, projection
onto flat-floor plane preserves XY). No drift, no cap.
### Why this fits the user-facing bug
- "Stuck climbing cellar" — the player walks forward, accumulates Z,
bumps cottage floor, can't progress. Matches what the user sees.
- "Pure jump in cellar caps at same Z" — jumping doesn't refresh the
contact plane either. Drift continues. Matches.
- "Six prior fix attempts failed" — all attempted to fix the CAP
mechanics (step-up, slope projection at the cap, edge-slide). None
questioned why the contact plane was the ramp at all.
### What still needs verification (next session's task)
1. **Chronological evidence**: walk the live capture from the start of
the cellar session. When did the player last stand on the actual
ramp? Does `bodyBefore.contactPlane` persist as the ramp's plane
across many ticks of horizontal walking? Quantify the cumulative
Z drift.
2. **The walkable-refresh gap**: where in
`Transition.FindEnvCollisions` / `SpherePath.SetWalkable` /
related is the contact plane supposed to be refreshed when the
sphere is over a different walkable polygon? Retail's
`CObjCell::find_env_collisions` is the decomp anchor — find the
path that detects a NEW walkable and overwrites the contact
plane, and find where our engine skips that.
3. **Retail cdb cross-check** (optional, definitive): attach cdb to a
running retail acclient, walk to a cottage cellar, log the
contact plane each tick. If retail's contact plane refreshes
to (0,0,1) when the player walks off the ramp, hypothesis
confirmed.
---
## Pickup prompt for next session
```
A6.P3 #98 — apparatus convergence landed, NEW root-cause hypothesis
(stale ramp contact plane) needs verification.
Read FIRST (in order, ~15 min):
1. docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
— start with TL;DR (evening v3 update at top), then the section
"The stale-contact-plane finding — full evidence" near the bottom.
Skip the middle sections (evening v1 + v2 arcs) unless context is
needed.
2. CLAUDE.md "Current A6 phase" block — look for the "Evening v3
finding" paragraph.
3. tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
— the RegisterCottageGfxObj helper + 2 LiveCompare_FirstCap_*
tests are what you'll iterate against.
State both altitudes (one sentence each):
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 — apparatus convergence shipped (cap event
reproduces bit-perfect). New root-cause hypothesis: stale ramp
contact plane causes per-tick Z drift that makes the cap reachable.
Needs verification.
What was shipped today (3 commits — DO NOT REDO):
- cc3afbc: GfxObj dump infrastructure (ACDREAM_DUMP_GFXOBJS)
- 97fec19: Harness reproduces cottage-floor cap (RegisterCottageGfxObj)
- 7729bdc + (this commit): findings doc + CLAUDE.md updates
The hypothesis with full math:
- Body's contact plane = ramp's plane (n=(0,0.719,0.695), d=-69.5035)
- Player position at cap = world (141.5, 7.22, 92.74)
- Cellar ramp's actual world XY = X∈[129.7, 131.3] — 10m from player
- AdjustOffset projects requested move along contact-plane perpendicular
- Per-tick Z gain ≈ 0.201m from slope projection on STALE ramp plane
- Accumulates over ticks → head sphere reaches Z=94 → bumps cottage
floor → cap fires
- If contact plane refreshed to flat cellar floor (n=(0,0,1)) when
player walks off ramp, no Z drift, no cap
Concrete next moves (in order):
(1) **Verify the hypothesis chronologically.** Walk
a6-issue98-resolve-capture-2.jsonl (or the cottage capture
fixture's full file) from the start. Find when the player last
stood on the actual ramp (within world X∈[129.7, 131.3], Y∈[10.19,
13.09]). Quantify: how many ticks does the body's contact plane
persist as the ramp's plane while the player walks horizontally
away? Compute the cumulative Z drift. Should match observed Z=92.74
at cap if the hypothesis holds. (Probably 30 min PowerShell jq.)
(2) **Locate the walkable-refresh code path.** In
src/AcDream.Core/Physics/TransitionTypes.cs, search for where
Transition.FindEnvCollisions or SpherePath.SetWalkable is supposed
to detect a new walkable polygon under the sphere and overwrite
the contact plane. The fix likely lives at the call site that
EITHER fails to fire OR fires but doesn't replace the existing
contact plane.
(3) **Cross-ref retail decomp.** acclient_2013_pseudo_c.txt's
CObjCell::find_env_collisions + the walkable-detection chain.
Find the path where retail unconditionally replaces
contact_plane when a new walkable is found. Quote the line
numbers in the fix commit.
(4) **Implement the fix + verify against harness.** The harness's
LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal test
currently PASSES asserting the cap reproduces. After the fix,
if the contact plane refreshes correctly, the cap should NOT fire
(no Z drift to make it reachable). The test should start FAILING
— that's the signal the fix works.
(5) **Visual verification (user-side).** Launch acdream live, walk
into a Holtburg cottage, down to the cellar, then back up. The
user-facing bug should resolve if the hypothesis is correct.
Decomp grep targets:
- CObjCell::find_env_collisions
- CPhysicsObj::find_object_collisions
- CTransition::find_walkable
- CSpherePath::set_walkable / walkable_hits_sphere
- OBJECTINFO::object → contact_plane writes
CLAUDE.md rules apply throughout:
- NO speculative fixes — the saga's converted to evidence-driven.
Verify hypothesis with chronological capture BEFORE coding.
- Visual verification belongs to the user.
- If the chronological verification (step 1) shows the contact
plane is NOT actually stale across many ticks, the hypothesis is
wrong — pivot to retail cdb trace (definitive oracle).
Out-of-scope but observed: pre-existing test suite has 819 failures
across runs of the same code due to static-state leakage between test
classes (PhysicsResolveCapture, PhysicsDiagnostics statics). Targeted
issue-#98 tests pass deterministically in isolation. Don't touch the
flakiness this session; it's a separate investigation.
Test baseline: harness's 12 CellarUpTrajectoryReplayTests + 4
GfxObjDumpRoundTripTests + 1 new PhysicsDiagnosticsTests + 4
CellDumpRoundTripTests all pass in isolation. Maintain.
Test baseline: 1178 + 8 pre-existing failures (serial run).
Maintain throughout. The previously-failing
LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered
test is now in documents-the-bug form (PASSES while bug exists; FAILS
when fix lands) — flip it when the cottage GfxObj is registered.
```
---
## Resolution 2026-05-24
### What was wrong with the evening-v3 hypothesis
The v3 "stale ramp contact plane" hypothesis (top of this doc) was
**FALSIFIED** by chronological walk of `a6-issue98-resolve-capture-2.jsonl`:
- Player position at the first cap event (tick 55101, line 55102 of the
JSONL): world `(141.605, 7.304, 92.656)`
- `bodyBefore.walkableVertices`: the ramp polygon at world
X∈[140.5, 142.1], Y∈[5.80, 8.70], Z∈[90.99, 93.99]
- Player XY is **inside** the ramp polygon's footprint
- `bodyBefore.contactPlane.normal` = (0, 0.7189884, 0.69502217) — the
ramp's plane
The v3 doc claimed "ramp at world X∈[129.7, 131.3], 10m away from
player." That geometry was computed from a wrong source (not the actual
ramp polygon). The live capture's `walkableVertices` are the ground
truth and show the player IS on the ramp at the cap event. The contact
plane is the ramp's plane because the player is on the ramp — correct,
not stale.
Tick 55020 (line 55021) shows the contact plane refreshing in real time
as the player crossed onto the ramp: `bodyBefore` had the previous
polygon's plane, `bodyAfter` had the ramp's plane. The walkable-refresh
chain works. No drift mechanism exists in the way v3 described.
### What the actual mechanism was
The evening-v2 finding was correct: head-sphere bumps the cottage
GfxObj's downward-facing floor poly (poly 0 in the GfxObj fixture, a
triangle covering world X∈[136.3, 142.5], Y∈[3.5, 19.5], Z=94) from
below. Player at (141.605, 7.304) is inside that triangle. Head sphere
top at Z=foot+1.68=94.336 penetrates the cottage floor at Z=94 by
0.336m → cn=(0,0,-1) push-back → stuck.
Why retail doesn't have this cap: decomp grep of
`CObjCell::find_obj_collisions` (line 308916) shows retail iterates
`this->shadow_object_list` — a **per-cell list**. `CObjCell::find_cell_list`
(line 308742) branches indoor/outdoor at registration time: indoor adds
only the indoor cell + portal-visible neighbors; outdoor adds all
overlapping outdoor cells via `add_all_outside_cells`. So a landblock-
baked static like the cottage gets added to outdoor cells'
shadow_object_list only — never to indoor EnvCells like the cellar.
`CEnvCell::find_collisions` therefore never tests the sphere against
the cottage when sphere is inside the cellar.
`sides_type` (the polygon flag the v2 finding option (b) speculated
about) does NOT affect retail's BSP collision code — it only appears in
rendering/mesh-batch code. The collision-path divergence is purely
architectural: per-cell list vs spatial-radius registry.
### What shipped (commit b3ce505)
Smallest behavioral patch matching retail's effect at the query level:
- `ShadowObjectRegistry.GetNearbyObjects` gained an optional
`primaryCellId` parameter. When indoor (≥ 0x0100), the outdoor radial
sweep is skipped — only indoor-scoped shadows from `indoorCellIds` are
returned.
- `Transition.FindObjCollisions` passes `sp.CheckCellId`.
- Harness `LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal`
flipped to `LiveCompare_FirstCap_FixClosesCottageFloorCap` — asserts
the downward-facing cottage-floor cap does NOT fire after the fix.
- Residual-X-motion test deleted — it documented post-cap edge-slide,
irrelevant once the cap is gone.
Verified: 11/11 cellar harness tests pass. 55 directly-affected physics
tests pass. Pre-existing static-state leakage failures (819 across
serial runs) unchanged. Full `dotnet build` clean.
Visual verification: user confirmed "Finally I can go up!" in the
Holtburg cottage cellar.
### Known regression caused by b3ce505 + next phase
Doorway edge case (flagged in the commit message): doors are server-
spawned entities with their own cylinder collision, registered via
`UpdatePosition` to whichever cell their position resolves to. Doors at
building thresholds typically resolve to outdoor cells. With the
indoor-primary radial-sweep gate, a sphere inside an indoor doorway-
adjacent cell doesn't see the outdoor door → can walk through.
User reported this: "I can also run through doors."
This regression is the direct consequence of NOT doing retail's full
portal-aware shadow propagation at registration time. Retail's
`find_cell_list` indoor branch recurses through `VisibleCellIds` and
adds the object to all portal-visible cells. Our `Register` doesn't do
this; the b3ce505 stopgap covers cottage-cellar but not doorways.
**Next phase: A6.P4 — port retail's per-cell shadow_object_list
architecture in full.** Design spec at
`docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md`
(this session). Approach: refactor `ShadowObjectRegistry.Register` to
compute the cell set via the retail-faithful indoor/outdoor branch +
portal-visible recursion (using `CellPhysics.VisibleCellIds`). Eliminate
the cellScope=0 spatial approximation. `GetNearbyObjects` becomes pure
per-cell list iteration. Removes the b3ce505 stopgap. Closes the door
regression as a side effect.
Also-likely-closed by A6.P4: #97 (phantom collisions on 2nd floor),
indoor sling-out (Finding 3 family), other indoor/outdoor seam bugs.
### Memory updates (this resolution)
- `feedback_retail_per_cell_shadow_list.md` — the architectural lesson
- `feedback_apparatus_for_physics_bugs.md` — the apparatus pattern that
finally cracked this saga (template for future physics bugs)

View file

@ -1,165 +0,0 @@
# A6.P3 issue #98 handoff — 2026-05-23 (early morning)
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
**Branch:** `claude/strange-albattani-3fc83c`
**HEAD at handoff:** `467a81f` (this doc) on top of `cf3deff` (slice 5 probe + diagnosis)
**Status:** Cellar-up still broken. Tonight's slice 6 attempt at placement-insert bypass (multiple variations) did not converge. Worktree code is at the slice 5 baseline (commit `cf3deff`); none of tonight's bypass variations landed. **Investigation direction needs to pivot** — the placement-insert path is not the right place to fix this.
**Pasteable session-start prompt at the bottom of this doc.**
---
## TL;DR
Three sessions on this bug. Each previous session was confident about the diagnosis; each one was wrong:
| Session | Diagnosis | Outcome |
|---|---|---|
| 2026-05-22 morning | `BSPQuery.FindCollisions` Path 5 vs Path 6 path-selection | **Wrong** — slice 5 probe + retail BP4 data proved every retail `find_collisions` hit has `collide=0`, so retail enters the same Contact branch we do. |
| 2026-05-22 evening (slice 5) | Cellar ceiling polygon 0x0020 blocks placement_insert; cell-promotion would unstick the player | **Sharpened but incomplete** — the probe identified the polygon correctly, but cell-promotion alone doesn't fix it. |
| 2026-05-22 late evening (slice 6 attempts, this handoff) | Bypass placement_insert when blocker is a downward-facing cell-boundary polygon | **6+ variations tried, none unstuck the player.** Each variation produced "bypass fires, player still stuck." |
**The CLEAN finding from tonight:** the placement-insert path is NOT the root cause. Bypassing it (in 6 different ways) doesn't unstick the player. The actual blocker is somewhere else in the resolve chain, OR in the geometry pipeline (terrain mesh hole missing).
**User's most actionable clue (not yet investigated):** "Looking down to the cellar I can see that the entry is covered with outside ground. Like the ground continues and covers only the open path down into the cellar." → suggests a missing hole in the outdoor terrain mesh over the cellar entry. That's a terrain-generation bug, not a physics bug.
## What's committed
- `cf3deff` (slice 5) — `[place-fail]` + `[place-fail-obj]` probe + side-channel polygon attribution + the corrected diagnosis in ISSUES.md #98. **This is the durable value from this work.** It rules out the morning's Path 5/6 hypothesis with hard data and gives any future investigator the diagnostic infrastructure to identify which polygon blocks any placement check.
- The two captures at `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_place_fail/`:
- `acdream.log` (probe pass 1)
- `acdream_v2_with_obj_probe.log` (probe pass 2 with object-id emit)
## What did NOT work tonight (reverted)
All six variations of placement-insert bypass in `Transition.FindEnvCollisions` + `Transition.DoStepUp`:
| Variant | What it tried | Failure mode |
|---|---|---|
| **Sibling fallback** | If primary cell's Path 1 placement returns Collided, try other cells via portal-graph BFS. Accept if ANY sibling cell's BSP accepts the sphere. | All siblings (0xA9B40143, 0xA9B40146) also returned Collided. No cell accepts the sphere position. |
| **Cell-boundary bypass** (no lift) | When primary's blocker is a downward-facing polygon (N.Z < -0.5), return OK from FindEnvCollisions without modifying CheckPos. | Sphere stayed where the step-down probe left it (cellar walkable at world Z=93.22). Next tick re-runs same logic. Player oscillates at one Z. |
| **Bypass + ceiling-clearance lift** (`+0.05`) | Same as above, but also lift CheckPos to ceiling_world_z + 0.05 (sphere foot just above ceiling). | Sphere foot stuck at 93.87 across 72 events. Cell-resolver did not promote to cottage cell (cottage CellBSP volume might not extend down to sphere center at world Z=94.35, or rotation makes the check fail). |
| **Bypass + aggressive lift** (`+ diameter + 0.05`) | Lift CheckPos by ceiling + 0.96m so sphere clearly clears the cottage floor thickness layer. | 0 bypass events captured. Possibly client-side issue or geometry placement diverged enough to skip the bypass branch entirely. |
| **Override DoStepDown false result via flag** | When DoStepDown returns false AND CellBoundaryBypassActive=true, override stepDown=true. | The flag is reset at start of each TI iteration, and DoStepDown normally returns true via bypass — so the override branch never fires. Same player position. |
| **Per-bypass +0.1m lift in FindEnvCollisions** | Lift CheckPos by 0.1m each bypass fire. Multiple bypass fires per tick = cumulative climb. | Not properly tested — user signaled fatigue with the repeat-test cycle before this could be evaluated. |
**Common pattern across all variants:** bypass mechanically fires (verified via `[place-bypass]` log entries up to 72 per session). But the player's visual position does not progress in world Z. Sphere world-Z stays in the 93.0-93.9 band across hundreds of bypass events.
## What we KNOW (hard data, slice 5 captures)
1. **Blocking polygon identified:** polyId `0x0020` in cellar cell `0xA9B40147`'s BSP. Plane in cell-local: `n=(0,0,-1) d=-0.2`. World Z=93.82. This IS a real polygon in the dat — it's the underside of the cottage main floor's thickness layer (cellar ceiling).
2. **The polygon's "twin":** polyId `0x0004` quad form (n=(0,-0.707,0.707) d=-0.247) is a 45° walkable INSIDE the cellar. The step-down probe's `find_walkable` converges on this polygon and lifts the sphere to world Z ≈ 93.60 (sphere center).
3. **Cell origin (corrected):** cellar cell `0xA9B40147` has `WorldTransform.Translation = (130.5, 11.5, 94.02)`. My earlier inference of cell origin from `wpos - lpos` ignored cell rotation and was wrong. The probe's direct `worldOrigin` capture is authoritative.
4. **Sibling cells via portal graph:** the cellar connects to `0xA9B40146` and `0xA9B40143`. Neither cell's BSP accepts the sphere placement at world Z=93.60 — both have their own geometry that rejects (cottage floor underside, walls).
5. **Retail's player ascent reaches `ContactPlane = cottage main floor`:** retail's BP7 (`set_contact_plane`) fires 18 times during the ascent, all setting ContactPlane to `(0,0,1) d=-93.9998` (world Z=94, the cottage main floor surface). That polygon lives in some BSP — possibly the cottage main floor cell's BSP — and retail's find_walkable reaches it. Our find_walkable doesn't.
6. **Player input is real:** the user's input log shows MovementForward Press events. The user IS walking. The sphere world-Y advances ~0.3m over 22 ticks of bypass events — confirming forward motion IS being applied, just not climbing.
## What we DON'T KNOW (the open questions)
A. **Why our sphere world-Z doesn't progress despite the step-down probe lifting it onto the 45° walkable.** Each TI iteration's `StepSphereDown` adjusts the sphere upward, but successive iterations don't accumulate. After 5 iterations, sphere stops at the 45° walkable's surface. Maybe walk_interp depletion after iter 1 prevents further lift in iter 2-5; if so, retail must do the same and shouldn't progress either — but retail does. **Hypothesis: retail's `find_walkable` reaches a HIGHER walkable polygon than ours, possibly the cottage main floor itself, possibly via multi-cell iteration.**
B. **Why our ResolveCellId doesn't promote to cottage cell after the lift.** Even with sphere center at world Z=94.35 (above ceiling, above cottage main floor at Z=94), `engine.ResolveCellId` returned the cellar cell. Either the cottage cell's CellBSP volume doesn't extend down to Z=94.35 (geometry quirk), our PointInsideCellBsp test is too strict, or the portal-graph BFS doesn't include the cottage cell as a candidate at this position.
C. **The user's terrain-mesh clue: "outside ground covers the cellar entry."** Not investigated. If the outdoor landblock terrain mesh is missing a hole over the cellar entry, the visible terrain would block the player at the cellar's upward exit. This is a TERRAIN GENERATION bug, completely separate from `BSPQuery.FindCollisions` / `Transition.DoStepUp`. Code to inspect: `LandblockMesh.Build`, scenery generation, building stabs, the dat's `LandBlockInfo.CellsHas` flag handling.
D. **Why descending into the cellar WORKS but ascending doesn't.** The descent is the same physics + same dat geometry. Comparing descent vs ascent might reveal what's symmetric and what's not. We haven't captured `[place-fail]` during descent.
## What did the slice-5 captures actually prove?
Re-read carefully: the data identifies the BLOCKER (polygon 0x0020). But it does NOT prove that bypassing the placement_insert is the right fix. The captures show:
- Retail's BP5 (`adjust_sphere`) fires on the ramp polygon during the ascent (17 hits on `n=(0,-0.719,0.695)`). Sphere climbs from `cz=-1.07` to `+1.05` in object-local. **This is the player CLIMBING THE RAMP.**
- Retail's BP7 sets ContactPlane to the cottage main floor (world Z=94) 18 times. **This is the player REACHING the cottage main floor.**
Both happen in retail. In our client, neither happens — the sphere stays in the cellar's middle, oscillating near the 45° walkable. **The bug is in how our physics PROGRESSES the sphere UP THE RAMP**, not in how it handles the placement_insert at the top.
Maybe the placement_insert problem we obsessed over tonight is a SYMPTOM, not a cause. The sphere is stuck near the cellar ramp top → step-up fires → placement check fails. But the FIRST-ORDER question is: why is the sphere stuck in the middle of the cellar instead of climbing the ramp?
## Most promising directions for the next session
**Order matters — investigate in this sequence:**
1. **Investigate the terrain-mesh clue (highest signal, lowest effort).** Open the client at the cottage entrance, look DOWN into the cellar. If there's terrain covering the cellar's upward opening, that's a major suspect for the physical block. Code to inspect: terrain mesh generation, `LandblockMesh.Build`, hole-cutting where indoor cells exist above terrain. ~30 min investigation.
2. **Capture acdream's [place-fail] log during the cellar DESCENT (currently works) and compare to the ASCENT (doesn't work).** Same dat, same physics. The difference will be obvious.
3. **Add a `[step-walk]` probe** that logs sphere position + ContactPlane + WalkInterp at the start and end of each `ResolveWithTransition` call. Use it to see whether the sphere's Z progresses tick-by-tick during forward walking on the cellar ramp. If Z doesn't progress per tick, the bug is in `AdjustOffset` slope-projection, not in step-up.
4. **Capture retail at the cellar DESCENT** via cdb. Compare to ascent. If retail's `[BP1]` `transitional_insert` reaches different polygons during descent vs ascent, that tells us what's asymmetric.
5. **DO NOT** re-attempt any placement-insert bypass variant. Tonight's 6 variants are conclusive evidence that this code path is not the fix.
## Specific files to inspect for direction #1 (terrain mesh)
- `src/AcDream.App/Rendering/Wb/LandblockMesh.cs` — terrain mesh generation, scenery placement
- `src/AcDream.Core/Rendering/Wb/TerrainUtils.cs` — terrain triangle generation, split formula
- Anywhere that handles `LandBlockInfo.CellsHas` (the "this cell has indoor cells above it" flag)
- WorldBuilder's terrain generation as a reference (in `references/WorldBuilder/`)
## Pickup prompt for fresh session
Open a new Claude Code session at this worktree:
- **Path:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
- **Branch:** `claude/strange-albattani-3fc83c`
- **HEAD:** `467a81f` (this handoff doc) on top of `cf3deff` (slice 5 probe)
Then paste:
---
```
Pick up A6.P3 issue #98 — cellar ascent stuck — with a NEW investigation direction.
Read FIRST:
docs/research/2026-05-23-a6-p3-issue98-handoff.md
docs/research/2026-05-22-a6-p3-slice5-handoff.md
docs/ISSUES.md issue #98 entry
Then state both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 — fix #98 cellar-up
Next concrete step: investigate the terrain-mesh hole over the cellar entry
(user's clue: "outside ground covers only the open path down into the
cellar"). This is direction #1 from the slice 6 handoff.
IMPORTANT: do NOT re-attempt any placement-insert bypass in
BSPQuery.FindCollisions, Transition.FindEnvCollisions, or Transition.DoStepDown.
The 2026-05-22 evening / 2026-05-23 early-morning sessions tried 6 variations
of this approach and none unstuck the player. The slice 5 probe data
identified polygon 0x0020 as the blocker but bypassing it doesn't fix the
underlying issue.
The actual fix is likely in one of these orders of likelihood:
1. Terrain mesh generation missing a "hole" over the cellar entry (#1)
2. Step-down probe's find_walkable doesn't reach the cottage main floor
polygon (which retail's BP7 data confirms IS the eventual ContactPlane)
3. AdjustOffset slope-projection isn't accumulating Z progress on the
cellar ramp (per-tick climb is too slow or zero)
Test baseline: 1148 pass + 8 fail. Maintain through any fix.
CLAUDE.md rules apply. No workarounds without explicit approval.
If the user instructs "continue fixing" after 3+ failed attempts, push back
firmly — the systematic-debugging skill is unambiguous about this, and the
2026-05-22 sessions have proven that swinging through fatigue produces 6+
wasted variations.
```
---
## References
- A6.P3 slice 5 (committed): commit `cf3deff` adds `[place-fail]` probe + diagnosis correction
- Slice 5 handoff: [`docs/research/2026-05-22-a6-p3-slice5-handoff.md`](2026-05-22-a6-p3-slice5-handoff.md)
- Original A6.P3 handoff (morning, since superseded): [`docs/research/2026-05-22-a6-p3-handoff.md`](2026-05-22-a6-p3-handoff.md)
- ISSUES.md #98 entry — has the corrected diagnosis already
- Captures: `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_place_fail/`
- Retail cellar-up gold-standard data: `docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_retail_for_issue98/`

View file

@ -1,229 +0,0 @@
# A6.P3 #98 — Trajectory Replay Harness handoff
**Session:** 2026-05-23 (full day, 10+ commits)
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
**Branch:** `claude/strange-albattani-3fc83c`
This handoff documents the apparatus committed this session, the things we
learned, the things we ruled out, and the concrete next-session pickup move.
Read this first when you resume.
---
## TL;DR
- **#98 is NOT fixed.** Six fix-shape attempts across this saga (4 prior
sessions + 1 this session's Shape 1) all failed or got reverted.
- **The trajectory replay harness is REAL but blocked.** Mechanically
works — runs 200 physics ticks in <100 ms against pre-loaded cell
fixtures. Blocked on a NEW second bug we surfaced during harness
commissioning (airborne-at-tick-1).
- **The cellar ramp polygon is NOT in the cell** — it's in a separate
GfxObj (a static building piece) registered as a ShadowEntry. The
harness reconstructs the ramp polygon programmatically from the live
capture's polydump data.
- **Per the systematic-debugging skill: 6 hypotheses tested without
convergence = stop and reflect.** The next-session move is NOT
another speculative fix attempt — it's a side-by-side comparison
harness against live PlayerMovementController state.
---
## What ran this session (chronological, 10 commits)
| Commit | What |
|---|---|
| `8a232a3` | `[step-walk-adjust]` probe inside `Transition.AdjustOffset` — names which projection branch fires per call + Z gain |
| `8daf7e7` | Findings note + capture snapshot. **AdjustOffset projection is CORRECT** — sphere climbs 90.95 → 92.80 monotonically. Caps at top of ramp because step-up rejects (cottage floor is ABOVE not below). |
| `0cb4c59` | Shape 1 fix attempt: gate `BSPQuery.AdjustSphereToPlane`'s two `SetContactPlane` call sites by `worldNormal.Z >= 0.99`. |
| `402ec10` | Revert Shape 1 — broke OnWalkable for all sloped walkable surfaces (74% of live capture lines in falling state). |
| `5f3b64c` | Session-pause handoff in ISSUES.md + CLAUDE.md. |
| `4c9290c` | Trajectory replay harness (PhysicsEngine + PhysicsDataCache + PhysicsBody + cell fixtures). Mechanics validated. |
| `3d2d10b` | Harness extension: programmatic synthetic stair GfxObj + ShadowEntry. **Discovery:** ramp polygon lives in GfxObj, not cell. |
| `227a775` | Diagnostic dump + 0.05m initial Z lift experiment. Same airborne behavior. |
| `5c6bdbe` | Deep investigation: 6 hypotheses tested via the harness, none isolated root cause of (0,1,0) hit at tick 1. |
---
## What the harness IS (committed apparatus)
[`tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`](../../tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs)
A deterministic trajectory replay that:
1. Loads three issue-#98 cell fixtures (cellar + 2 cottage neighbors) via `CellDumpSerializer.Hydrate`.
2. Wraps each cell with a synthetic single-leaf `PhysicsBSPTree` (`AttachSyntheticBsp`) — needed because Hydrate sets BSP=null and without BSP the indoor branch is skipped.
3. Registers the cellar's stair-ramp polygon as a synthetic `GfxObjPhysics` (`RegisterStairRampGfxObj`) — polygon vertices in WORLD coordinates so the ShadowEntry registers at origin with identity rotation/scale.
4. Constructs a `PhysicsBody` seeded with:
- `ContactPlaneValid=true`, `ContactPlane=(0,0,1,-90.95)` (cellar floor plane)
- `WalkablePolygonValid=true`, `WalkableVertices` = cellar floor poly under sphere XY
- `TransientState = Contact | OnWalkable`
5. Drives N ticks of `PhysicsEngine.ResolveWithTransition` with a constant -Y forward offset (`PerTickOffset = (0, -0.1, 0)`).
6. Returns a per-tick `TrajectoryPoint` list (Tick, Position, CellId, IsOnGround, CpValid).
5 tests, all passing in ~75 ms total. Baseline maintained at 1167 + 5 (harness) = 1172 + 8 pre-existing failures.
### Reusable helpers in the harness
| Helper | Purpose |
|---|---|
| `BuildEngineWithCellarFixtures()` | Full engine setup — cells + synthetic BSPs + (optional) stair GfxObj |
| `AttachSyntheticBsp(CellPhysics)` | Wraps a hydrated cell with a one-leaf BSP referencing every Resolved polygon. **Reusable for any indoor-cell test that needs the indoor BSP path to fire.** |
| `RegisterStairRampGfxObj(engine, cache)` | Constructs a programmatic GfxObj + ShadowEntry for the cellar ramp polygon. **Reusable for any indoor-static-collision test.** |
| `BuildInitialBody()` | PhysicsBody with both ContactPlane AND WalkablePolygon seeded. **The seeding pattern is the discovery** — both must be set or the engine treats the sphere as "grounded but anchorless." |
| `SimulateTicks(engine, body, cellId, N)` | Per-tick driver with proper cross-tick PhysicsBody state. |
---
## Bug 1: #98 — cellar-up freeze (UNFIXED)
The original bug. Sphere climbs the cellar ramp partway (world Z 90.95 → 92.80) then caps. Cottage floor at world Z=94 still 1.2m above.
**Refined diagnosis from this session's `[step-walk-adjust]` probe:**
AdjustOffset's slope projection is CORRECT — 145/146 calls take `into-plane` branch with mean +0.045 m zGain per call. The cap happens because step-up's downward step-down probe at the ramp top finds no walkable surface below (cottage floor is ABOVE). 101 `stepdown-reject` vs 1 acceptance.
**Six fix shapes attempted across the saga, all failed:**
1. Placement-insert bypasses (slice 6, 6 variants)
2. Cell-resolver tiebreaker changes (slice 3)
3. Negative-side polygon handling (slice 7, reverted)
4. Building-check / IsLandblockBuilding flag (slice 7, reverted)
5. Multi-cell BSP iteration (A4, shipped but doesn't address top-of-ramp)
6. **Shape 1: gate ContactPlane assignment by Normal.Z ≥ 0.99** (this session — broke OnWalkable, reverted)
---
## Bug 2: Airborne-at-tick-1 (NEW, surfaced this session)
When the trajectory replay harness drives ResolveWithTransition with a sphere seeded grounded on the cellar floor, **tick 1 reports `hit=yes n=(0,1,0) walkable=False/True` and the body goes airborne**. The sphere then floats horizontally over the cellar floor for the rest of the simulation, never touching the ramp.
This is **structurally different** from #98:
- #98 fails MID-CLIMB at the top of the ramp
- This bug fails AT START — sphere can't even walk a flat floor
This bug blocks the harness from reproducing #98 in test isolation. It must be solved before the harness can drive #98 fix attempts.
### Confirmed via investigation (committed in 5c6bdbe)
| Hypothesis | Outcome |
|---|---|
| WalkablePolygon NOT seeded in body | PARTIAL FIX — `walkable=True` survives but (0,1,0) hit still appears |
| Initial sphere Z lift 0.0 vs 0.05m | NO — same hit either way |
| Synthetic stair GfxObj triggering wall hit | NO — same hit without stair |
| Stub landblock terrain at Z=0 triggering hit | NO — same hit without landblock |
| Cell BSP=null falling through to terrain | NO — same hit with synthetic BSP attached |
| `body=null` vs body-with-CP-seed | NO — same hit either way |
### What we know about the (0,1,0) hit
- It's a +Y world normal — doesn't match any registered geometry (the stair has normal (0, 0.719, 0.695), the cellar floor has normal (0,0,1), the cellar walls have normal in the X/Y/Z axis directions but at known positions far from the sphere).
- It appears at the `after-validate` step-walk probe site — set BY ValidateTransition between `after-insert` and `after-validate`.
- `ValidateTransition`'s default-fallback line sets UnitZ=(0,0,1), not UnitY=(0,1,0). So something INSIDE TransitionalInsert set `ci.CollisionNormal=(0,1,0)` before ValidateTransition ran.
- 12 different `SetCollisionNormal` call sites in TransitionTypes.cs — root cause not isolated to one.
---
## DO NOT DO (next session)
The 5-attempt-failure pattern from #98 saga + this session's 6-hypothesis-failure on the airborne bug = **a long list of dead ends**. Don't retry any of these:
For #98 itself:
- Placement-insert bypasses in `BSPQuery.FindCollisions` / `Transition.FindEnvCollisions` / `Transition.DoStepDown`
- Cell-resolver tiebreaker changes in `PhysicsEngine.ResolveCellId` (slice 3 already shipped a fix)
- Negative-side polygon handling
- bldg-check / IsLandblockBuilding flag propagation
- Gating ContactPlane assignment by Normal.Z in `BSPQuery.AdjustSphereToPlane` (Shape 1 — breaks OnWalkable for sloped walkables)
- Any suppression flag, grace period, retry loop, or `if (problematicState) return early` workaround
For the airborne bug:
- Re-attempting any of the 6 hypotheses listed above
- Speculation about init fields without comparing to a live capture
- Adding more probes randomly — we already have 4+ probes wired
---
## What apparatus exists to use
| Tool | Location | Purpose |
|---|---|---|
| `[step-walk]` probe | TransitionTypes.cs (many call sites) | Per-step-site full state dump |
| `[step-walk-adjust]` probe | TransitionTypes.cs:AdjustOffset | Per-AdjustOffset call branch + zGain |
| `[resolve]` probe | PhysicsEngine.cs end of ResolveWithTransition | Per-call input/output/hit/cp summary |
| `[indoor-bsp]` probe | TransitionTypes.cs:1917-1926 | Per-indoor-BSP-call summary (only when BSP non-null) |
| `[poly-dump]` probe | BSPQuery.cs:402 | Per-AdjustSphereToPlane polygon hit dump |
| `[push-back]` probe | BSPQuery.cs:354-394 | Per-push-back motion details |
| `[place-fail]` probe | TransitionTypes.cs:2908 | Per-DoStepDown placement_insert rejection |
| `Issue98CellarUpReplayTests` | tests/.../Physics/ | 7 tests, single-frame failing-frame geometry |
| `CellarUpTrajectoryReplayTests` | tests/.../Physics/ | 5 tests, N-tick trajectory harness |
| Cell fixtures | tests/.../Fixtures/issue98/*.json | 3 hydratable cells (cellar + 2 cottage neighbors) |
| Retail cdb captures | docs/research/2026-05-23-a6-captures/ | Multiple capture sessions, decoded |
| cdb scripts | tools/cdb/*.cdb + tools/cdb/*.ps1 | Re-runnable retail-side capture infrastructure |
---
## Recommended next-session move
**Build a side-by-side comparison harness against live PlayerMovementController state.**
Concretely:
1. In the live client, attach a probe to `PlayerMovementController.cs:1105-1129` (the production ResolveWithTransition call site) that captures the FULL state passed in (every PhysicsBody field, sphere radius/height, step heights, mover flags, entity id) and the FULL state returned (ResolveResult fields, body state after the call).
2. Walk in a Holtburg cottage cellar. Capture 2-3 ticks of full state.
3. Save the capture as a JSON fixture in `docs/research/`.
4. Add a test to `CellarUpTrajectoryReplayTests.cs` that loads that fixture and feeds the EXACT captured state into ResolveWithTransition. Compare per-field divergence between the captured `ResolveResult` and the harness's result.
5. The divergence WILL exist (otherwise we wouldn't have the airborne bug). The first divergence pinpoints the missing state init step.
This approach is **evidence-driven, not speculation-driven**. The whole reason the 6-hypothesis investigation failed is we kept guessing what the harness was missing. A live capture tells us directly.
**Estimated effort:** 1 hour to wire the production-side probe + capture + JSON dump; 30 min to write the comparison test; 30 min to analyze the first divergence. Total ~2 hours, then the airborne bug should be solvable.
---
## Alternative next-session moves
If the comparison harness investment feels too big, here are smaller alternatives:
1. **Pivot to a different M1.5 issue.** The cellar-up demo isn't the only M1.5 critical path. Other issues in `docs/ISSUES.md` that need work: chronic open issues (#2, #4, #28, #29, #37, #41), the #90 workaround removal (now redundant after slice 3), or one of the Phase C visual fidelity items. Less coupling, faster forward progress.
2. **Pivot to M2 prep.** M1.5 is blocking M2 by policy ("one active milestone at a time"). But if the user authorizes, M2 has nicer scope — inventory panel (F.2), combat math (F.3), dev panels (F.5a). Visible wins, no physics rabbit holes.
3. **Use the harness elsewhere.** The `RegisterStairRampGfxObj` + `AttachSyntheticBsp` patterns are reusable for ANY indoor-static-collision test. If there's a different bug (corpse pickup boundary, door swing collision, etc.) that needs deterministic testing, the harness's apparatus is ready.
---
## Pickup prompt for next session
```
A6.P3 #98 trajectory harness — session paused 2026-05-23.
Read FIRST:
docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md (this file)
tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
(especially the class-doc comment + the 5 [Fact] tests)
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 — trajectory replay harness, blocked on a SECOND
bug (airborne-at-tick-1) that surfaced during commissioning. The
original #98 cellar-up freeze remains unfixed; the harness needs
the airborne bug solved before it can drive #98 fix attempts.
The handoff doc has three options for what to do next:
(A) Build the side-by-side comparison harness — capture live
PlayerMovementController state, replay in test, diff. ~2 hours.
Most retail-faithful path. Recommended.
(B) Pivot to a different M1.5 issue (chronic open issues, #90 removal,
Phase C work). Less coupling, faster wins.
(C) Pivot to M2 prep (requires user authorization — M2 is policy-deferred
until M1.5 lands).
Pick A, B, or C. If A: there's a step-by-step plan in the handoff
doc's "Recommended next-session move" section.
CLAUDE.md rules apply throughout. NO speculative fixes — the saga has
six failed shapes already. Evidence first.
Test baseline: 1172 + 8 (pre-existing failures). Maintain throughout.
```

View file

@ -1,334 +0,0 @@
# A6.P3 issue #98 — acdream replay vs retail cdb comparison
**Date:** 2026-05-23
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
**Status:** Apparatus complete. Divergence identified. Fix plan to follow.
This document closes the loop on Step 5 of
[`C:\Users\erikn\.claude\plans\i-did-some-work-sharded-acorn.md`](../../C:/Users/erikn/.claude/plans/i-did-some-work-sharded-acorn.md).
It compares acdream's deterministic-replay output against the retail
cdb capture taken at the equivalent scenario, and names the
divergence target for the (next) fix plan.
The four prior sessions (2026-05-22 AM + PM, 2026-05-23 AM + PM)
shipped 10+ speculative fixes without data. This session shipped the
apparatus that turns the next attempt into evidence-driven work
(commits `35b37df``6f666c1` on top of slice 5's `cf3deff`).
---
## TL;DR — the divergence target
**Retail's `BSPLEAF::find_walkable` accepts the cottage main floor
polygon when the sphere is RESTING ON TOP of it.** Sphere local
Z = +radius (= +0.48 in the cottage cell). Sphere world Z ≈ 94.48
(cottage floor at world Z=94, plus radius).
**acdream's failing-frame sphere is 0.69m BELOW the cottage main floor
plane** when our walkable query runs. Sphere local Z = -0.6883 in
0xA9B40143. Sphere world Z ≈ 93.31.
Delta: **retail's sphere is 1.17 m higher** at the equivalent decision
point. Either:
1. Our step-up sequence doesn't lift the sphere high enough before
`find_walkable` is called against the cottage cell, OR
2. We're calling `find_walkable` against the cottage cell using the
wrong sphere reference (foot-sphere center instead of the step-
lifted center), OR
3. The cellar→cottage transition in retail happens GRADUALLY across
many physics ticks (the sphere climbs the ramp one step at a time),
and acdream's per-tick climb is too small.
The fix plan needs to choose between (1), (2), and (3) — most likely
(3) given retail's BPE-write distribution.
A surprising secondary finding: **`CPolygon::find_crossed_edge` fires
ONLY ONCE in 35K probe hits in retail.** Our replay harness uses
`FindCrossedEdge` as the primary edge-containment test. Either retail
takes a different path through the walkable predicate cascade, or
acdream is over-reliant on the edge test for a case retail doesn't
hit.
---
## Apparatus shipped this session
Six commits on top of `cf3deff` (slice 5):
| Commit | What |
|---------|------|
| `35b37df` | chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments. Kept: render-vs-physics origin split (GameWindow), terrain-hole cutout, multi-sphere CellTransit, step-walk diagnostic probes. Reverted: neg-poly path split, bldg-check flag, isBuilding propagation, IsLandblockBuilding. Test baseline restored to 1148+8 base. |
| `f62a873` | feat(phys): Step 2 — cell-dump probe (`ACDREAM_DUMP_CELLS=0xA9B4xxxx,...`) + JSON DTOs (`CellDump`, `PolygonDump`, etc.) + `CellDumpSerializer` (Capture / Read / Write / Hydrate) + 4 round-trip tests. |
| `3f56915` | capture(phys): Three cell fixtures from live capture — 0xA9B40143 (14 polys), 0xA9B40146 (4 polys), 0xA9B40147 (37 polys). All share worldOrigin=(130.5, 11.5, 94.0) with 180° yaw. |
| `856aa78` | test(phys): Step 3 — `Issue98CellarUpReplayTests` — 7 tests reproducing the live failure pattern deterministically (<1ms per test). Confirms 0xA9B40143 poly 0x0004 rejected at the failing-frame sphere; 0xA9B40146 has no walkable candidate at all. |
| `6f666c1` | tools(cdb): Step 4 — `issue98-cellar-up-find-walkable.cdb` + `issue98-runner.ps1` for retail-side capture. BPA/B/C/D/E/F break on find_walkable, walkable_hits_sphere, find_crossed_edge, check_other_cells, set_contact_plane, adjust_sphere_to_plane. |
| (this doc) | Step 5 — divergence comparison. |
---
## Raw data — retail cdb capture
Capture: [`docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.log`](2026-05-23-a6-captures/cellar_up_capture_1/retail.log)
(decoded: `retail.decoded.log`)
User ran retail acclient.exe v11.4186 attached via
`tools/cdb/issue98-runner.ps1 -ScenarioTag "cellar_up_capture_1"`. They
walked up and down a Holtburg cottage cellar stair several times. cdb
captured 35,219 BP hits over ~5 seconds of motion.
Hit distribution:
| BP | Function | Hits | Notes |
|-----|----------------------------------------------|--------|-------|
| BPA | `BSPLEAF::find_walkable` | 6,160 | per-leaf walkable query |
| BPB | `CPolygon::walkable_hits_sphere` | 7,028 | per-polygon overlap test |
| BPC | `CPolygon::find_crossed_edge` | **1** | almost never fires! |
| BPD | `CTransition::check_other_cells` | 21,422 | outer dispatcher fires very frequently |
| BPE | `COLLISIONINFO::set_contact_plane` | **161**| ContactPlane writes |
| BPF | `CPolygon::adjust_sphere_to_plane` | 431 | sphere projections |
### BPE — retail's accepted ContactPlanes
Every one of the 161 BPE writes lands on one of TWO planes:
```
n=(0, 0, 1) d=-93.9998 → world Z=94 (cottage main floor)
n=(0, 0, 1) d=-90.9500 → world Z=90.95 (cellar floor)
```
Retail's ContactPlane is **never** set to:
- the cellar ramp (normal ≈ (0, -0.719, 0.695))
- any of the cellar wall polygons
- the cellar ceiling (poly 0x0020 in our nomenclature — normal=(0,0,-1) at world Z=93.82)
The transition cellar floor → cottage main floor happens directly:
ContactPlane shifts from `d=-90.95` to `d=-93.9998` with no
intermediate plane.
### BPA — sphere position at each cottage-floor acceptance
The find_walkable call immediately before each BPE write to the
cottage floor shows a consistent sphere position pattern:
| BPE hit | Last BPA before | Sphere LOCAL | Notes |
|---------|------------------------|-------------------------------|-------|
| #1 | hit#435 (cell B) | (-0.3270, 0.5998, +0.6300) | first cottage-floor accept |
| #50 | hit#2533 (cell B) | (-0.3131, 0.7340, +0.6300) | cz unchanged |
| #100 | hit#3822 (cell B) | (-0.3245, 0.3292, +0.6300) | cz unchanged |
| #160 | hit#6159 (cell B) | (-0.3195, 0.5271, +0.6300) | cz unchanged |
Sphere local Z is consistently **+0.6300** in cell B at the moment
retail accepts. Cell B's cottage floor plane is at local Z=-0.15
(observed from BPB hit#7012 with plane d=-0.15), so the sphere is
0.78m above that floor. Sphere radius 0.48 → sphere bottom is 0.30m
above the floor — close enough that `walkable_hits_sphere` accepts.
The find_walkable hit just BEFORE the cell-B query (hit#433, hit#2532,
hit#3820, hit#6158) lands in a different cell ("cell A") at local
position ≈ `(-11.12, 7.16, +0.48)`. Cell A's cottage floor plane is at
local Z=0 → sphere is 0.48m above (= sphere radius), perfectly resting
on the floor.
**Both cells consistently see the sphere at `local Z = +0.48 to +0.63`
at the acceptance moment.** Sphere world Z ≈ 94.48 — the sphere has
been lifted ABOVE the cottage floor.
---
## acdream replay — sphere position at the equivalent moment
Replay anchor: failing-frame sphere world position
`(141.7164, 8.3937, 92.0093)` r=0.4800, from
[`a6-issue98-negpoly-20260523-135032.out.log`](../../a6-issue98-negpoly-20260523-135032.out.log)
line 11338 (`[walkable-nearest]`) + 11339 (`[issue98-walkable-detail]`).
In cell 0xA9B40143 (cottage neighbour, 14 physics polys):
```
sphere LOCAL = (-11.2892, 4.3653, -0.6883)
nearest walkable: poly 0x0004
plane n=(0,0,1) d=0 (local) → world Z=94 (cottage floor)
verts: [(-6.2, 7.6, 0), (-10.0, 7.6, 0), (-10.0, 2.8, 0)]
signed distance from plane: -0.6883
abs distance: 0.6883
gap (abs - radius): 0.2083
insideEdges: FALSE (sphere XY beyond triangle edge by 1.29 m on X)
overlapsSphere: FALSE (|0.6883| > radius 0.48)
```
In cell 0xA9B40146 (cottage neighbour, 4 physics polys):
```
sphere LOCAL = (similar)
nearest walkable: NONE
(the cell has no Z-up polygon close enough to be selected)
```
In cell 0xA9B40147 (cellar primary, 37 physics polys):
```
sphere LOCAL = (-11.2164, 3.1063, -1.9907)
nearest walkable: the cellar ramp (poly 0x0008 — n=(0,-0.719, 0.695))
→ accepted as ContactPlane
```
Our replay confirms the live failure: cottage-cell walkable queries
return no usable result; cellar ramp is the only ContactPlane we ever
get.
---
## Side-by-side comparison
| Field | Retail (BPE #1) | acdream (negpoly fail) |
|-----------------------------------------|---------------------|-------------------------|
| Sphere world Z | **94.48** | **92.01** |
| Cottage floor plane (world) | Z = 94 | Z = 94 |
| Sphere position vs cottage floor | **+0.48 m ABOVE** | **-1.99 m BELOW** |
| Sphere top vs cottage floor | +0.96 m above | -1.51 m below |
| Walkable accepted in cottage cell? | **YES** — sphere rests on plane | **NO** — sphere far below plane |
| ContactPlane set to cottage floor? | **YES** (161 times) | **NO** (never) |
| find_crossed_edge invocations | 1 (in 35K BPs) | (used heavily by our walkable test) |
| check_other_cells invocations | 21,422 | (per-tick, similar order) |
**Sphere world Z delta: 2.47 m.** Retail's sphere is nearly 2.5 m
higher than ours at the equivalent decision point.
---
## Plausible fix targets, in priority order
These are HYPOTHESES — the fix plan must verify each before changing
code. Each is testable against the replay harness without launching
the client.
### Target 1 (highest confidence): step-up + ramp climb doesn't gain enough Z per tick
Retail's data shows the sphere climbs the ramp GRADUALLY across many
ticks — BPB hits move smoothly from sphere local Z=-2.57 (resting on
cellar floor) through intermediate values up to sphere local Z=+0.48
(resting on cottage floor) over ~7,000 walkable_hits_sphere calls.
Our `[step-walk]` diagnostic from the failing log shows the sphere
oscillating at world Z ≈ 92.0 — never gaining altitude. The ramp's
ContactPlane is being set but `AdjustOffset` is consuming all
WalkInterp on the lift, leaving nothing for forward motion (slice 7
handoff's reading was right on this).
Look at:
- `Transition.AdjustOffset` — when ContactPlane is the ramp, forward
motion should project to ramp-local, gaining Z. Does it?
- `Transition.DoStepUp` — when does step-up fire? Is it lifting by
the right amount? Compare to retail's step_sphere_up.
- The interaction between WalkInterp depletion and step-up — does our
step-up reset WalkInterp like retail does?
### Target 2: cottage-cell candidacy uses wrong sphere reference
Retail iterates cells with the SAME sphere across find_walkable calls
in a tick. The sphere position visible to find_walkable for the
cottage cell is already at the lifted position. acdream's
`CellTransit.FindCellSet` uses `sp.GlobalSphere` — but at what tick
phase? If we use the pre-step-up sphere center to decide cottage-cell
candidacy, but then run the walkable query at the same pre-step-up
position, we'll never see the cottage cell as walkable.
Look at:
- `CheckOtherCells` in `TransitionTypes.cs` — what sphere does it
pass to `BSPQuery.FindCollisions`? Does it use the step-lifted
position or the pre-step position?
- The retail oracle `CTransition::check_other_cells` at
`acclient_2013_pseudo_c.txt:272717-272798`.
### Target 3: find_crossed_edge is over-used in our walkable acceptance
Retail's BPC hit count of 1 in 35K is a striking outlier. Either
retail's walkable acceptance never needs the edge containment test
(because `walkable_hits_sphere` does enough), or `find_crossed_edge` is
gated behind a different code path we're not hitting.
Look at:
- `BSPQuery.FindCrossedEdge` — when is it called? Compare to retail's
`CPolygon::find_crossed_edge`. Maybe we call it in step-up, retail
doesn't.
This is a SECONDARY target — not directly the issue #98 failure mode,
but a code-shape divergence worth investigating once the primary fix
lands.
### Target 4 (low confidence): the cellar ramp normal-Z is wrong
If our cellar ramp polygon has a slightly wrong normal compared to
retail, AdjustOffset's slope projection would compute different Z
gains. The polydump capture shows ramp normal (0, -0.7190, 0.6950);
the JSON fixture has the same. Likely not the bug, but worth
verifying via `dotnet test` after any fix attempt.
---
## What the apparatus delivers for future fix attempts
1. **`Issue98CellarUpReplayTests`** runs in <200ms with no client
launch. Any change to `BSPQuery.FindCrossedEdge`, polygon
containment, or cell transform shows up instantly.
2. **JSON fixtures in `tests/AcDream.Core.Tests/Fixtures/issue98/`**
are real-geometry captures. Any future fix can call
`CellDumpSerializer.Hydrate` to load them and drive the predicates
directly.
3. **`tools/cdb/issue98-runner.ps1`** is reusable. Any new
hypothesis can be re-captured against retail with a 5-minute user
action.
4. **`tools/cdb/decode_retail_hex.py`** decodes the hex-bits format —
no changes needed.
5. The retail comparison data is checked into
`docs/research/2026-05-23-a6-captures/cellar_up_capture_1/`
future analyses can re-grep without re-capturing.
---
## What this plan does NOT do
This document does not ship a fix. The fix is the next plan, scoped to
Target 1 (most likely) or Target 2 (next likely). The user should
review this divergence reading before authorizing implementation.
Per CLAUDE.md and the systematic-debugging mandate: 4 prior sessions
guessed and were wrong. This plan refuses to be the 5th.
---
## Pickup prompt for the fix plan
Open this worktree:
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
Then:
```
A6.P3 issue #98 — apparatus complete; ready to write the fix plan.
Read FIRST:
docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md
tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs
docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.decoded.log
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 — fix #98 cellar-up (fix plan)
Next concrete step: pick Target 1 (step-up Z gain) or Target 2
(cottage-cell sphere reference) from the comparison doc and write
the fix plan against it. NO speculative fixes — use the replay
harness to verify the hypothesis before writing code.
The fix MUST be evidence-driven. The replay harness gives us a 200ms
test loop; a fix that doesn't change the failing assertions in
Issue98CellarUpReplayTests is not the fix.
Test baseline: 1167 + 8 (with apparatus). Maintain through any fix.
CLAUDE.md rules apply. No workarounds without explicit approval.
```

View file

@ -1,185 +0,0 @@
# A6.P3 #98 — [step-walk-adjust] capture analysis (2026-05-23)
**Capture:** [docs/research/2026-05-23-a6-captures/stepwalkadjust/acdream.log](2026-05-23-a6-captures/stepwalkadjust/acdream.log) (1.3 MB, 6,467 lines)
**Plan ref:** [docs/superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md](../superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md)
**Probe commit:** `8a232a3` — added `[step-walk-adjust]` site inside `Transition.AdjustOffset` (branch token + zGain per call).
---
## TL;DR
The fix plan's four-branch decision tree (A / B / C / D) **does not match what the data shows**. The diagnostic conclusively proves:
1. **AdjustOffset is correct.** `branch=into-plane` for 145 of 146 calls; `zGain = +0.052 ± 0.001` per call when sphere offset points into the ramp normal `(0, 0.719, 0.695)`. Cumulative theoretical zGain across the climb portion: roughly **+5 m**, far more than the ~2 m the sphere actually climbed.
2. **Z gain accumulates correctly while mid-ramp.** Sphere world Z went 90.95 → 92.80 monotonically across the climb portion.
3. **The climb caps at world Z ≈ 92.80** with the sphere frozen at `cur=(141.5054, 7.1684, 92.7968)`. X drifts by ~0.006/tick from sliding; **Y and Z are nailed**. The cottage floor at world Z=94 is still 1.20 m above.
4. **At the freeze, the per-step rollback mechanism takes the +Z out.** The sequence:
- `find-start` — winterp=1.0, walkPoly=True, CP=ramp ✓
- `[step-walk-adjust]` — input=(0.006,-0.105,0), output=(0.006,-0.051,+0.052), branch=into-plane ✓
- `after-adjust` — adj=(0.006,-0.051,+0.052), CP=ramp ✓
- **CP cleared by the per-step reset at [TransitionTypes.cs:723-725](../../src/AcDream.Core/Physics/TransitionTypes.cs:723-725).**
- `before-insert` — check advanced to (141.5117, 7.1179, **92.8491**), CP=n/a
- Inside `TransitionalInsert(3)`: step-up branch fires (`stepUp=True`), step-down probes by 0.6m downward.
- Step-down finds no walkable below the proposed position (cottage floor is ABOVE, not below).
- **Two `stepdown-reject` fires** inside the insert.
- `after-insert` — check rolled back to `(141.5117, **7.1684, 92.7968**)`. Only X advanced by 0.006. **walkPoly=False, winterp=-0.0000.**
- `find-end` — same state, walkPoly=False.
5. **This is a NEW fix target — call it "Target E."** The plan's decision tree didn't anticipate this mode. AdjustOffset's slope projection works perfectly. The failure is in the step-up validation logic at the **top of the ramp**, where the next walkable surface (cottage floor) is ABOVE the proposed position, not below. The step-down probe inside step-up scans downward and finds nothing → rejects → rollback.
---
## Branch histogram (across the entire capture)
| Branch | Count | % |
|---|---:|---:|
| `into-plane` | 145 | 99.3% |
| `no-cp` | 1 | 0.7% |
| All others (away-plane, slide-crease, slide-degenerate, no-cp-slide, *+safety-push*) | 0 | 0% |
No safety-push annotations. No slide planes ever installed. No CP-cleared mid-climb (except by the deliberate per-step reset).
## zGain summary
- 146 calls total.
- Total zGain: **+6.63 m**.
- Mean per call: **+0.045 m**.
- Cellar-floor calls (CP normal `(0,0,1)`, d=-90.95): zGain=0 (expected — flat floor doesn't tilt motion).
- Ramp calls (CP normal `(0, 0.719, 0.695)`, d=-69.50): zGain ≈ +0.052 to +0.055 per call (very tight distribution).
- Math verified: collisionAngle = dot(input, normal) ≈ -0.076 → result -= normal × collisionAngle → +Z component matches log exactly.
## cur Z trajectory (from `[step-walk] site=after-adjust`)
| Phase | World Z | Notes |
|---|---|---|
| start | 90.9500 | Walking flat across cellar floor (cell 0xA9B40147 floor) |
| climb begins | 90.9500 → 91.013 → 91.068 → ... | Sphere reaches ramp foot |
| climb proceeds | rises by ~0.05/tick | Y decreasing as Z increasing — climbing -Y direction |
| **cap** | **92.7968** | Sphere locks here; X drifts only |
| end-of-capture | 92.7968 | Sphere never escapes |
Max Z reached: 92.7968. Cottage floor: 94.00. **Gap: 1.20 m.** Sphere top (center+radius): 93.28 — still 0.72 m below cottage floor.
## stepdown probe-site counts (across whole capture)
| Site | Count |
|---|---:|
| `stepdown-enter` | 236 |
| `stepdown-after-insert` | 236 |
| `stepdown-after-offset` | 134 |
| `stepdown-reject` | **101** |
| `stepdown-after-placement` | 1 |
101 rejections vs 1 acceptance + 134 offset-only outcomes. **Step-down is failing far more often than succeeding.** This is the failure-frequency signature.
---
## At the freeze: which validation rejects?
Reading [TransitionTypes.cs:2848-2850](../../src/AcDream.Core/Physics/TransitionTypes.cs:2848-2850):
```csharp
if (transitState == TransitionState.OK
&& CollisionInfo.ContactPlaneValid
&& CollisionInfo.ContactPlane.Normal.Z >= walkableZ)
```
The accept condition needs ALL three. At the freeze moment:
- `transitState == OK` — TRUE (per log).
- `CollisionInfo.ContactPlaneValid`**FALSE** (per log: `cp=n/a` at stepdown-after-insert, stepdown-reject).
- `ContactPlane.Normal.Z >= walkableZ` — moot since CP is invalid.
So **`ContactPlaneValid` is the false condition**.
Why is `ContactPlaneValid` false after `TransitionalInsert(5)` (called by DoStepDown at line 2825)?
The CP was set to `(0, 0.719, 0.695)` at `find-start`. Then per-step reset at line 724 cleared it before `TransitionalInsert(3)` ran. Inside that insert, step-up logic fired. Step-up internally calls `DoStepDown(stepDownHeight=0.6, walkableZ=0.6642, runPlacement=true)`. **That nested DoStepDown runs `TransitionalInsert(5)` again**, and inside THAT, the sphere checks for walkable polys. None found below the proposed step-up position → CP stays unset → accept condition fails → `stepdown-reject`.
The retail behavior (from the cdb capture, [retail.decoded.log](2026-05-23-a6-captures/cellar_up_capture_1/retail.decoded.log)):
- **Retail's BPE writes ContactPlane to (0,0,1) d=-93.9998 (cottage floor at world Z=94) DIRECTLY from (0,0,1) d=-90.9500 (cellar floor) with no intermediate.**
- Retail's BPE writes never set CP to the cellar ramp normal.
- Retail's sphere DOES climb across the ramp, but the CP stays on the flat-floor planes the whole time.
So retail's mechanism: the sphere climbs the ramp by step-up SUCCEEDING and landing on cottage floor as the next walkable surface. The ramp itself isn't used as a ContactPlane in retail.
In acdream: the ramp is treated as a walkable surface. When the sphere reaches the top of the ramp, the next required walkable surface (cottage floor) is too far above the proposed position to be acceptable to the step-down probe.
---
## Conclusion: Fix target is "Target E" (new)
The previous decision tree (A / B / C / D) was based on the divergence comparison doc's framing of "no altitude gain." The data shows the climb DOES gain altitude (correctly). The bug is at the **top of the ramp**, in the **step-up + step-down validation**, NOT in `AdjustOffset`.
### Target E definition
**Name:** Step-up validation rejects ramp-climb advances when the next walkable surface (cottage floor) is too high above the proposed step-up position to be acceptable to the downward step-down probe.
**Failure mechanic:** At the top of the cellar ramp:
1. Sphere proposes to advance up the ramp by ~0.10 m horizontal + 0.05 m vertical.
2. The advance puts the sphere bottom AT world Z ≈ 92.37 (still 1.63 m below cottage floor at world Z=94).
3. Step-up logic fires (because there's a +Z component in the offset).
4. Step-up calls DoStepDown with stepDownHeight=0.6 m to find a walkable surface within reach.
5. Step-down probes the sphere downward by 0.6 m to world Z ≈ 91.77, but no walkable polygon exists at that altitude in any of the overlapping cells (0x0147, 0x0143, 0x0146).
6. step-down rejects → step-up rejects → rollback restores sphere Y and Z, advances X by sliding amount.
7. Sphere is now in IDENTICAL state next tick → infinite loop.
### Two candidate fix shapes (TO RESEARCH — DO NOT CODE YET)
**Shape 1 — keep ramp as ContactPlane during the climb.** Match retail's behavior of NOT clearing ContactPlane between AdjustOffset calls when the player is mid-ramp. Retail's BPE shows CP is "sticky" on the cellar floor, then suddenly transitions to cottage floor. Our per-step reset at TransitionTypes.cs:721-725 clears CP every step; this is the documented "ACE order" but may not match retail.
**Shape 2 — fix step-up to look UPWARD for cottage floor.** When step-up fails to find a walkable directly below the proposed position, probe UPWARD by `stepUpHeight` looking for a walkable that the sphere can land on after a vertical lift. This is the natural "climb up a ledge" behavior. The current step-up only probes downward (via DoStepDown).
**Shape 3 — preserve walkPoly across rollback.** When step-up rejects, the rollback should preserve `walkPoly=True` if the PREVIOUS frame had it (the sphere was on a valid walkable). Currently `walkPoly=False` after rollback, which then poisons the next tick's `OnWalkable` check.
These three shapes are NOT mutually exclusive. The fix may need shape 1 + 3, or shape 2 alone, or some combination.
---
## What this rules out
| Hypothesis | Status |
|---|---|
| AdjustOffset projection broken (decision-tree Branch A / B / C / D) | **RULED OUT** — projection works correctly, +zGain per call is consistent and matches the math. |
| WalkInterp depletion gating forward motion | **RULED OUT** — winterp=1.0 at find-start of every freeze tick. Only DEPLETED winterp=-0.0000 appears AFTER stepdown-reject, which is a consequence not a cause. |
| Cell-resolver ping-pong between cellar and cottage | **RULED OUT** — every tick has cell=0xA9B40147→0xA9B40147 (no transition); slice-3 stickiness fix held. |
| Step-down rejected because no walkable found above sphere | **NOT TESTABLE BY THIS PROBE** — this probe is inside AdjustOffset, not inside DoStepDown's accept-condition check. A follow-up probe inside the accept-condition check would prove which of the three accept clauses fails. We CAN see it indirectly: `cp=n/a` at stepdown-after-insert tells us ContactPlaneValid is false at the moment of the check. |
---
## Pickup prompt for the fix plan
```
A6.P3 issue #98 — [step-walk-adjust] capture analysis complete.
Read FIRST:
docs/research/2026-05-23-a6-stepwalkadjust-findings.md
docs/research/2026-05-23-a6-captures/stepwalkadjust/acdream.log
(search for "stepdown-reject" and the freeze tick at line ~3891)
Conclusion: Fix target is "Target E" (new) — step-up validation
rejects ramp-climb advances at the top of the cellar ramp because
the cottage floor is too far ABOVE the proposed step-up position to
be found by the downward step-down probe.
Three candidate fix shapes:
1. Keep ramp ContactPlane sticky across per-step resets (match retail).
2. Make step-up probe UPWARD for the next walkable (climb-up behavior).
3. Preserve walkPoly across rollback to avoid OnWalkable being poisoned.
Next: research which shape matches retail's named decomp at
acclient_2013_pseudo_c.txt (search step_sphere_up, step_sphere_down,
find_walkable). Retail's BPE writes ONLY ever set CP to flat floors
(cellar Z=90.95 then cottage Z=94) — never to the ramp.
The replay harness (Issue98CellarUpReplayTests, <200ms) is the inner
test loop. The cdb capture in cellar_up_capture_1/ is the ground-truth
oracle. The fix MUST flip the failing-frame assertions in the replay
tests — that's the contract.
Test baseline: 1167 + 8. CLAUDE.md rules apply. No workarounds.
```

View file

@ -1,330 +0,0 @@
# A6.P4 — Retail-faithful per-cell shadow_object_list port — pickup handoff
**Date:** 2026-05-24 (end of A6.P3 session, start of A6.P4 plan)
**Status:** Ready to start. Design committed (b55ae83). Pre-flight pending in slice 1's first moves.
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
**Branch:** `claude/strange-albattani-3fc83c`
**Milestone:** M1.5 — "Indoor world feels right" (active)
**Predecessor:** A6.P3 (issue #98 cellar-up) — closed 2026-05-24 by `b3ce505` as a behavioral stopgap. A6.P4 ships the full architectural port and removes the stopgap.
---
## TL;DR for the next session
1. **State both altitudes** in your first message: M1.5 active; current phase A6.P4; first concrete step is the slice-1 pre-flight reads (Q1 + Q2 below).
2. **Read these three documents first** (in this order, ~15 min):
- `docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md` — the design (slices, anchors, risks)
- `docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md` — the Resolution section at the bottom (architectural divergence + b3ce505 stopgap + door regression)
- `docs/ISSUES.md`#98 (DONE, contextual), #99 (OPEN — what slice 1 closes), #100 (OPEN — separate phase after A6.P4)
3. **Resolve the two pre-flight questions** (~20 min total) before touching code.
4. **Slice 1 implements** in ~30 min. Test + visual + commit.
5. **Slices 2-3** follow in subsequent sessions (one per session ideally).
6. **Then #100** (transparent ground around houses) — separate phase.
---
## What's already done (DO NOT REDO)
### Commits on this branch (recent, A6.P3 + handoff)
- `b3ce505` — fix(phys): A6.P3 #98 — gate outdoor shadow radial sweep on indoor primary cell. **Stopgap; slice 3 of A6.P4 removes it.**
- `b55ae83` — docs: A6.P3 #98 resolution + A6.P4 design + #99/#100 filed. **Includes the design doc you'll execute against.**
### Memory entries (out-of-tree at `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\`)
- `feedback_retail_per_cell_shadow_list.md` — the architectural lesson + decomp anchors
- `feedback_apparatus_for_physics_bugs.md` — the apparatus pattern (live capture + dump + harness)
- `MEMORY.md` index updated
### Apparatus in tree (REUSE; don't rebuild)
- `PhysicsResolveCapture` ([`src/AcDream.Core/Physics/PhysicsResolveCapture.cs`](../../src/AcDream.Core/Physics/PhysicsResolveCapture.cs)) — env var `ACDREAM_CAPTURE_RESOLVE=<path>` writes JSON Lines per `ResolveWithTransition` call
- `GfxObjDump` / `GfxObjDumpSerializer` ([`src/AcDream.Core/Physics/GfxObjDump.cs`](../../src/AcDream.Core/Physics/GfxObjDump.cs)) — env var `ACDREAM_DUMP_GFXOBJS=0xHHH,0xHHH,...`
- `CellDump` / `CellDumpSerializer` ([`src/AcDream.Core/Physics/CellDump.cs`](../../src/AcDream.Core/Physics/CellDump.cs)) — env var `ACDREAM_DUMP_CELLS=0xHHH,...`
- Harness: [`tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`](../../tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs) — `LiveCompare_*` test pattern
- Fixtures at `tests/AcDream.Core.Tests/Fixtures/issue98/` — 16 cell dumps + cottage GfxObj `0x01000A2B.gfxobj.json` + 3-record `live-capture.jsonl`
---
## Direction: A6.P4 full (slices 13), then #100
**Why this order** (user decision 2026-05-24): #99 (doors) is a regression from b3ce505 that needs prompt fix; slices 2-3 close it architecturally and likely fold in #97 (phantom collisions) + Finding 3 family (sling-out); doing the full port in one phase preserves apparatus + decomp context that would degrade if we paused for #100 in the middle. #100 is cosmetic (visual ground) and doesn't block any demo target.
**User's stated value driving the choice:** "I want retail parity on collision." Quoted in `feedback_no_patching_collision.md`. The b3ce505 stopgap is, by my own commit message, "the smallest behavioral patch matching retail's effect at the query level" — A6.P4 is the actual port.
---
## Slice 1 — query-side portal expansion (1-2 hours)
### Goal
Close issue #99 (run-through doors) by extending the query side of `GetNearbyObjects` to include portal-reachable outdoor cells when the primary cell is indoor. **Minimal change; sets up slice 2's registration-side refactor.**
### Pre-flight (~20 min — answer BEFORE writing code)
**Q1: Does `CellPhysics.VisibleCellIds` include the outdoor cell on the other side of a building doorway?**
- Read [`src/AcDream.Core/Physics/CellPhysics.cs`](../../src/AcDream.Core/Physics/CellPhysics.cs) — find what populates `VisibleCellIds`
- Read [`src/AcDream.Core/World/LandblockLoader.cs`](../../src/AcDream.Core/World/LandblockLoader.cs) — find where portal data hydrates into CellPhysics
- Cross-ref against a real loaded EnvCell — `tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40143.json` has the cottage main floor; does its CellBSP / portal data list any outdoor cell?
- **Decision branch:**
- If `VisibleCellIds` DOES include outdoor neighbors → slice 1 is straightforward; walk that list, filter by `< 0x0100u` (outdoor), include in indoor query
- If `VisibleCellIds` is indoor-only → walk the cell's `Portals` directly (each `PortalInfo` has an `OtherCellId`); collect those that resolve outdoor
**Q2: Are doors actually registered with outdoor cellScope today?**
- Find the door spawn path. Likely candidates:
- [`src/AcDream.App/Rendering/GameWindow.cs:3139`](../../src/AcDream.App/Rendering/GameWindow.cs:3139) — server-spawned entities register here (Cylinder collision)
- `EntitySpawnAdapter` or `WorldEntityFactory` — the construction path
- Check what `cellScope` is passed. Default: `cellScope = entity.ParentCellId ?? 0u`. For a door at a doorway, `ParentCellId` might be:
- **null** → cellScope=0u → landblock-wide registration → currently registered via outdoor 24m grid → the b3ce505 gate now skips it from indoor queries → walk-through
- **the indoor cell** → cellScope=that-cell-id → registered indoor-scoped → indoor query already finds it (no #99 bug from this door)
- **the outdoor cell** → cellScope=that-cell-id → indoor-scoped registration with an outdoor cellId (an A1.5 corner case) → behavior depends on how `GetNearbyObjects` handles outdoor cellScope (likely treats it as indoor branch and skips it via the `< 0x0100u` filter — needs verification)
- **If Q2 reveals doors aren't outdoor-registered**, the diagnosis is wrong. Stop coding, re-trace the regression via launch + `ACDREAM_CAPTURE_RESOLVE` + the door scenario.
**If Q1 + Q2 both confirm the design**, proceed to implementation. Otherwise adjust slice 1.
### Implementation (~30 min)
Files to touch:
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs``GetNearbyObjects` gains a new parameter `IReadOnlyCollection<uint>? portalReachableOutdoorCells = null`. When primary is indoor and this is non-null, iterate the outdoor cells listed (each is a regular cell key into `_cells`) and merge into results.
- `src/AcDream.Core/Physics/TransitionTypes.cs:2180+` — in `FindObjCollisions`, after computing `indoorCellIds` via `CellTransit.FindCellSet`, build a `portalReachableOutdoorCells` set by walking each indoor cell's `VisibleCellIds` (or `Portals` per Q1 answer) and filtering outdoor ids (`< 0x0100u` low byte). Pass to `GetNearbyObjects`.
Test:
- New `LiveCompare_DoorThroughDoorway_*` test. Two options:
- **(preferred)** Capture a live tick where a door blocks the player at a Holtburg doorway. `ACDREAM_CAPTURE_RESOLVE=<path>` set. Walk into the inn doorway with door closed. Find the tick where the engine detected the door (`obj=0x...` in the `[resolve]` probe). Add the record to a new fixture.
- **(fallback)** Synthetic harness test: register a fake door Cylinder shadow at a known doorway portal position with the right outdoor cellScope, verify `FindObjCollisions` from the indoor cell returns it. Same shape as the existing harness tests.
Tests must pass:
- 11/11 `CellarUpTrajectoryReplayTests` continue passing
- 19+ `ShadowObjectRegistryTests` continue passing
- New door test passes
Visual verification:
- Launch acdream (use the `Run-WithLogout` pattern from `CLAUDE.md` to avoid 3-minute stuck-session)
- Walk into a Holtburg cottage — door blocks from outside ✓
- Walk inside, walk back toward the doorway — door blocks from inside ✓ (this was the regression)
- Walk into the cellar — cellar climb still works ✓ (no #98 regression)
- Bump into a chair / fireplace inside — still blocks ✓ (no indoor-static regression)
- Bump into a building exterior wall from outside — still blocks ✓ (no outdoor-static regression)
Commit shape:
```
feat(phys): A6.P4 slice 1 — portal-reachable outdoor cells in indoor shadow query
Closes #99. The b3ce505 stopgap (gate outdoor sweep on indoor primary cell)
correctly closes #98 but blocks doors registered to outdoor cells from
being seen by spheres in the adjacent indoor cell. Mirrors retail's
behavior via query-side portal expansion: when primary cell is indoor,
walk indoor cells' VisibleCellIds (or Portals), include any portal-
reachable outdoor cells in the iteration set.
This is slice 1 of A6.P4. Slice 2 ports retail's full Register-side cell-
set computation; slice 3 removes the b3ce505 gate entirely.
Pre-flight Q1+Q2 verified before coding:
- Q1: VisibleCellIds is populated with [populate with answer]
- Q2: doors register with cellScope=[populate]
Verification:
- 11/11 CellarUpTrajectoryReplayTests pass
- new LiveCompare_DoorThroughDoorway test passes
- ShadowObjectRegistry tests pass
- visual: doors block both sides, cellar still climbable, indoor + outdoor
statics unaffected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```
---
## Slice 2 — registration-side BuildShadowCellSet (~half day with verification)
### Goal
Port retail's `CObjCell::find_cell_list` indoor/outdoor branch + portal-visible recursion into `ShadowObjectRegistry.Register`. After slice 2, objects are placed in retail-faithful per-cell shadow lists at registration time — the query side becomes pure per-cell list iteration.
### Plan
- New helper `ShadowObjectRegistry.BuildShadowCellSet(boundingSphere, m_positionCellId, landblockContext)` returns the set of cellIds the object should be registered in.
- If `m_positionCellId` is indoor (≥ 0x0100): include that cell, recurse via the cell's portal-visible neighbors (use `VisibleCellIds` or walk `Portals.OtherCellId`)
- If outdoor: enumerate outdoor cells the bounding sphere overlaps — current behavior for cellScope=0
- `Register` deprecates `cellScope` param (Obsolete attribute kept for slice 2). New required param `m_positionCellId`.
- All 6 production registration sites in [`GameWindow.cs`](../../src/AcDream.App/Rendering/GameWindow.cs) updated to pass the entity's m_position cellId:
- `:3139` server-spawned entities — pass `spawn.Position.Value.LandblockCellId` (or analog)
- `:5893` landblock-baked statics — pass the static's resolved cellId (compute from world XY if no `ParentCellId`)
- `:5963, :5999, :6024, :6211` setup-derived primitive shapes — same as 5893
### Tests
- `Register_OutdoorPosition_RegistersInOutdoorCellsOnly` — outdoor m_position, indoor cell list is empty for that entity
- `Register_IndoorPosition_RegistersInThatCellAndPortalNeighbors` — indoor m_position, the cell + portal-visible cells are in the list
- Existing 11/11 harness tests + 19+ ShadowObjectRegistry tests continue passing
- Slice 1's `LiveCompare_DoorThroughDoorway` continues passing
### Risks (call-outs from design doc §5)
- **Two-tier streaming order:** if far-tier cells load BEFORE their portal-visible neighbors are loaded, `BuildShadowCellSet` might miss portal cells that arrive later. Mitigation: verify the streaming order in `StreamingController` + `LandblockStreamer`. Possibly re-register on cell load if a portal-neighbor arrives late.
- **Live entity perf:** `UpdatePosition` runs at 5-10 Hz per visible entity. `BuildShadowCellSet`'s portal-traversal is O(portal_count_per_cell). Measure before/after — should still be sub-microsecond.
### Commit shape
```
feat(phys): A6.P4 slice 2 — BuildShadowCellSet for retail-faithful Register
refactor(phys): A6.P4 slice 2 — production call sites pass m_positionCellId
```
(Two commits — feat for the registry change, refactor for the GameWindow.cs site updates. Keep them in separate commits so a future bisect can attribute regressions cleanly.)
---
## Slice 3 — remove b3ce505 stopgap (~few hours)
### Goal
Delete the `primaryCellId` parameter on `ShadowObjectRegistry.GetNearbyObjects` and the indoor-primary skip gate. After slice 2, the architecture no longer needs query-time gating — the right shadows are returned by per-cell iteration alone.
### Plan
- `ShadowObjectRegistry.GetNearbyObjects`: remove `primaryCellId` param + the `if ((primaryCellId & 0xFFFFu) >= 0x0100u) return;` block
- `TransitionTypes.cs:2180` (`Transition.FindObjCollisions`): drop the `primaryCellId: sp.CheckCellId` argument
- `LiveCompare_FirstCap_FixClosesCottageFloorCap` test docstring: update to attribute the fix to registration-side cell-set computation instead of query-side gate
- Remove slice-1's `portalReachableOutdoorCells` parameter too if slice 2's registration-side fix obsoletes it (verify by running slice 3 without it and confirming doors still work)
### Verification — the load-bearing check
After slice 3, the fix is supposed to live at the registration side, not the query side. Visual verify that:
- Cellar still climbable (#98 still closed)
- Doors still block both sides (#99 still closed)
- Indoor statics still block (chair, fireplace)
- Outdoor statics still block (building walls from outside)
If anything regresses after removing the stopgap, slice 2 didn't fully port the registration-side architecture — investigate before declaring slice 3 done.
### Commit shape
```
refactor(phys): A6.P4 slice 3 — remove b3ce505 indoor-primary gate (stopgap retired)
docs: A6.P4 ship — #98 architectural close, #99 close, likely-closes #97 + Finding 3 family
```
---
## After A6.P4: #100 (transparent ground around houses)
### What we know
- Bisected to commit `35b37df` ("chore(phys): A6.P3 #98 triage")
- Introduced the `hiddenTerrainCells` mechanism in `src/AcDream.Core/Terrain/LandblockMesh.cs:178` — collapses terrain triangles in outdoor cells where buildings sit
- Granularity is 24m × 24m outdoor cell; cottage footprint is ~12m × 12m → entire 24m cell hidden but cottage only fills part of it → dark rectangle around every house
- The hide list comes from `LandblockLoader.BuildBuildingTerrainCells` reading `LandBlockInfo.Buildings`
### Three fix paths (from `docs/ISSUES.md` #100)
1. **Polygon-level terrain occlusion** — build per-building convex-hull cutouts, modify mesh to have a polygon-precise hole. Retail-faithful (probably) but real engineering work in `LandblockMesh.Build`
2. **Drop the hiddenTerrainCells mechanism + Z lift** — accept that buildings sit on terrain and use a render-only Z lift on building floors (same trick env cell floors already use at `GameWindow.cs:5363 + Vector3(0,0,0.02f)`)
3. **Render the building's "yard" mesh** — if retail has a stone-foundation mesh around each building, render it. Need retail visual research
Option 2 is the smallest and probably right; option 1 is the most faithful. Decide via retail visual cross-check at session start.
### Phase shape
File as A6.P5 or N.7 (it's rendering, not physics — should be in a separate phase letter). Likely 1 session (small change + visual verification).
---
## Decomp anchors (one stop reference)
All from `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
| Line | Function | Role |
|---|---|---|
| 308742+ | `CObjCell::find_cell_list(Position, ...)` | Cell list at registration |
| 308751-308769 | (within) indoor/outdoor branch | Indoor adds 1; outdoor calls `add_all_outside_cells` |
| 308773-308825 | (within) visible-cells recursion | Portal traversal via vtable offset 0x80 |
| 282819+ | `CPhysicsObj::add_shadows_to_cells(CELLARRAY)` | Adds to each cell's list |
| 283322, 283369, 283389 | call sites | Build cell array, then add_shadows_to_cells |
| 308584+ | `CObjCell::add_shadow_object` | Per-cell list append |
| 308916 | `CObjCell::find_obj_collisions(this, ...)` | Per-cell iteration at query time |
| 309560 | `CEnvCell::find_collisions` | Indoor entry — env then obj |
| 316951 | `CLandCell::find_collisions` | Outdoor entry — env then sort then obj |
---
## CLAUDE.md rules that apply
- **No workarounds without approval** — A6.P4's purpose IS removing a workaround (b3ce505). Don't add new ones. If slice 2 reveals an architectural mismatch that needs a band-aid, STOP and file an issue with full repro notes.
- **Retail-faithful first; cleaner second** — if a retail-port decision conflicts with a modern-design preference, retail wins.
- **Visual verification belongs to the user** — at the end of each slice, request a launch. Don't claim "fix verified" without it.
- **Work-order autonomy** — Claude picks the next step; user reviews. Don't ask "should I start slice 2?"; do it after slice 1 verifies.
- **Apparatus-first for physics divergences** — if any slice surfaces a new bug, build apparatus before guessing (per `feedback_apparatus_for_physics_bugs.md`).
---
## Pickup prompt for next session
```
A6.P4 — retail-faithful per-cell shadow_object_list port. Three slices,
then issue #100. Worktree open:
C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c
Read FIRST (in order, ~15 min):
1. docs/research/2026-05-24-a6-p4-pickup-handoff.md — this handoff
(the canonical pickup; everything else expands from it)
2. docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md
— the design doc (slices, anchors, risks)
3. docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
— Resolution section at the bottom (the saga that led here)
State both altitudes at the start:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 slice 1 — query-side portal expansion to close #99
(run-through doors regression from b3ce505)
Direction (user-approved 2026-05-24):
Option B — A6.P4 full (slices 1-3) then issue #100 (transparent ground).
Slice 1 closes #99 fast. Slices 2-3 port retail's Register-side cell-set
computation and remove the b3ce505 stopgap. Likely closes #97 + Finding 3
family as side effects. #100 is a separate phase after A6.P4 (rendering,
not physics).
DO NOT REDO:
b3ce505 — issue #98 cellar fix (visual-verified by user 2026-05-24)
b55ae83 — design doc + #98 resolution + #99/#100 filed + memory entries
Apparatus already in tree: PhysicsResolveCapture, GfxObjDump, CellDump,
CellarUpTrajectoryReplayTests harness + fixtures
Slice 1 first moves (in order):
(1) PRE-FLIGHT Q1 (~10 min): Does CellPhysics.VisibleCellIds include
the outdoor cell on the other side of a building doorway? Read
src/AcDream.Core/Physics/CellPhysics.cs + LandblockLoader.cs.
Cross-ref with tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40143.json
(cottage main floor cell). If yes, slice 1 walks VisibleCellIds.
If no, slice 1 walks Portals.OtherCellId directly.
(2) PRE-FLIGHT Q2 (~10 min): Are doors actually registered with
outdoor cellScope today? Find the door spawn path (likely
GameWindow.cs:3139 + EntitySpawnAdapter), trace cellScope passed.
If doors aren't outdoor-registered, the #99 diagnosis is wrong;
stop and re-investigate via ACDREAM_CAPTURE_RESOLVE at a Holtburg
doorway.
(3) IMPLEMENT (~30 min if Q1+Q2 confirm):
- ShadowObjectRegistry.GetNearbyObjects gains an optional
portalReachableOutdoorCells parameter
- TransitionTypes.cs:2180 (FindObjCollisions) computes the set
from indoorCellIds + VisibleCellIds/Portals
- New LiveCompare_DoorThroughDoorway_* test (live capture
preferred; synthetic fallback)
- 11/11 CellarUpTrajectoryReplayTests must still pass
(4) VERIFY (user-side): launch acdream, walk cottage cellar (still
climbable), test doors from both sides (block from both sides
now), bump indoor furniture (still blocks), bump outdoor walls
(still blocks).
(5) COMMIT (per slice 1 commit shape in the handoff doc).
Slices 2-3 plans + #100 plan in the handoff doc — execute one slice
per session, visual-verify between, file follow-ups as discovered.
CLAUDE.md rules apply:
- No workarounds (the b3ce505 stopgap is what slice 3 retires; don't
add new ones)
- Apparatus-first if a new bug surfaces (3+ failed attempts = stop)
- Visual verification belongs to user
- Work-order autonomy — keep going through slices without asking
"should I continue?"
Test baseline: 11/11 CellarUpTrajectoryReplayTests + 19+
ShadowObjectRegistry + 4 GfxObjDumpRoundTrip + 4 CellDumpRoundTrip
+ 1 PhysicsDiagnosticsTests pass in isolation. Maintain. Pre-existing
8-19 static-state-leakage failures in serial physics suite are
unchanged from baseline (verified by stash+retest pattern).
```

View file

@ -1,215 +0,0 @@
# Door collision — apparatus replay shipped, root cause identified
2026-05-24 (continuation of the door-collision investigation)
> **SUPERSEDED 2026-05-25** by
> [`docs/research/2026-05-25-door-bug-partial-fix-shipped.md`](2026-05-25-door-bug-partial-fix-shipped.md).
> The root-cause analysis here was correct in direction
> (cell-portal traversal is upstream of BSP query) but missed the
> specific bug: `CellTransit.AddAllOutsideCells` silently failed for
> landblock-local sphere coords (production's convention) because it
> subtracted an absolute-world `lbXf` offset. Diagnosis + fix in the
> 2026-05-25 doc.
## TL;DR
The trajectory-replay apparatus is **wired and useful**. Run the diagnostic
test for the failing tick and the engine's full `[step-walk]` trace
prints, naming the divergence per-field.
**The bug: `CellTransit.FindCellSet` does not surface outdoor cell
`0xA9B40029` (where the door is registered) from indoor primary cell
`0xA9B40150`.** With issue #98's indoor-cell gate on the outdoor radial
sweep, the door is therefore invisible to `GetNearbyObjects` and the
BSP slab is never tested. The player walks through unimpeded.
Cn=(0,1,0) from the harness is **not the door** — it's the seeded
walkable polygon's south edge being treated as a wall when the sphere
falls off it. The harness reproduces production's "door not queried"
behavior, just with an apparatus artifact in place of clean walkthrough.
## What was shipped
1. **Live capture** (`door-walkthrough.jsonl`, 24,310 records ≈ 45 MB).
The capture was driven via `ACDREAM_CAPTURE_RESOLVE` + the existing
`[entity-source]` + `[bsp-test]` probes. **One record per
`PhysicsEngine.ResolveWithTransition` call** with full
`PhysicsBody` snapshots before/after.
2. **Fixture extraction**
([tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl](../../tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl), 4 KB).
Two representative ticks pulled from the JSONL:
- **Tick 13558** — the walkthrough. Player at (132.36, 16.81, 94) in
**indoor cell 0xA9B40150**, target (132.43, 17.20, 94). Live
result.Position = target with `collisionNormalValid = false`. Door
centered at world XY (132.57, 16.99), BSP radius 1.975, state
`0x00010008` = `PERSISTENT_PS | 0x8` (NO `ETHEREAL_PS = 0x4`
**CLOSED**).
- **Tick 22760** — the working block. Player at (133.14, 18.02, 94)
in **outdoor cell 0xA9B40029**, target (133.10, 17.60, 94). Live
blocks at Y=18.018 with cn=(0, +1, 0). Same door, different
primary cell type.
3. **Replay harness**
([DoorBugTrajectoryReplayTests.cs](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs)):
loads tick fixtures, hydrates door GfxObj `0x010044B5` from real dat
(`DatCollection.Get<GfxObj>`), registers a synthetic door via
`ShadowObjectRegistry.RegisterMultiPart` at the captured BSP world
center (`(132.57, 16.99, 95.36)`) with `cellScope=0u` (mirrors
production registration at
[GameWindow.cs:3158-3167](../../src/AcDream.App/Rendering/GameWindow.cs#L3158)).
`AssertCallMatchesCapture` replays the call and prints the first
per-field divergence. Diagnostic variant enables every
`PhysicsDiagnostics.Probe*Enabled` and dumps the full engine trace.
## Chronology (from `door-walkthrough.launch.log`)
Confirmed the door state at the time of every walkthrough:
| Log line | Event |
|---|---|
| 10796 | `[setstate]` door state → `0x0001000C` (PERSISTENT + ETHEREAL = OPEN) |
| 10993 | `[setstate]` door state → `0x00010008` (PERSISTENT, NOT ethereal = CLOSED) |
| 1099511071 | First and last `[bsp-test]` line on door 0x000F4246. All `state=0x00010008` |
So every `[bsp-test]` hit on the door, and every walkthrough event in
the JSONL, is against the **closed** door. The bug is real, not an
ETHEREAL pass-through.
## What the diagnostic test prints (tick 13558)
```
=== Replay tick 13558 (the walkthrough) ===
[step-walk] site=find-start cur=(132.36,16.81,94) ... walkPoly=True
[step-walk-adjust] branch=into-plane input=(0.07,0.39,0.00) output=(0.07,0.39,0.00) zGain=0
[step-walk] site=before-insert ... delta=(0.0744,0.3928,0) cell=0xA9B40150 ... walkPoly=True
[step-walk] site=stepdown-enter ... delta=(0.0744,0.3928,0) stepDown=True walkableZ=0.6642
[step-walk] site=stepdown-after-offset ... delta=(0.0744,0.3928,-0.75) ... walkPoly=True
... (probes down by 0.75, then 1.5; all OK; walkPoly=True)
[step-walk] site=stepdown-enter ... delta=(0.0744,0.0000,0) ... hit=(0,-1,0) walkPoly=False
... (probes down again; hit stays (0,-1,0); walkPoly=False throughout)
[step-walk] site=after-insert state=Collided ... hit=(0,-1,0) walkPoly=False
[step-walk] site=after-validate state=OK ... position back to input
[resolve] in=(132.360,16.811,94) cell=0xA9B40150 tgt=(132.435,17.204,94)
out=(132.360,16.811,94) cell=0xA9B40150 ok=True
hit=yes n=(0,-1,0) walkable=True
=== Harness: pos=(132.36,16.81,94) cn=(0,-1,0) cnValid=True onGround=True cell=0xA9B40150
=== Live: pos=(132.43,17.20,94) cn=(0,0,0) cnValid=False onGround=True cell=0xA9B40150
```
**No `[bsp-test]` line fires.** The door's BSP is never queried. The
hit `(0, -1, 0)` is the engine's "sliding off the south edge of the
seeded walkable polygon" response — not a door collision.
This matches production: at indoor primary cell `0xA9B40150`,
`GetNearbyObjects` returns ZERO shadows because:
1. The captured `cellId` low-nibble `0x150 >= 0x100` → indoor →
issue #98's gate at
[ShadowObjectRegistry.cs:480](../../src/AcDream.Core/Physics/ShadowObjectRegistry.cs#L480)
skips the outdoor radial sweep.
2. `portalReachableCells` (built by `CellTransit.FindCellSet`) lacks
outdoor cell `0xA9B40029`. In the harness, this is because we
register no cell fixture for `0xA9B40150` and the indoor branch at
[CellTransit.cs:403-407](../../src/AcDream.Core/Physics/CellTransit.cs#L403)
early-returns with empty candidates. **In production**, the cell
IS in cache but the traversal still doesn't produce `0xA9B40029`
the cell's exit portal (`OtherCellId=0xFFFF`) either doesn't fire
`exitOutside=true` at the sphere's position, or `AddAllOutsideCells`
isn't computing the right outdoor cell.
## Next investigation move
**Dump cell `0xA9B40150` from the dat and inspect its portal list.**
Two ways:
a) **Dat-direct read in a test** (preferred — no live launch). Pattern
from
[DoorSetupGfxObjInspectionTests](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs):
`dats.Get<EnvCell>(0xA9B40150u)`, then iterate
`envCell.CellPortals` and print each portal's `OtherCellId`,
`PolygonId`, `Flags`. If no portal with `OtherCellId == 0xFFFF`,
`exitOutside` can never be true → bug is in the cell's portal-graph
loading (or the cottage doesn't connect via 0xFFFF exit portals;
it might use the building-shell path via
`BuildingPhysics.CheckBuildingTransit` instead).
b) **Live `ACDREAM_DUMP_CELLS=0xA9B40150,0xA9B4013F,0xA9B40154`**
another launch cycle. Less preferred; we already have what we need
from the dat read.
The dat-direct read can be a new test method in
`DoorSetupGfxObjInspectionTests` (it's the natural home for this
class of dat-introspection checks).
## What NOT to do next
1. **Don't speculate on the fix.** We have the right replay apparatus
now; the next move is **read the dat** to determine the cell's actual
portal structure. Then we'll know whether the bug is in the dat
data, the portal loading, the exit-portal detection in
`FindTransitCellsSphere`, or `AddAllOutsideCells`'s grid math.
2. **Don't modify the replay test to mask the walkable-polygon edge
artifact.** The artifact is harmless (it documents that, given a
single isolated walkable poly, the engine treats its boundary as a
wall — true regardless of the door bug). The interesting finding is
"no `[bsp-test]` line"; the edge artifact just happens to fill the
collision slot.
3. **Don't re-do the registration shape.** Multi-part registration
+ dedup fix + Task 7 wiring are correct. Verified by the harness's
ability to query the door registration (it just isn't reached at
indoor primary cells).
## Files touched this session
**Committed:** none yet — pending commit at session end.
**Uncommitted:**
- `tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl`
2 captured ResolveWithTransition records (tick 13558 walkthrough +
tick 22760 outdoor block)
- `tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs`
apparatus: 2 LiveCompare tests + 1 Diagnostic dump
- `docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md`
this doc
## Pickup prompt for the next session
```
A6.P4 door bug — apparatus replay shipped. DoorBugTrajectoryReplayTests
loads tick 13558 (walkthrough) and 22760 (block) from a captured fixture
and replays through the engine. Door 0x000F4246 (closed, state=0x00010008,
BSP world (132.57, 16.99, 95.36) radius 1.975) IS registered correctly
in the harness, BUT the engine never queries it from indoor primary cell
0xA9B40150 — no [bsp-test] line fires. Root cause located:
CellTransit.FindCellSet's portal traversal does not surface outdoor cell
0xA9B40029 from indoor cell 0xA9B40150.
Read docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 door bug — cell-portal investigation.
Apparatus shipped; next step is to dump cell
0xA9B40150's portal list (from the dat) and
determine why FindTransitCellsSphere doesn't
add outdoor cell 0xA9B40029 to candidates.
First move: add a test to DoorSetupGfxObjInspectionTests (or a new
CellPortalDatInspectionTests file) that reads EnvCell 0xA9B40150 from
the real dat and prints every portal's OtherCellId, PolygonId, Flags.
Then read 0xA9B4013F (player's other indoor cell from JSONL) and
0xA9B40029 (door's outdoor cell) for cross-comparison. The portal
structure will reveal whether cottages use 0xFFFF exit portals
(FindTransitCellsSphere path) or building-shell portals
(CheckBuildingTransit path). If 0xFFFF exit portals exist but
exitOutside isn't firing, the bug is in the sphere-vs-plane test
at CellTransit.cs:99-112. If they don't exist, the building-shell
path is misconfigured for indoor-primary calls.
DO NOT:
- Modify the replay test to mask the walkable-polygon-edge artifact
- Re-do the registration shape (correct)
- Speculate on the fix without dat evidence
```

View file

@ -1,188 +0,0 @@
# Door collision — end-of-session handoff (2026-05-24, late)
**Branch:** `claude/strange-albattani-3fc83c`
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
## TL;DR — what was actually accomplished
**No user-visible bug was fixed this session.** The door bug the user
reported at the start (center blocks, off-center walks through,
inside-out walks through) is **identically reproducible after the
4 commits** as it was before them.
What changed: infrastructure. Server-spawned doors now register
multi-part shadow shapes (cylinder + BSP slab) instead of one
cylinder approximation. The BSP slab is queried 135 times per door
near approach but produces **zero collision hits**, so observed
behavior is unchanged.
**Don't re-do the infrastructure.** It's correctly built and necessary
for any future fix. The remaining work is downstream of it.
## Commits landed (4)
```
163a1f0 diag(phys): [bsp-test] probe + grounded apparatus test + handoff
ca9341c feat(phys): A6.P4 Task 7 — RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart
3b7dc46 fix(phys): GetNearbyObjects dedup-by-entityId silently drops multi-part shadows
e1d94d7 test(phys): door setup + GfxObj dat-inspection — Hypothesis A falsified
```
**Real-but-latent value from these:**
- The dedup-by-entityId issue (3b7dc46) was a latent footgun: any
future attempt at multi-part shadows (NPCs with hit-region capsules,
multi-part creatures, props with separated collision) would have
silently dropped all but the first shape. Now safe.
- The dat-inspection (e1d94d7) proved part 0 (`0x010044B5`) has a
real 1.9×0.26×2.5 m BSP slab in the dat. A future fix doesn't have
to question whether the data exists.
- The Task 7 wiring (ca9341c) puts the right architecture in place —
doors now register the shapes retail expects (cyl per CylSphere +
cyl per Sphere + BSP per Part-with-BSP).
- The `[bsp-test]` probe (163a1f0) fires before the cache lookup,
distinguishing "cache miss → silent skip" from "queried but no
hit" — neither of which `[resolve-bldg]` ever showed.
**Brutally clear: zero of these commits change observed door behavior.**
## What we now know vs. what we don't
### Known (from this session's probes)
- `0x010044B5` PhysicsBSP has 6 collision-bearing polygons forming a
1.925 × 0.261 × 2.490 m door slab. All `SidesType=Landblock`
(two-sided). Bounding sphere radius 1.975 m. Verified by direct
dat read.
- `0x010044B6` (the two leaf parts) have `HasPhysics=false`,
`PhysicsBSP=null`, `PhysicsPolygons.Count=0`. Visual-only by retail
design — our skip matches retail's
`CPhysicsPart::find_obj_collisions:275051`.
- Live Holtburg doors register with `shapes=cyl1+bsp1`. Cache is
populated. BSP entries are visited (135x for one door at player
approaches as close as 0.42 m).
- The BSP traversal produces ZERO attributed hits during live walking
(all 19 `[resolve-bldg]` lines show `gfxObj=0x00000000`, which is
the Cylinder shape). Whatever is happening inside
`SphereIntersectsPolyInternal` or the dispatch around it is
swallowing the hit silently.
### NOT known (don't speculate further)
- **Whether `DoStepUp` is involved.** The prior handoff doc
(`2026-05-24-door-collision-task7-shipped-but-bug-remains.md`)
asserted "step-up incorrectly succeeds" as the leading hypothesis.
That was over-reach. In the apparatus, `ACDREAM_DUMP_STEPUP=1`
produced no `stepup: ENTER` lines — `DoStepUp` was never called.
So the apparatus shows `hit=yes n=(0,0,1)` from some OTHER path
(terrain step-down? walkable poly preservation?). It does not
confirm step-up is the production bug.
- **Whether the production hit happens at the BSP polygon edge test,
the BSP node traversal, or some other layer.**
- **Whether the production code path is the same as the apparatus
path 5 in the first place.**
The earlier framing of "step-up is the bug" was a guess I inflated
into a conclusion. Treat it as a candidate, not a finding.
## Proper next move
**Same pattern that closed issue #98 after 6+ failed speculation rounds:
live capture + apparatus replay.**
The infrastructure for this already exists in the codebase:
1. `ACDREAM_CAPTURE_RESOLVE=<path>` env var (see
`src/AcDream.Core/Physics/PhysicsResolveCapture.cs`) captures every
player-side `PhysicsEngine.ResolveWithTransition` call as a JSON
Lines record with full `PhysicsBody` before-and-after snapshots.
2. `CellarUpTrajectoryReplayTests.LoadCapturedRecord` +
`AssertCallMatchesCapture` replay a captured record through a
harness engine and emit the first per-field divergence between
live and harness outputs.
The plan:
1. Launch with `ACDREAM_CAPTURE_RESOLVE=door-walkthrough.jsonl`
(no other probes — capture is independent).
2. Walk into a closed Holtburg cottage door 50 cm off-center.
3. Close gracefully. Save the JSONL.
4. Write a new test `LiveCompare_DoorOffCenterWalkthrough` that loads
the failing-tick record and replays it through a harness with the
real `0x010044B5` BSP hydrated + registered via
`RegisterMultiPart`. Compare per-field.
5. The first divergent field names the broken assumption. Fix that.
This is concrete, deterministic, and doesn't ask you to relaunch
multiple times for each fix attempt. The harness round-trip is <500
ms; a fix iteration is ~3 seconds.
## What NOT to do
1. **Do NOT re-do the multi-part registration.** It's correct. The
dedup fix is correct. Task 7 is correct. Verified by 53/53 tests
in the targeted scope.
2. **Do NOT speculate-and-fix.** This session burned cycles on a
"step-up is the bug" hypothesis that wasn't supported by the
evidence. The apparatus-first rule (`feedback_apparatus_for_physics_bugs.md`)
exists for exactly this. Build the apparatus before the fix.
3. **Do NOT re-investigate whether the door has BSP polygons.**
It does. 6 of them. Forming a full door slab. Cached. Visited.
4. **Do NOT relaunch with more probes hoping for an obvious signal.**
The probes we have already say "BSP visited 135 times, no hits."
More log lines won't tell us WHY it doesn't hit. The apparatus
replay will.
## Files to read first
- This doc (you're in it).
- `docs/research/2026-05-24-door-dat-inspection-findings.md` — the
dat data, polygon layout, bounding sphere center vs frame offset.
- `docs/research/2026-05-24-door-collision-task7-shipped-but-bug-remains.md`
— the prior end-of-session handoff. **Read with skepticism** — its
"leading hypothesis" section overstates confidence in the step-up
theory (corrected here).
- `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`
— the capture+replay pattern to mirror for the door bug. See
`LiveCompare_*` tests.
## State of the M1.5 milestone
Doors at Holtburg cottages: center blocks, off-center walks through,
inside-out walks through. Same as it was 24 hours ago. The walking-
through case is the actual user pain point. Until the apparatus
replay names the divergence, treat M1.5 indoor-world as still
incomplete on the door front.
The infrastructure is in place for the eventual fix. The fix itself
remains future work.
## Pickup prompt for the next session
```
Door collision investigation. Previous session shipped infrastructure
(multi-part registration + GetNearbyObjects dedup fix) but did NOT fix
the user-visible bug: off-center / inside-out approaches still walk
through closed Holtburg cottage doors.
Read docs/research/2026-05-24-door-collision-session-end-handoff.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 door bug — apparatus replay phase.
Multi-part registration shipped; need live capture
+ per-field divergence comparison to identify why
the door's BSP slab fires zero attributed hits
despite being visited 135x per approach.
First move: launch the client with ACDREAM_CAPTURE_RESOLVE=<path>,
walk into a closed Holtburg cottage door 50 cm off-center, close
gracefully. Then write a LiveCompare_* test in CellarUpTrajectoryReplayTests
that loads the captured failing tick + replays through a harness
with the door BSP hydrated via the existing 0x010044B5 dat read
pattern and registered via RegisterMultiPart.
DO NOT redo the multi-part registration. DO NOT speculate about
step-up without evidence — the apparatus tested DoStepUp and it
didn't fire. The bug is upstream of step-up. The replay will name
the actual divergence.
```

View file

@ -1,265 +0,0 @@
# Door collision per-part BSP session — handoff
**Date:** 2026-05-24 (long session, multiple phases)
**Branch:** `claude/strange-albattani-3fc83c`
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
This handoff documents an A6.P4-driven session that:
1. Shipped A6.P4 slice 1 (real cleanup, didn't close #99)
2. Investigated why doors don't block (apparatus-first)
3. Brainstormed + speced a per-part BSP collision design
4. Shipped most of the implementation (Tasks 1-6 of 10)
5. Discovered Task 7's per-part BSP doesn't actually fix the door bug
6. Reverted Task 7 and paused for further investigation
---
## TL;DR
**Shipped (real commits):**
- `b49ed90` — A6.P4 slice 1: drop the `< 0x0100u` filter in
`ShadowObjectRegistry.GetNearbyObjects`'s portalReachableCells loop,
rename `indoorCellIds``portalReachableCells`. Real cleanup; the
`FindCellSet`-already-includes-outdoor-cells discovery means doors at
building thresholds should be reachable from indoor primary spheres
via the exit-portal logic. But the user-visible #99 close was wrongly
claimed in the commit message — see below.
- `d71ceab` — design spec: per-part BSP for server-spawned entities.
- `8d4f14c` — 10-task implementation plan.
- `ab4278c` — Task 1: ShadowShape record.
- `7f5c287` — Task 2: ShadowShapeBuilder.FromSetup + 7 unit tests.
- `1454eab` — Task 3: ShadowEntry adds LocalPosition + LocalRotation.
- `fca0a13` — Task 4: ShadowObjectRegistry.RegisterMultiPart + 6 tests +
Deregister clears `_entityShapes` (Task 6 folded in).
- `d5ffb03` — Task 5: UpdatePosition recomposes multi-part transforms
via `_entityShapes`.
- `3e5dc8c` — Task 6 regression test: stray UpdatePosition after
Deregister is no-op.
- `1498697``[cyl-test]` diagnostic probe (broadly useful).
**Reverted (Task 7 staged, then `git restore`):** the
`RegisterLiveEntityCollision` refactor at `GameWindow.cs:3076`. Reverted
because visual verification showed the per-part BSP shape didn't actually
block the door — only the small Cylinder did, and even that only at
dead-center approach.
**Still pending:** Tasks 7-10 in the plan + the real fix for door
collision.
---
## What we learned (apparatus-first findings)
### Door Setup 0x020019FF shape inventory (live dump captured 2026-05-24)
```
[door-setup-dump] setupId=0x020019FF setupRadius=0.141 setupHeight=0.200
cylSpheres=0 spheres=1 parts=3 placementFrames=1
stepUp=0.090 stepDown=0.090
[door-setup-dump] sphere[0] r=0.100 origin=(0.000,0.000,0.018)
[door-setup-dump] part[0] gfxObj=0x010044B5
[door-setup-dump] part[1] gfxObj=0x010044B6
[door-setup-dump] part[2] gfxObj=0x010044B6
```
### Per-shape registration (post-Task-7-experiment)
With `ShadowShapeBuilder.FromSetup` running over Setup 0x020019FF in the
live launch, doors registered 2 shadows each:
1. `type=Cylinder radius=0.100 height=0.200 localPos=(0,0,0.018)` — from
the Sphere converted to short Cylinder.
2. `type=BSP gfxObj=0x010044B5 radius=2.000 localPos=(-0.006,0.125,1.275)`
from part 0 (the frame). The other two parts (`0x010044B6` x2) have
`BSP=null` → skipped.
### Collision behavior (visual verified by user, 2026-05-24)
| Scenario | Result |
|---|---|
| Cellar climb (#98 regression check) | ✅ Works |
| Door from outside, dead center | ⚠️ Partial — only the small Cylinder blocks; player stops at the center |
| Door from outside, ~50 cm off-center | ❌ Pass through |
| Door from outside (Use → swing) | ✅ Swing animation works, door opens |
| Indoor furniture (#91 regression check) | ✅ Works |
| Outdoor exterior wall (regression check) | ✅ Works |
| Door from inside walking out | ❌ Pass through |
### Diagnostic evidence
In 188K+ resolve lines from the launch:
- `Door 0xF4249 : 85 cyl-tests, 13 resolve hits attributed`
- `Door 0xF424F : 227 cyl-tests, 16 resolve hits attributed`
- **Zero `[resolve-bldg]` attributions for any door**
Conclusion: the per-part BSP at `0x010044B5` produces NO collision hits.
Either:
1. The PhysicsBSP at that GfxObj has no collision-bearing polygons
(only visual polys), OR
2. Our world-to-part-local sphere transform is wrong, OR
3. The broadphase rejects it (unlikely with radius=2.0 default).
---
## Why this differs from M1 visual verification on 2026-05-13
The user remembers doors blocking on the M1 demo verification. That
demo was "open the inn door" — clicking + watching the swing animation.
The walking-through-an-open-door part was not deliberately tested. The
closed-door blocking was probably observed accidentally when the user
walked directly at a center-of-doorway cylinder; the 14 cm cylinder is
just wide enough to catch a sphere at exactly the centerline. Today's
careful off-center test exposed the gap.
So nothing regressed since 2026-05-13. The bug has been latent. Our
investigation just exposed it.
---
## Investigation gap to close before the next implementation attempt
The per-part BSP design IS retail-faithful in shape (matches
`CPhysicsObj::FindObjCollisions``CPartArray::FindObjCollisions`
`CPhysicsPart::find_obj_collisions``CGfxObj::find_obj_collisions`).
But it didn't surface a working blocker for the cottage doors. Three
hypotheses, ranked by likelihood:
### Hypothesis A (most likely): Part 0x010044B5 has no collision-bearing PhysicsBSP polygons
The Setup defines visual parts. Some parts (especially decorative
hardware) may have a PhysicsBSP that's just the visual mesh's bounding
volume, with no walls or threshold polygons. The door's collision might
genuinely be just the small Cylinder by retail design, and retail
gets full doorway blocking from the **building's BSP** having a narrow
gap exactly the size of the door's Cylinder (~28 cm × 28 cm).
**How to verify:** Dump `0x010044B5`'s PhysicsBSP polygons via
`ACDREAM_DUMP_GFXOBJS=0x010044B5`. Inspect the polygons. If they're
just an axis-aligned bounding box matching the visual mesh, no useful
collision data exists at the part level.
### Hypothesis B: Building BSP has a wide doorway gap that retail's tiny cylinder doesn't fill
A retail building (e.g., cottage interior 0x020XXXXX) has its walls as
BSP polygons. The doorway is a gap. If the gap is ~2 m wide (visual
opening), the 28 cm cylinder doesn't span it — even retail wouldn't
block.
**How to verify:** Open RenderDoc on retail (or our client) and inspect
the cottage interior GfxObj BSP at the doorway. Measure the gap. If
it's narrow (~30 cm), the small cylinder fills it. If wide (~2 m), the
cylinder is decorative and the actual blocker must come from elsewhere.
### Hypothesis C: Retail uses a different collision mechanism entirely
Doors might use Setup.Radius / Setup.Height (the bounding cylinder
dimensions, 0.141 × 0.200 — slightly larger than our Sphere-derived
0.100 × 0.200) AS THE PRIMARY BLOCKER, not the Sphere. Or retail
overrides shape selection for `ItemType==Door` specifically.
**How to verify:** Attach cdb to a live retail client at a cottage
doorway, set a breakpoint on `CPhysicsObj::FindObjCollisions` for the
door's PhysicsObj, observe which shape branch fires.
---
## Recommended next-session approach
Per the project's "apparatus-first for physics divergences" rule
(`feedback_apparatus_for_physics_bugs.md`):
1. **Stop coding.** Don't try another fix without evidence.
2. **Dump 0x010044B5's PhysicsBSP** via `ACDREAM_DUMP_GFXOBJS=0x010044B5`.
If it has zero floor-touching polygons → Hypothesis A confirmed.
3. **Attach cdb to retail** at a cottage doorway. Trace which shapes
block the player. See `project_retail_debugger.md` for the toolchain.
4. **Cross-reference ACE source** for Door collision (if any) — search
`references/ACE/Source/ACE.Server/Physics/` for door handling.
5. **Re-brainstorm** with the new evidence. The Task 1-6 infrastructure
stays (it's correctly modeling retail's CPhysicsObj-per-entity
with parts iterated for collision). Only the SHAPES we register
need to change.
The infrastructure investment was not wasted. The architecture is right.
We just registered the wrong shapes from the door setup.
---
## What's in the tree right now
```
$ git log --oneline -15
1498697 diag(phys): [cyl-test] probe — log every Cylinder shadow collision test
3e5dc8c test(phys): Task 6 regression — Deregister clears _entityShapes cache
d5ffb03 feat(phys): UpdatePosition handles multi-part entities
fca0a13 feat(phys): ShadowObjectRegistry.RegisterMultiPart
1454eab feat(phys): ShadowEntry adds LocalPosition + LocalRotation
7f5c287 feat(phys): ShadowShapeBuilder.FromSetup
ab4278c feat(phys): add ShadowShape record (no callers yet)
8d4f14c docs(phys): implementation plan — per-part BSP for server-spawned entities
d71ceab docs(phys): design spec — per-part BSP collision for server-spawned entities
b49ed90 feat(phys): A6.P4 slice 1 — portal-reachable cellSet includes outdoor cells
b3ce505 fix(phys): A6.P3 #98 — gate outdoor shadow radial sweep on indoor primary cell
b55ae83 docs: A6.P3 #98 resolution + A6.P4 design + #99/#100 filed
3e3cd77 docs(handoff): A6.P4 pickup handoff — full session-resume artifact
```
All 49+ tests pass:
- 24 ShadowObjectRegistryTests
- 7 ShadowShapeBuilderTests
- 8 ShadowObjectRegistryMultiPartTests
- 11 CellarUpTrajectoryReplayTests
Pre-existing 6-8 baseline static-state-leakage failures in the broader
Physics suite are unchanged from prior sessions.
**No-commit state:** working tree is clean. `git status --short`
shows only untracked investigation logs (`a6-issue98-*.log`,
`launch-task7-*.log`, etc. — these accumulate from launches and don't
get committed).
---
## #99 status: still open
The A6.P4 slice 1 commit message claimed "Closes #99" but the visual
verification today proves that's premature. Slice 1 did a real cleanup
(removed a misleading filter) but didn't fully address the user-visible
door-block bug. Update `docs/ISSUES.md` accordingly (issue #99 remains
OPEN; the per-part BSP architecture is NEW infrastructure built today
that will support the eventual fix once we identify the right shapes).
---
## Pickup prompt for next session
```
Door collision still doesn't fully block in M1.5 Holtburg. Per-part BSP
infrastructure shipped 2026-05-24 (Tasks 1-6 of A6.P4 plan), but the
specific shapes we register from door setup 0x020019FF don't catch the
player. Need apparatus-first investigation:
Read docs/research/2026-05-24-door-collision-session-handoff.md
(this doc — recent session handoff)
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 — investigation phase to find the right door
collision shapes; per-part BSP infrastructure
already shipped; need to verify Hypothesis A/B/C
before any more implementation
First moves (in order):
1. Dump GfxObj 0x010044B5's PhysicsBSP via ACDREAM_DUMP_GFXOBJS.
Does it have collision-bearing polygons or just visual?
2. If yes → debug the per-part transform (likely Hypothesis B/C
wrong); if no → confirm Hypothesis A and pivot strategy.
3. Either way, attach cdb to retail at a cottage doorway to see
what retail actually blocks with.
DO NOT speculate-and-fix again. The session 2026-05-24 already
burned a Task 7 attempt on a hypothesis that turned out wrong. The
6 committed implementation tasks (Tasks 1-6) are correct and stay.
Only Tasks 7-10 of the plan need to change once we know the right
shapes.
```

View file

@ -1,247 +0,0 @@
# Door collision — Task 7 shipped, partial fix, deeper bug remains
**Date:** 2026-05-24 (evening, continuation of door collision investigation)
**Branch:** `claude/strange-albattani-3fc83c`
**Status:** A6.P4 architecture is correct. Multi-part registration works.
The Holtburg door bug PARTIALLY fixed — center blocks, but off-center
and inside-out still walk through. Root cause is downstream in the
engine's grounded BSP collision path (Path 5 + step-up), NOT in the
multi-part registration we just shipped.
---
## TL;DR
**Three commits shipped** (composable foundation):
| SHA | Title | What it does |
|---|---|---|
| `e1d94d7` | dat-inspection test | Confirmed door part `0x010044B5` has full 1.9×0.26×2.5 m BSP slab (6 Landblock polys). Hypothesis A from prior handoff was wrong. |
| `3b7dc46` | `GetNearbyObjects` dedup fix | Changed `HashSet<uint>` (entityId) → `HashSet<ShadowEntry>`. Multi-part shapes no longer silently dropped. |
| `ca9341c` | Task 7 live wiring | `RegisterLiveEntityCollision` uses `ShadowShapeBuilder.FromSetup` + `RegisterMultiPart`. Doors now register cyl+bsp instead of just cyl. |
**Live verification (visual user test):**
| Scenario | Result |
|---|---|
| Dead center, walk into closed door (outside) | ✅ Blocks |
| 50 cm off-center, walk into closed door (outside) | ❌ Walks through |
| Inside walking out (closed door) | ❌ Walks through |
| Use door → swing → walk through | ✅ Works (ETHEREAL flip path) |
**Probe-instrumented live capture confirms multi-part registration works:**
- Every door spawn shows `[entity-source] shapes=cyl1+bsp1` — both shapes register.
- BSP part `0x010044B5` is visited 135 times for a single door at player approaches as close as `distXY=0.415 m`.
- `cacheHit=True` for every visit — the cache is populated.
- BUT: zero `[resolve-bldg]` attributions for the BSP shape (all 19 attributed hits show `gfxObj=0x00000000` = the Cylinder shape).
So the BSP is being QUERIED but never produces an attributed hit. The
sphere walks through despite the BSP geometry being present and
visited.
---
## What's in the tree right now
```
$ git log --oneline -8
ca9341c feat(phys): A6.P4 Task 7 — RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart
3b7dc46 fix(phys): GetNearbyObjects dedup-by-entityId silently drops multi-part shadows
e1d94d7 test(phys): door setup + GfxObj dat-inspection — Hypothesis A falsified
c89df8e docs(handoff): door collision per-part BSP session handoff (2026-05-24)
1498697 diag(phys): [cyl-test] probe — log every Cylinder shadow collision test
3e5dc8c test(phys): Task 6 regression — Deregister clears _entityShapes cache
d5ffb03 feat(phys): UpdatePosition handles multi-part entities
fca0a13 feat(phys): ShadowObjectRegistry.RegisterMultiPart
```
**Uncommitted (to commit next):**
- `src/AcDream.Core/Physics/TransitionTypes.cs` — new `[bsp-test]` probe in
the BSP collision dispatch, mirrors `[cyl-test]`. Fires when a BSP entry
is visited, BEFORE the cache lookup. Distinguishes "cache miss → silent
skip" from "queried but no hit." Gated on `ACDREAM_PROBE_BUILDING=1`.
- `tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs`
new test `Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug`
that attempts to reproduce the production bug with a grounded body +
seeded ContactPlane. Currently fails because the apparatus's behavior
diverges from production (apparatus blocks immediately at tick 0 with
a Z+ normal from the synthetic floor; production walks through).
---
## Path 5 vs Path 6 — the divergence
`BSPQuery.FindCollisions` dispatches to 6 paths based on `ObjectInfo`
state. The crucial difference:
- **Path 6 (Default)** — fires when `obj.State` has no `Contact` flag.
Calls `SphereIntersectsPolyInternal` and `SetCollide` on hit.
**Apparatus tests use this path** (no body, `isOnGround=false`). They
all PASS — the door's BSP blocks the sphere correctly.
- **Path 5 (Contact branch)** — fires when `obj.State.HasFlag(Contact)`.
Calls `SphereIntersectsPolyInternal`; on hit, calls
`StepSphereUp → DoStepUp → DoStepDown` to attempt climbing over the
obstacle. Returns OK if step succeeds, Slid if step fails.
**Production uses this path** (player grounded → `isOnGround=true`
engine sets `Contact` flag at `PhysicsEngine.cs:631`). Production
WALKS THROUGH.
So the bug is somewhere in Path 5's step-up logic. The leading
hypothesis (not yet proven):
> When the player is standing on flat ground in front of the door,
> step-up's `DoStepDown` probes 0.6 m downward from the sphere's
> current position. It finds the SAME flat ground extending to the
> OTHER SIDE of the door (Holtburg cottages have no Z change between
> exterior and interior floor — both at Z=94). `find_walkable`
> declares step-up SUCCESS, the BSP collision returns `OK`, and the
> sphere walks through the door.
>
> The fix probably involves: step-up should reject if a forward probe
> at the lifted height STILL hits the same obstacle. The current
> DoStepDown probes only DOWNWARD; it doesn't verify that the
> forward motion at the lifted height is clear.
This is speculation — needs apparatus verification.
---
## Why the apparatus didn't reproduce the bug
The grounded apparatus test (`Apparatus_Grounded_50cmOffCenter_*`) was
supposed to fail in the same way as production (walk through). Instead
it BLOCKED at tick 0 with normal=(0,0,1) — sphere position unchanged.
Diagnostic output:
```
[bsp-test] obj=0x000F424F gfx=0x010044B5 ... pos=(11.99,12.12,1.27)
distXY=1.234 cacheHit=True
[resolve] in=(12.500,11.000,0.480) tgt=(12.500,11.100,0.480)
out=(12.500,11.000,0.480) ok=True hit=yes n=(0,0,1) walkable=True
```
`ACDREAM_DUMP_STEPUP=1` produced no `stepup: ENTER` lines, so
`DoStepUp` was NOT called. The hit normal `(0,0,1)` came from
somewhere else (likely the seeded walkable polygon or the synthetic
floor interaction with the engine's terrain step-down).
The apparatus's stub terrain (Z=-1000) + synthetic walkable poly at
Z=0 may be causing the engine to take a different code path than
production's real Holtburg terrain. Reproducing production fully
would require:
1. Real terrain heightmap covering the test landblock at Z=94
2. EnvCell or stab geometry near the test door
3. Proper cottage/cell setup so portal-reachable cells include
the door's outdoor cell when player is indoor
This is significant apparatus investment. Worth it IF the bug
requires multi-tick simulation in real geometry to surface. For
now, the apparatus shows the broad shape: with proper grounded
state + seeded body, the engine doesn't take the same path as
the airborne (Path 6) test.
---
## Recommended next steps (ranked)
### Option A — Live diagnostic with ACDREAM_DUMP_STEPUP=1 (cheapest)
Relaunch with `ACDREAM_PROBE_BUILDING=1` + `ACDREAM_DUMP_STEPUP=1`.
Walk into a closed door off-center. The step-up dump will show:
- Whether `DoStepUp` fires at all when the BSP hits
- If so, what the input normal is
- Whether `stepDown` succeeds or fails
If `stepDown` succeeds (i.e., step-up climbs over the door), we've
confirmed the hypothesis above and can target the fix.
### Option B — Build a richer apparatus
Replace the stub terrain with a real heightmap-like surface at Z=94
spanning the test landblock. Replace the synthetic walkable poly with
a proper terrain polygon at the door's world XY. This should let
Path 5 run the SAME way as production. Then iterate on the fix
locally in <500 ms.
Estimated effort: 1-2 hours of apparatus work.
### Option C — Direct retail cdb trace
Attach cdb to a running retail client at a Holtburg cottage doorway,
break on `CTransition::step_up` or `CTransition::step_down`, and
observe how retail handles step-up against a door. Compare against
acdream's behavior.
Estimated effort: 30 min - 2 hours depending on what we find.
### Option D — Pivot to fix-and-verify
Hypothesis-based fix: in `DoStepUp`, reject step-up if the input
collision normal is mostly horizontal AND the obstacle's bounding
sphere height range significantly exceeds the step-up height. The
door has BS radius 1.975 m centered at Z=1.275 → top of BS at Z=3.25,
way above step-up=0.6. If we detect "this obstacle is too tall to
step over," fall back to wall-slide.
Risk: might break stairs / ramps. Need apparatus to verify.
### Recommendation
Option A first (~5 min, no code changes needed). If hypothesis
confirmed, then Option D (with apparatus from Option B for
regression testing).
---
## Files touched this session (cumulative)
**Committed:**
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (dedup fix)
- `src/AcDream.App/Rendering/GameWindow.cs` (Task 7 wiring)
- `tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs` (NEW)
- `tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs` (NEW)
- `docs/research/2026-05-24-door-dat-inspection-findings.md` (NEW)
**Uncommitted (this doc + 2 file changes):**
- `src/AcDream.Core/Physics/TransitionTypes.cs` (added `[bsp-test]` probe)
- `tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs`
(added grounded test scenario — fails for unrelated apparatus
reasons but the probe wiring is sound)
**Memory updated:** `feedback_dedup_keys_after_cardinality_change.md`
---
## Pickup prompt for next session
```
A6.P4 Task 7 shipped (RegisterLiveEntityCollision uses
ShadowShapeBuilder + RegisterMultiPart) and the foundation fix
(GetNearbyObjects dedup on full ShadowEntry instead of entityId).
Production verification: center blocks, but off-center + inside-out
still walk through closed doors. The multi-part registration is
correct (verified by live probes); the remaining bug is downstream
in BSPQuery Path 5's step-up logic.
Read docs/research/2026-05-24-door-collision-task7-shipped-but-bug-remains.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 door collision — step-up misbehavior
investigation. Multi-part registration shipped;
step-up at thin tall obstacles is the remaining bug.
Recommended first move: Option A from the findings doc — relaunch
with ACDREAM_PROBE_BUILDING=1 + ACDREAM_DUMP_STEPUP=1, walk into
a Holtburg cottage door off-center. The step-up dump will reveal
whether DoStepUp is incorrectly succeeding for the door's BSP slab
hit (the leading hypothesis: DoStepDown finds the same flat floor
on the other side of the door, declaring step-up success).
DO NOT re-investigate the multi-part registration or GetNearbyObjects
dedup — both are confirmed working. Focus on the step-up path 5
behavior for thin tall obstacles.
```

View file

@ -1,258 +0,0 @@
# Door collision dat inspection — findings
**Date:** 2026-05-24 (evening, continuation of door collision investigation)
**Branch:** `claude/strange-albattani-3fc83c`
**Status:** Evidence gathered. Hypothesis A from
[`2026-05-24-door-collision-session-handoff.md`](2026-05-24-door-collision-session-handoff.md) **FALSIFIED**.
---
## TL;DR
A deterministic, read-only dat-inspection test
([`DoorSetupGfxObjInspectionTests.cs`](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs))
opens the real client dat and prints the raw state of door Setup
`0x020019FF` + its three referenced GfxObjs.
**Result — Hypothesis A is wrong.** Part 0 (`0x010044B5`) has a complete
1.925 m × 0.261 m × 2.490 m door-sized collision volume in the dat. Six
two-sided (`SidesType=Landblock`) physics polygons form the closed door
slab. Bounding sphere radius 1.975 m. Setup `Flags=HasPhysicsBSP`.
Parts 1, 2 (`0x010044B6`) **are** visual-only by design — `HasPhysics`
flag is clear, `PhysicsBSP` is null, `PhysicsPolygons.Count = 0`. **This
matches retail's `CPhysicsPart::find_obj_collisions`**
([`acclient_2013_pseudo_c.txt:275051`](../research/named-retail/acclient_2013_pseudo_c.txt)),
which explicitly short-circuits when `physics_bsp == 0`. So retail also
runs no collision against `0x010044B6` — and our skip-on-null-BSP
behavior is retail-faithful, not a bug.
**This rewrites the "next-session approach" recommendation in the prior
handoff.** The handoff said "if 0x010044B5's BSP has zero floor-touching
polys → Hypothesis A confirmed → pivot strategy." The BSP has six
collision polygons forming the whole door slab. The pivot is not needed;
we need to figure out why our integration of `0x010044B5`'s BSP didn't
fire during the Task 7 experiment.
---
## Raw dump (verbatim from the test)
```
=== Setup 0x020019FF ===
Flags = HasParent, AllowFreeHeading, HasPhysicsBSP (0x0000000D)
Radius = 0.1414
Height = 0.2000
StepUp = 0.0900
StepDown = 0.0900
CylSpheres = 0
Spheres = 1
[0] r=0.1000 origin=(0.000,0.000,0.018)
Parts = 3
[0] gfxObj=0x010044B5
[1] gfxObj=0x010044B6
[2] gfxObj=0x010044B6
PlacementFrames = 1
[Default] frameCount=3
frame[0] pos=(-0.006,0.125,1.275) rot=(0.000,0.000,0.000,1.000)
frame[1] pos=(0.710,0.000,1.210) rot=(0.000,0.000,0.000,1.000)
frame[2] pos=(0.710,0.247,1.210) rot=(0.000,0.000,1.000,0.000)
=== GfxObj 0x010044B5 === (the door slab — has physics)
Flags = HasPhysics, HasDrawing, HasDIDDegrade (0x0000000B)
HasPhysics = True
VertexArray = non-null, 8 vertices
PhysicsPolygons = 6 polys
PhysicsBSP = non-null
PhysicsBSP.Root = non-null
Root.Type = BPnN
Root.BoundingSphere = (-0.390,-0.056,-0.150) r=1.975
BSP tree total polys (including children) = 6
PhysicsPolygon AABB sweep (first 6 polys):
[0x0000] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(0.971,0.127,-1.236) # bottom face
[0x0001] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(-0.954,0.127,1.255) # left face
[0x0002] nVerts=4 sides=Landblock min=(-0.954,-0.134, 1.255) max=(0.971,0.127,1.255) # top face
[0x0003] nVerts=4 sides=Landblock min=( 0.971,-0.134,-1.236) max=(0.971,0.127,1.255) # right face
[0x0004] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(0.971,-0.134,1.255) # front face
[0x0005] nVerts=4 sides=Landblock min=(-0.954, 0.127,-1.236) max=(0.971,0.127,1.255) # back face
PhysicsPolygons combined AABB: min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255)
size=(1.925, 0.261, 2.490)
=== GfxObj 0x010044B6 === (the leaves — visual-only by design)
Flags = HasDrawing, HasDIDDegrade (0x0000000A)
HasPhysics = False
VertexArray = non-null, 40 vertices
PhysicsPolygons = 0 polys
PhysicsBSP = NULL
Polygons (visual) = 87 polys
DrawingBSP = non-null
```
---
## What this means
### The data is right
Part 0's BSP is a six-faced thin slab oriented as a vertical door:
1.925 m wide × 0.261 m thin × 2.490 m tall. Placed at frame[0] offset
`(-0.006, 0.125, 1.275)`, it occupies entity-local Z ∈ `[0.039, 2.530]`
a standard door height. All six faces are
`SidesType=Landblock` (two-sided collision — catches a sphere
approaching from either side).
This is exactly what retail's collision system uses to block doors.
No mystery, no missing data, no need to fall back to a wider Cylinder
approximation.
### The leaves are correctly visual-only
`0x010044B6` is the swinging door leaf (used twice — left + right
panels). It has no physics by retail design. Our `ShadowShapeBuilder`
skipping these parts matches both the dat and retail's
`CPhysicsPart::find_obj_collisions`.
### So the bug is in integration, not data
The previous session's Task 7 experiment registered `0x010044B5`'s BSP
correctly (we saw `type=BSP gfxObj=0x010044B5 radius=2.000
localPos=(-0.006,0.125,1.275)` in the per-shape registration), yet got
**zero `[resolve-bldg]` attributions** during live play. With the data
now confirmed good, that gap must be in:
1. **The BSP collision dispatch never enters for the door entry**
`TransitionTypes.cs:2257` silently `continue`s when
`engine.DataCache.GetGfxObj(obj.GfxObjId)?.BSP?.Root is null`. If the
GfxObj wasn't cached at collision time (race with renderer load), the
entry is invisibly skipped. **No log distinguishes this from
"queried-and-no-hit."**
2. **Broadphase placeholder radius** — Task 2's `ShadowShapeBuilder`
uses `bspRadius = 2f` as a stand-in pending a Task 5/6 caller
replacement. The real dat value is `1.975` — close enough not to be
the blocker, but the placeholder convention means callers MUST
substitute the real BS radius from `cache.GetGfxObj(gfxId).BoundingSphere.Radius`
before registering. If a future caller forgets, the broadphase will
still mostly work but won't be tight.
3. **The broadphase center is the part's FRAME origin, not the BSP's
bounding-sphere center.** Frame origin = `(-0.006, 0.125, 1.275)`;
BS center in part-local = `(-0.390, -0.056, -0.150)`. Distance:
1.48 m. The 2.0 m broadphase radius nominally covers the BS sphere
(radius 1.975) from the frame origin only on the side closest to the
BS center. For approaches on the opposite side, the broadphase
sphere extends 2.0 m + 1.48 m = 3.48 m from the BS center — wider
than needed, but never too tight in the door case. Still, a more
faithful encoding centers the broadphase on the part's BS center +
frame offset, with radius = BS radius.
4. **BSPQuery against `SidesType=Landblock` polys**`BSPQuery.cs`
pass-through-copies `SidesType` (line 2277) but doesn't filter on
it. We have not yet verified that `Landblock`-typed polys actually
produce collision hits in our query pipeline against a thin-slab
geometry. (Note: indoor cells use `SidesType=Single`-typed cell-floor
polys and those work — the cellar replay tests pin that. But Doors'
`Landblock` polys may behave differently — particularly w.r.t.
two-sided collision.)
5. **Entity rotation at the doorway** — Holtburg cottage doors face
non-cardinal directions. The entity's world rotation
`entity_rot` composes with `frame[0].Rotation` (identity for part 0)
to produce `obj.Rotation = entity_rot`. The sphere
transform `inv(entity_rot) * (sphere_world obj.Position)` is
sensitive to that rotation. If we register with identity (forgetting
to plumb the spawn's rotation through), the BSP polys will be
oriented "into the world" wrong — passing tests that approach from
the wrong axis.
---
## Recommended next step
The handoff's "DO NOT speculate-and-fix again" rule still applies. The
right next move is **apparatus-first**, not another implementation
attempt:
**Write a focused unit test** that:
1. Loads the real `0x010044B5` PhysicsBSP from the dat via the
inspection test's pattern (or use `GfxObjDumpSerializer.Hydrate`
for a deterministic fixture).
2. Constructs a synthetic door entity at a known world position
`(132.6, 17.1, 94.08)` with a known rotation (try identity AND a
~90° Z rotation to cover both axes).
3. Sweeps a player sphere at the door from each of the four
compass directions, at off-center positions (50 cm off-center)
AND dead center.
4. Calls `Transition.FindObjCollisions` / `ResolveWithTransition`
directly (apparatus path mirrors the live one).
5. Asserts:
- Dead-center approach → `Collided` / `Adjusted` / `Slid`
with `CollideObjectGuids` containing the door entity.
- 50 cm off-center approach → same.
- From inside walking out → same.
If the test fails: we have a deterministic reproduction of the live
bug in <500 ms, and we can fix the integration with confidence.
If the test passes: the door bug is elsewhere (cell registration,
spawn-time race, etc.).
This is the next apparatus the previous session was building toward
when it ran out of cycles. With the data question now closed by the
dat inspection, it's the highest-information next move.
---
## What's in the tree right now
```
$ git status --short
?? docs/research/2026-05-24-door-dat-inspection-findings.md
?? tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
[+ untracked launch logs from prior sessions]
```
Build green; existing tests still pass; new test runs in 34 ms and
produces the dump above.
---
## Pickup prompt for next session
```
Door collision dat inspection (2026-05-24 evening) FALSIFIED
Hypothesis A. Part 0 (0x010044B5) has a full door-slab BSP in the
dat — 6 Landblock-typed polys forming a 1.925 m × 0.261 m × 2.490 m
collision volume. Parts 1, 2 (0x010044B6) are visual-only by retail
design (HasPhysics flag clear). Retail and acdream both skip those
in CPhysicsPart::find_obj_collisions — that's not a bug.
Read docs/research/2026-05-24-door-dat-inspection-findings.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 — door collision investigation continues.
Per-part BSP infrastructure (Tasks 1-6) ships
already; data is confirmed good in the dat; need
to determine WHY our integration of 0x010044B5's
BSP didn't fire collisions during the Task 7
experiment.
Next moves (in order):
1. Write CellarUpTrajectoryReplay-style apparatus test that
loads 0x010044B5's PhysicsBSP from a dat dump, registers a
synthetic door via RegisterMultiPart, and sweeps a player
sphere at it. Confirm BSP collision fires (or doesn't) in
isolation.
2. If the test passes → bug is in live registration (likely
cell scoping, entity rotation, or race with renderer
loading). Investigate live cell membership for door
entities.
3. If the test fails → bug is in BSPQuery.FindCollisions
against thin-slab Landblock-typed polys. Investigate the
6-path dispatcher for that case.
DO NOT re-attempt Task 7 (per-part BSP wiring in
RegisterLiveEntityCollision) until the apparatus test confirms
the BSP works in isolation. Tasks 1-6 stay; they're correct.
```

View file

@ -1,328 +0,0 @@
# A6.P6 / A6.P7 — Door cylinder + slab interaction handoff
**Date:** 2026-05-25 PM
**Status:** A6.P5 cellSet fix shipped (3b1ae83). A6.P6 cyl step-over shipped
(3d4e63f). Residual symptom remains: sphere can't slide tangentially
past the door's foot cylinder when the cyl's radial collision normal
dominates the slide direction. Three fix options identified; user picked
"investigate retail first" — that's this session's work.
---
## TL;DR
Walking into a closed cottage door from outside in acdream:
| Before A6.P5 | After A6.P5 only | After A6.P6 too |
|---|---|---|
| Sphere walks through (cellSet didn't include door) | Sphere blocks BUT cyl phantom radial-pushes sphere AWAY from target (~10 cm push-out at door center) | Sphere stops at current position when cyl fires — no more push-out, but also can't slide tangentially past the cyl on some headings |
A6.P5 made the door reliably visible from all approach angles (closed
the cellSet bug); A6.P6 routed Contact-grounded cyl collisions through
step-over instead of radial push. Both retail-anchored. But the residual
"can't slide past cyl on certain headings" still happens because:
1. The door has two collision shapes: a tiny foot cylinder (r=0.10,
h=0.20) and the big slab BSP.
2. Our FindObjCollisions tests shapes in registration order. The cyl
gets tested FIRST. When cyl fires, FindObjCollisions returns
immediately — slab BSP never tested in that iteration.
3. The cyl's collision normal is radial (away from cyl axis). For a
sphere wanting to move SE past a door at world (132.6, 17.1), the
cyl-radial normal is roughly (0.86, 0.51, 0). The slide tangent
from that normal points mostly south — INTO the slab. Slab then
blocks (in a downstream iteration). Net: sphere doesn't move.
4. If the slab's clean (0, +1, 0) normal were used instead, the slide
tangent would be pure east. Sphere would slide cleanly along the
door. This is what retail does visibly.
So the question is: how does retail end up with the slab's normal
driving the slide, when retail also has the cyl AND tests it?
---
## What today shipped (DO NOT redo this)
### A6.P5 — cellSet portal expansion fix (commit 3b1ae83)
- File: `src/AcDream.Core/Physics/CellTransit.cs`
- Function: `FindTransitCellsSphere` exit-portal branch + `BuildCellSetAndPickContaining`
- Change: exit portals contribute `exitOutside = true` by topology, not by sphere-plane overlap.
- Retail anchor: `CObjCell::find_cell_list` at `acclient_2013_pseudo_c.txt:308742-308869`.
- Tests: `CellTransitTests.A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell` + `A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell`. Both pass.
- Fixture: `tests/AcDream.Core.Tests/Fixtures/door-bug/over-penetration-capture.jsonl` (3 records from the 17 MB live capture).
### A6.P6 — cyl step-over for Contact movers (commit 3d4e63f)
- File: `src/AcDream.Core/Physics/TransitionTypes.cs`
- Function: `CylinderCollision` — added Contact-grounded branch
- Change: when `oi.Contact && !sp.StepUp && !sp.StepDown && engine != null` and cyl height fits step-up-height, attempt `DoStepUp(collisionNormal, engine)`. On failure → `StepUpSlide(this)`. On step-fail, behavior changes from radial push to tangent-along-crease.
- Retail anchor: `CCylSphere::intersects_sphere` at `acclient_2013_pseudo_c.txt:324626-324641` (Contact branch dispatches `step_sphere_up`) + `CCylSphere::step_sphere_up` at `acclient_2013_pseudo_c.txt:324516-324538`.
- Tests: all `A6P5_*` + `Path 5` tests + door directional tests pass in isolation. Full Core suite 17 failures (same as A6.P5 baseline) — diff is documented static-leak flakiness.
### Probes added (still in place — useful for next session)
- `ACDREAM_PROBE_CELLSET=1``[cellset-build]` line per `BuildCellSetAndPickContaining` call.
- `ACDREAM_PROBE_BUILDING=1``[cyl-test]` + `[bsp-test]` (existing).
- `ACDREAM_PROBE_RESOLVE=1``[resolve]` (existing).
- `ACDREAM_CAPTURE_RESOLVE=<path>` → JSONL capture for replay.
### Captures from today (gitignored, on disk)
- `door-stuck-capture.jsonl` (17 MB, 8483 records) — the original phantom reproduction.
- `door-phantom-capture.jsonl` (13 MB, ~7000 records) — captured with cyl/bsp probes ON post-A6.P5.
- `door-a6p6-v2.launch.log` (UTF-16) + `door-a6p6-v2.utf8.log` — most recent diagnostic launch with all 3 probes on after A6.P6 fix landed. Shows residual cyl phantom (12+ resolves with cn=(0.86, 0.51, 0) attributed to door entity 0x000F4245).
---
## The remaining symptom (what to fix)
User walks into a closed cottage door (Setup 0x020019FF, entity at
world ≈ (132.6, 17.1, 94.1)). When the sphere ends up at certain
angles to the door (NE / SE of the cyl center), the cyl's slide
"blocks" the sphere from making tangential progress along the slab
face.
Specific evidence from `door-a6p6-v2.utf8.log` (line ~23553):
```
[resolve-bldg] obj=0x000F4245 ... hitPoly: plane=(0.000,0.000,-1.000,-1.236) ← slab BOTTOM hit, but culled (no Z motion)
[cyl-test] obj=0x000F4245 ... result=Slid ← cyl fired
[resolve] in=(132.777,17.724) tgt=(133.044,17.400) out=(132.777,17.724)
hit=yes n=(0.86,0.51,0.00) obj=0x000F4245 nObj=9
```
The cn=(0.86, 0.51, 0) is the cyl's radial normal (sphere is NE of cyl
axis). The slide direction is perpendicular = (0.51, -0.86, 0) ≈ mostly
south = into the slab. Slab blocks in subsequent iteration. Net: out == in.
Counts from the latest launch (~7K resolves):
- 117 hit=yes attributed to door entity 0x000F4245
- 99 hit=yes attributed to cottage GfxObj 0xA9B47900
- 350 cyl-tests result=Slid (out of 1623 total cyl tests)
- 12 resolves with cn=(0.86, 0.51, 0) on the door — the "phantom slide direction" pattern
---
## The three options (user picked #2-investigation first)
### Option 1: BSP-first per-entity test order (smallest fix)
Within an entity's shapes, test BSP shapes before Cylinder shapes. If
BSP fires, skip the cyl. The slab's clean (0, ±1, 0) normal drives the
slide → sphere slides smoothly along door face.
- ~10 lines in `FindObjCollisions` (sort `nearbyObjs` per-entity).
- Retail-faithful behaviorally; whether it's retail-faithful
architecturally is uncertain (see Option 2 research).
### Option 2: Port retail's per-physobj dispatch (architectural)
Restructure `ShadowObjectRegistry` to group shapes by entity. Implement
retail's `CPhysicsObj::FindObjCollisions` dispatch including the
`state & 0x10000` branch logic (acclient_2013_pseudo_c.txt:276861).
- Large change; touches many files.
- True retail-faithful architecture. **But** behaviorally may end up
producing the same outcome as Option 1 if our state flag mapping
is correct.
### Option 3: Door-cyl-as-informational
Hypothesis: retail's door cyl is for click-target / sound trigger /
foot-slip prevention for non-player entities, NOT a physics blocker
for the standard player. Skip registering it as a collision shape on
entities that also have a BSP.
- Needs retail research to confirm.
- Risk: breaks foot-slip prevention for small entities.
---
## Retail investigation needed (THIS SESSION's main work)
The fundamental question: **what does retail do with the door's cyl
that produces clean sliding past it?** Two specific things to read +
test:
### Investigation 1: What does `state & 0x10000` mean?
Retail's `CPhysicsObj::FindObjCollisions` at
`acclient_2013_pseudo_c.txt:276861`:
```c
if (((this->state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0) {
// iterate cylspheres + spheres
} else {
// iterate BSP parts via CPartArray::FindObjCollisions
}
```
Door state at spawn = `0x00010008`. Bit 0x10000 (bit 16) IS set. So
condition `state & 0x10000 == 0` is FALSE. The branch depends on
`ebp_1` and `eax_12`.
**Investigation steps:**
1. Grep `acclient_2013_pseudo_c.txt` for what assigns to `ebp_1` and
what `eax_12` is computed from. Identify which mover/target state
bits drive the branch.
2. Search `docs/research/named-retail/acclient.h` for state flag bit
definitions (look for constants `0x10000`, `OBJECT_USES_PHYSICS_BSP`
or similar around the OBJECTINFO / PhysicsObj state field).
3. Determine which branch fires for: closed door (state 0x10008) +
grounded player.
4. If cyl branch fires for our case: how does retail block player
from passing through the door without the BSP test?
5. If BSP branch fires: why? What state condition is off in our
replica?
Cross-reference with ACE's `PhysicsObj.FindObjCollisions`
`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs`. ACE might
have cleaner names for the same logic.
### Investigation 2: What does the door's cyl actually DO in retail?
Concrete experiment using cdb on the live retail client:
1. Attach cdb to retail acclient.exe (toolchain in CLAUDE.md "Retail
debugger toolchain" section).
2. Set breakpoint on `CCylSphere::collides_with_sphere` (acclient
address 0x53a880) with action: log entity id + sphere position +
result. Use `qd` after ~5000 hits to detach.
3. Walk retail player into a closed cottage door from outside,
trying to slide along it.
4. Capture trace. Look for:
- Does the door cyl ever fire `collides_with_sphere` returning 1?
If yes → cyl IS active in retail.
- If no → cyl is somehow excluded from physics in retail (Option 3
plausible).
5. Set breakpoint on `BSPTREE::find_collisions` for the same scenario.
Determine if BSP slab is tested.
### Investigation 3: Inspect Setup parsing differences
Compare what our `ShadowShapeBuilder.FromSetup` produces from
`Setup 0x020019FF` vs what retail's PhysicsObj constructs from the
same Setup:
1. `dotnet test --filter "FullyQualifiedName~DoorSetupGfxObjInspectionTests"
--logger "console;verbosity=detailed"` for our parse.
2. Inspect retail's PhysicsObj creation flow (acclient.exe around the
PhysicsObj constructor + part_array initialization). Look for
filtering: does retail include the Setup's cyl in its physics shape
list, or is there a flag-driven include/exclude?
---
## Files to read FIRST next session
| File / location | What to find |
|---|---|
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:276776+` | `CPhysicsObj::FindObjCollisions` (the dispatch + state flag branch) |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:324558` | `CCylSphere::intersects_sphere` (the per-cyl dispatch for state & 3) |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:324516` | `CCylSphere::step_sphere_up` (our A6.P6 anchor; verify our port matches) |
| `docs/research/named-retail/acclient.h` | OBJECTINFO state bit constants (esp. `0x10000`) |
| `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | ACE's port — cleaner names |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:308916` | `CObjCell::find_obj_collisions` (per-cell shadow iteration, calls CPhysicsObj::FindObjCollisions) |
---
## Tests to keep green (do NOT regress)
Run these in isolation when verifying any new fix:
```bash
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build -c Debug --filter "FullyQualifiedName=AcDream.Core.Tests.Physics.CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.CornerSlide_AlcoveEastToCottageNorth_ShouldBlock|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Geometric_DoorSlabAtSphereHeight_OverlapsInZ|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.InsideOut_Tick3254_WithCottageWalls_ShouldBlock|FullyQualifiedName~BSPQueryTests.FindCollisions_Path5|FullyQualifiedName~CellTransitTests.A6P5|FullyQualifiedName~DoorCollisionApparatusTests.Apparatus_DeadCenter"
```
Expected: all 14 pass.
Full Core suite has 17 documented flaky-in-full-run failures — those
are the static-leak flakiness CLAUDE.md describes, not regressions.
---
## Things NOT to do (do-not-retry list)
1. **Don't reverse cyl/BSP iteration order globally.** Cross-entity
ordering should follow registration sequence (matches retail per-cell
shadow_object_list). Only within-entity ordering needs adjustment.
2. **Don't disable the door cyl unconditionally.** Foot-slipping
matters for small entities even if not for the player.
3. **Don't enlarge `EPSILON` in slide-back-off math** to "give more
margin." The 11mm residual penetration is a separate issue
(`SlideSphere` preserves `currPos.Y` which may already be slightly
penetrating); changing epsilon would mask other bugs.
4. **Don't add per-call workarounds in `CylinderCollision`** (like
"if entity has a sibling BSP, return OK"). Per CLAUDE.md no-workarounds
rule — fix the architectural issue, not the symptom.
5. **Don't break A6.P6 step-over for non-door cyls** (tree trunks, rock
pillars, NPCs). Whatever fix lands must keep cyl-only entities
blocking correctly.
---
## Open issue tracking
Add to `docs/ISSUES.md` after this handoff:
```
- door-cyl-residual-block: After A6.P5 + A6.P6, sphere can still be
blocked at NE/SE headings approaching a closed cottage door because
the cyl's radial collision normal drives the slide direction into
the slab. Three fix options outlined in
docs/research/2026-05-25-a6-door-cyl-investigation-handoff.md;
pending retail investigation to pick the retail-faithful path.
Severity: M1.5 polish (does not block "kill a drudge" demo).
```
---
## Pickup prompt for next session
```
A6.P6 / A6.P7 — door-cyl residual block investigation.
Read first (in this order):
1. docs/research/2026-05-25-a6-door-cyl-investigation-handoff.md
(full context: what landed, what's still broken, the 3 fix options,
do-not-retry list)
2. docs/research/named-retail/acclient_2013_pseudo_c.txt:276776
(CPhysicsObj::FindObjCollisions — the state-flag dispatch)
3. docs/research/named-retail/acclient_2013_pseudo_c.txt:324558
(CCylSphere::intersects_sphere — the cyl dispatch)
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P7 — retail investigation for door cyl + slab
collision interaction.
The session's main work: retail investigation. NOT implementation.
Specific questions to answer (cite retail line numbers in the report):
1. What does state bit 0x10000 mean? Closed cottage doors have it
set (state = 0x00010008). Retail's FindObjCollisions branches on
`((state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0`. What are
ebp_1 and eax_12? Which branch fires for a closed door + grounded
player? (Cross-reference references/ACE/Source/ACE.Server/Physics/
PhysicsObj.cs for cleaner names.)
2. Does the door cyl actually fire collides_with_sphere in retail
when player slides along the door? Set a cdb breakpoint on
CCylSphere::collides_with_sphere (acclient address 0x53a880),
walk a retail player into the cottage door, observe. If cyl
fires: how does retail produce smooth sliding past it? If cyl
doesn't fire: by what mechanism is it excluded?
3. Compare our ShadowShapeBuilder.FromSetup output vs retail's
PhysicsObj shape list for Setup 0x020019FF. Where do they
diverge?
Deliverable: a short report (~2-3 pages) covering the 3 questions with
retail line numbers + cdb trace excerpts. Then propose which of the
3 fix options (BSP-first per-entity / per-physobj dispatch port /
door-cyl-informational) is the most retail-faithful, justified by
the research.
DO NOT implement the fix this session — the brainstorming-only
discipline applies. After the report, the next session will pick
the implementation approach + execute via writing-plans → executing-plans.
Do-not-retry list (in handoff doc) — read it before starting.
Tests to keep green if any code changes happen: see handoff doc.
Reproduction setup ready to relaunch with diagnostics if needed:
ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELLSET=1
ACDREAM_CAPTURE_RESOLVE=<path>.jsonl
```

View file

@ -1,319 +0,0 @@
# A6.P7 — Retail dispatch investigation for door cyl + slab interaction
**Date:** 2026-05-25 PM
**Mode:** Report-only (per `/investigate` skill). No code edits.
**Predecessor:** [`2026-05-25-a6-door-cyl-investigation-handoff.md`](2026-05-25-a6-door-cyl-investigation-handoff.md)
---
## TL;DR — the smoking gun
**Retail's `CPhysicsObj::FindObjCollisions` dispatches BINARILY between
"BSP-only" and "cyl + sphere" — never both.** The selector is the state
bit `HAS_PHYSICS_BSP_PS = 0x10000` (named verbatim in the retail header).
For a closed cottage door + walking player:
- Door state `0x10008` has `HAS_PHYSICS_BSP_PS` set.
- Player isn't a missile.
- Player isn't a PvP-eligible target of the door.
- → Retail goes to the **BSP-only branch**. **The cyl is never tested.**
Acdream tests both because our dispatch iterates per `ShadowEntry`
(cyl and BSP are separate entries). The residual phantom slide at
NE/SE headings is the predictable consequence: the cyl's radial normal
fires first, drives the slide tangent into the slab face, slab blocks
in a downstream sub-tick, net out=in.
The fix is **~15 LOC at the per-entry test site**, reading
`obj.State & 0x10000u` (which is already populated on every
`ShadowEntry` from ACE's `spawn.PhysicsState`). It is **NOT** an
architectural restructuring of `ShadowObjectRegistry`. The handoff's
"Option 2 = large change" assessment was wrong — Option 2 is the
right answer, but its scope is dramatically smaller than the handoff
feared.
---
## Question 1 — What is `state & 0x10000`? Which branch fires?
**Named flag:** `HAS_PHYSICS_BSP_PS = 0x10000`
[`docs/research/named-retail/acclient.h:2833`](research/named-retail/acclient.h:2833).
The full retail `PhysicsState` enum lives at lines 2815-2843. The flags
implicated by the dispatch:
| Bit | Name | Meaning |
|---|---|---|
| 0x4 | `ETHEREAL_PS` | Non-solid; passes through other objects |
| 0x10 | `IGNORE_COLLISIONS_PS` | Skips collision processing entirely |
| 0x40 | `MISSILE_PS` | Object is a projectile / arrow / spell in flight |
| 0x10000 | `HAS_PHYSICS_BSP_PS` | Object exposes a per-Setup BSP collision mesh |
**Branch logic from
[`acclient_2013_pseudo_c.txt:276861`](research/named-retail/acclient_2013_pseudo_c.txt):**
```c
if (((this->state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0)
{
// CYL + SPHERE path (lines 276863-276953):
// iterate part_array's CylSpheres → CCylSphere::intersects_sphere
// fall through label_50f21d:
// iterate part_array's Spheres → CSphere::intersects_sphere
// (BSP is NEVER tested in this branch)
}
else
{
// BSP path (lines 276956-276985):
state_3 = CPartArray::FindObjCollisions(part_array, transition);
// (cyl + sphere are NEVER iterated in this branch)
}
```
**What `ebp_1` and `eax_12` are:**
- `ebp_1` is set at lines 276808-276841. It's non-null **only when**
THIS object's weenie is a player AND the moving transition's
ObjectInfo has the IsPlayer flag AND no PvP exclusion (IsPK match,
IsPKLite match, IsImpenetrable). Effectively: "I am a player and the
incoming mover is also a player I can collide with."
- `eax_12` is `OBJECTINFO::missile_ignore(transition, this)`
[`acclient_2013_pseudo_c.txt:274385`](research/named-retail/acclient_2013_pseudo_c.txt:274385).
Returns non-zero when the moving object is a missile that should
ignore this target. For a walking player vs door: returns 0.
**For our scenario (player walking vs closed door):**
- `state & 0x10000 == 0`: FALSE (door has the bit set).
- `ebp_1 != 0`: FALSE (door is not a player target).
- `eax_12 != 0`: FALSE (walking isn't a missile).
- Condition is FALSE → **ELSE branch fires → BSP-only path.**
The retail client **never calls `CCylSphere::intersects_sphere` on the
door's foot cylinder** when a non-missile, non-PvP mover walks into it.
**ACE cross-reference confirms the truth table exactly.**
[`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412-450`](../../references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs):
```csharp
if (!State.HasFlag(PhysicsState.HasPhysicsBSP) || missileIgnore || exemption)
{
// cyl-then-sphere iteration
}
else if (PartArray != null)
{
var collided = PartArray.FindObjCollisions(transition); // BSP path
// ...
}
```
ACE names the flag `HasPhysicsBSP`; the local variables are `missileIgnore`
(retail's `eax_12`) and `exemption` (retail's `ebp_1`). The structure is
identical bar a `// TODO: reverse this check to make it more readable`
comment at [`PhysicsObj.cs:401`](../../references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:401)
confirming ACE faithfully transcribed the negated predicate without
adding interpretation.
**Verdict on Q1: the cyl is not tested in retail for our case. Bit
0x10000 means "this object has a BSP — use it exclusively, do not
test the cyl/sphere proxies".**
---
## Question 2 — Does retail's cyl actually fire `collides_with_sphere`?
**Answer derivable from Q1 without running cdb: NO.** The retail dispatch
unambiguously routes a closed-door + walking-player call to
`CPartArray::FindObjCollisions` (the BSP path). The function
`CCylSphere::collides_with_sphere` is reached only via the cyl-and-sphere
path; that path is dead code for our scenario.
A cdb trace would confirm zero hits on `CCylSphere::collides_with_sphere`
for our scenario — but the decomp + ACE agreement is sufficient
evidence to skip the trace. The branch condition is fully resolved by
inspection.
If we wanted defensive verification (recommended only if a fix attempt
fails on first land), the live-trace recipe is:
```
bp acclient!CCylSphere::collides_with_sphere "r $t0=@$t0+1; gc"
bp acclient!CPartArray::FindObjCollisions "r $t1=@$t1+1; gc"
```
Walk into the cottage door from outside for ~10 seconds. Expected:
`@$t0 == 0` (cyl never tested), `@$t1` non-zero. This would settle
the question definitively, but is not blocking the fix.
---
## Question 3 — Compare our ShadowShapeBuilder vs retail's Setup parsing
**Retail STORES both cyl and BSP** for a door whose Setup has both.
The cyl + sphere primitives live in `CPartArray::cylspheres` /
`CPartArray::spheres`, the BSP is per-Part. Retail does not filter at
the storage layer; it filters at the **dispatch** layer via the
`HAS_PHYSICS_BSP_PS` flag.
**Our `ShadowShapeBuilder.FromSetup`** at
[`src/AcDream.Core/Physics/ShadowShapeBuilder.cs:41-110`](../../src/AcDream.Core/Physics/ShadowShapeBuilder.cs)
does the same — emits both a Cylinder shape and per-Part BSP shapes
for Setup `0x020019FF`. **This is correct.** The bug isn't in
registration; it's in dispatch.
**Where we diverge:**
| Step | Retail | Acdream |
|---|---|---|
| Storage | One `CPartArray` per `CPhysicsObj`; cyls + spheres + BSP parts all stored | Flat `ShadowEntry` rows in `_cells[cellId]`; one row per shape, no per-entity grouping at the cell layer |
| Dispatch trigger | `CPhysicsObj::FindObjCollisions` called once per shadow object (per-cell iteration) | `Transition.FindObjCollisions` iterates every `ShadowEntry` in `nearbyObjs` |
| Cyl-vs-BSP branch | Binary on `state & 0x10000` | None — every shape is tested |
| Effect on door | Only BSP tested → clean slab-normal slide | Cyl tested first → radial-normal drives slide into slab |
**Critical observation:** The retail state bit is already on every
acdream `ShadowEntry.State` (uint field), populated at
[`GameWindow.cs:3156`](../../src/AcDream.App/Rendering/GameWindow.cs:3156)
from `spawn.PhysicsState ?? 0u` — ACE delivers it on the wire.
Confirmed via direct check: the door test fixtures
([`DoorBugTrajectoryReplayTests.cs:61`](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:61),
[`DoorCollisionApparatusTests.cs:371`](../../tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs:371))
all seed the door with `0x10008u` (= `STATIC_PS | REPORT_COLLISIONS_PS |
HAS_PHYSICS_BSP_PS`). The bit is available — we just don't read it.
---
## Mapping to the three fix options
| Option | Retail-faithful? | Verdict |
|---|---|---|
| **#1 — BSP-first per-entity test order** | NO. Retail isn't "BSP-first"; it's "BSP-only when 0x10000 set." Per-entity ordering would also test the cyl for tree trunks (no BSP) which is correct — but would still test the cyl for doors, which retail doesn't. | Reject. |
| **#2 — Port retail's per-physobj dispatch** | **YES.** This is exactly what retail does. The handoff's scoping ("touches many files; large change") was based on a misread of what Option 2 requires — it does NOT require restructuring `ShadowObjectRegistry` to group shapes by entity. The retail check is per-shape on a state flag already present. | **RECOMMENDED.** ~15 LOC at the per-entry dispatch site. |
| **#3 — Door-cyl-as-informational (skip cyl registration when entity has BSP)** | NO. Retail STORES both shapes in `CPartArray` — registration includes both. Filtering at registration would diverge from retail's data model and risk breaking missile / PvP paths that need the cyl. | Reject. |
The handoff's option-2 worry about "restructure `ShadowObjectRegistry`
to group shapes by entity" is overengineered. The retail check is
local to each shape's `ShadowEntry.State`:
```text
For each ShadowEntry obj in nearbyObjs:
if obj is BSP and (obj.State & HAS_PHYSICS_BSP_PS) is unset, skip (impossible — BSP entries on entities WITH 0x10000 don't need a check; we just need to ensure they DO fire)
if obj is Cylinder/Sphere and (obj.State & HAS_PHYSICS_BSP_PS) is SET and not pvp-target and not missile-ignored, skip
```
Effectively: **suppress cyl/sphere tests when the entity has BSP.**
Implemented as a single `continue` guard inside the existing loop at
[`TransitionTypes.cs:2313`](../../src/AcDream.Core/Physics/TransitionTypes.cs:2313).
No data-structure change. No grouping pass. No new fields.
---
## Recommended next step
**Approve the implementation of a retail-binary dispatch** at the
per-entry site in `Transition.FindObjCollisions`. The fix has these
properties:
1. **Site:** [`src/AcDream.Core/Physics/TransitionTypes.cs:2313`](../../src/AcDream.Core/Physics/TransitionTypes.cs:2313)
(the `if (obj.CollisionType == ShadowCollisionType.BSP) ... else ...`
dispatch).
2. **Guard:** add a continue at the cyl/sphere branch when
`(obj.State & HasPhysicsBspPs) != 0 && !isPvpTarget && !missileIgnore`.
For M1.5 polish we can treat both `isPvpTarget` and `missileIgnore`
as `false` (no PK, no missiles in scope) and add `// TODO: wire
when PK / missiles ship` comments. The simplified guard is
`(obj.State & 0x10000u) != 0`.
3. **Companion constant:** add `HasPhysicsBsp = 0x10000u` to
`PhysicsStateFlags` ([`PhysicsBody.cs:25-43`](../../src/AcDream.Core/Physics/PhysicsBody.cs:25)) —
it's currently absent. Naming matches both retail (`HAS_PHYSICS_BSP_PS`)
and ACE (`HasPhysicsBSP`).
4. **Existing tests that would change outcome under the fix:**
- [`DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug`](../../tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs:213)
is in "documents the bug" form — its header comment at lines
285-298 explicitly says "When the fix lands, flip this to
`Assert.True(blocked)`." Fix lands → invert assertion in same
commit.
- Apparatus dead-center + back-approach tests — should remain
PASS (BSP still fires).
- `DoorBugTrajectoryReplayTests` LiveCompare tests — should
remain PASS (BSP-only behavior is closer to live capture).
- `CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap`
— unrelated path (#98 cottage-floor cap). Unaffected.
- `BSPQueryTests.FindCollisions_Path5_*` — unrelated; tests
`BSPQuery` internals not dispatch. PASS.
- `CellTransitTests.A6P5_*` — unrelated. PASS.
5. **Risks:**
- **Foot-slipping for small entities on the door cyl.** Retail
doesn't have this concern because retail's cyl isn't tested on
the door for the standard mover either — so we won't regress
anything that retail does. If a future fix needs cyl-vs-cyl for
a small dynamic entity (e.g. a chicken bumping the door), that's
a separate problem solved per `MISSILE_PS` / `ebp_1` rules, which
ours already approximate via `CollisionExemption`.
- **Other entities with `0x10000`.** Cottage walls (the static
landblock GfxObj `0xA9B47900`) likely have `HAS_PHYSICS_BSP_PS`
and only register BSP shapes (no cyl) — fix is a no-op there.
NPCs and players have no BSP, no `0x10000`, so the cyl path
continues firing for them — desired.
- **Verification:** run the existing test list from the handoff
(14 tests) post-fix; rerun live launch with all three probes;
expect zero `[cyl-test] obj=0x000F4245` lines in the log.
---
## Verification plan (post-fix)
When the fix lands, a single live launch + 14-test green list is
sufficient verification. The `door-a6p6-v2.utf8.log` showed:
- 117 `hit=yes obj=0x000F4245` resolves
- 350 `[cyl-test] result=Slid` (across all entities)
- 12 phantom `cn=(0.86, 0.51, 0)` resolves attributed to the door
Post-fix expectation in an equivalent capture:
- Door cyl-test count attributed to `obj=0x000F4245`: **0**
- Door BSP `[bsp-test]` calls: unchanged or slightly higher (no
cyl short-circuit)
- `cn=(0.86, 0.51, 0)` phantom on the door: **0**
- Visual confirmation: smooth slide along door slab face from
NE/SE approach.
---
## What this is NOT
- This is **NOT** a recommendation to restructure `ShadowObjectRegistry`.
The flat per-cell list is fine. The retail check is per-shape, not
per-entity.
- This is **NOT** an Option 1 ("BSP-first ordering") fix. Retail does
binary selection, not reordering.
- This is **NOT** an Option 3 ("don't register cyl") fix. Retail
registers both shapes.
- This is **NOT** related to A6.P6's `CCylSphere::step_sphere_up`
port (commit `3d4e63f`). That port is correct — it just doesn't
fire for the door because the cyl is never reached. A6.P6 remains
useful for non-door cylinders (tree trunks, rock pillars).
- This is **NOT** related to the cdb workflow being insufficient — we
could trace it for confirmation but the decomp + ACE agreement makes
inspection sufficient.
- **The cottage-floor cap (#98) is unrelated.** This bug is in entity
collision dispatch; #98 is in cell BSP / GfxObj polygon evaluation.
---
## Citations
| Source | Line(s) | What |
|---|---|---|
| `docs/research/named-retail/acclient.h` | 2815-2843 | `enum PhysicsState``HAS_PHYSICS_BSP_PS = 0x10000` at 2833 |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276776-276996 | `CPhysicsObj::FindObjCollisions` |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276861 | Binary dispatch branch |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276808-276841 | `ebp_1` (PvP-target-player flag) setup |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 274385-274410 | `OBJECTINFO::missile_ignore` |
| `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | 381-454 | ACE's `FindObjCollisions` |
| `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | 412-450 | ACE's binary dispatch (cleaner names) |
| `references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs` | 14, 24 | ACE's `Missile = 0x40` + `HasPhysicsBSP = 0x10000` |
| `src/AcDream.Core/Physics/TransitionTypes.cs` | 2189-2521 | Our `FindObjCollisions` |
| `src/AcDream.Core/Physics/TransitionTypes.cs` | 2313 | Our per-shape dispatch site |
| `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | 41-110 | Our `FromSetup` (emits both shapes — correct) |
| `src/AcDream.App/Rendering/GameWindow.cs` | 3156 | Where `spawn.PhysicsState` lands on `ShadowEntry.State` |
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | 587 | `ShadowEntry.State : uint` field |
| `src/AcDream.Core/Physics/PhysicsBody.cs` | 25-43 | `PhysicsStateFlags` (currently missing `HasPhysicsBsp`) |
| `tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs` | 213, 285-298 | The "documents the bug" fixture; flip-assertion guidance |

View file

@ -1,176 +0,0 @@
# Door bug — retail cdb trace + NegPolyHit dispatch findings
2026-05-25, continuation of door-collision investigation
## TL;DR
cdb attached to retail at a Holtburg cottage door while user walked the
inside-out off-center scenario. The smoking-gun trace identified the
real collision-recording function: **`SPHEREPATH::set_neg_poly_hit`**
fired hundreds of times during the walk; `SPHEREPATH::set_collide`,
`COLLISIONINFO::set_collision_normal`, `set_sliding_normal`,
`add_object` ALL fired zero times.
In our codebase, `NegPolyHitDispatch` exists but **is never called
from any production code path** — it's dead code. The `path.NegPolyHit`
flag is therefore never set. The downstream handler in
`Transition.TransitionalInsert` was a stub that just cleared the flag.
Two-part fix attempted this session:
1. **`BSPQuery.FindCollisions` Path 5** (Contact branch) restructured
to call `NegPolyHitDispatch` when sphere 0 had a near-miss polygon
set but didn't fully penetrate (mirrors retail's `var_5c != 0` case
at `acclient_2013_pseudo_c.txt:0053a6ce-0053a6fb`).
2. **`Transition.TransitionalInsert` NegPolyHit handler** rewritten
to dispatch to `step_up + step_up_slide` (NegStepUp=true) or
record collision normal + return `Collided` (NegStepUp=false).
**Result: fix doesn't fully close the bug.** User still squeezes
through. Diagnostic `[neg-poly-dispatch]` probe shows ZERO hits in
production — the BSP Path 5 changes don't surface NegPolyHit for this
case.
## Why the fix doesn't fire
Retail's `BSPTREE::find_collisions` calls
`vtable->sphere_intersects_poly(localspace_sphere, var_78_6, var_74_6, var_70_8)`
which:
- **Returns `eax_10`**: non-zero on full sphere-vs-poly hit
- **Writes `var_5c`**: closest polygon pointer, set EVEN ON
NEAR-MISS (BSP traversal sets it when entering a leaf containing
candidate polys, regardless of intersection)
So retail records "near miss" polygons during BSP traversal. The
caller dispatches `set_neg_poly_hit(1, var_5c + 0x20)` when sphere 0
returned `eax_10 == 0` but `var_5c != 0`.
Our `SphereIntersectsPolyInternal` only sets `hitPoly` on actual
hits. Near-miss polygons are NOT recorded. So the Path 5 branch
`if (hitPoly0 is not null)` is false → no `NegPolyHitDispatch` call
→ no NegPolyHit set → no dispatch in TransitionalInsert.
## The deeper fix needed
Implement retail's "BSP traversal records closest near-miss polygon"
behavior in `SphereIntersectsPolyInternal` (or a sibling). The
function should return TWO outputs:
- `bool hit` — true if sphere fully penetrates a polygon
- `ResolvedPolygon? closestPoly` — set during traversal to the
polygon that the sphere came closest to (in the BSP node walk),
regardless of whether the full intersection test passed
This requires modifying the BSP recursion to track the "closest
considered" polygon. Retail's sphere_intersects_poly likely tracks
this as a side effect of testing each candidate polygon during the
traversal.
Once that's in place, the existing Path 5 changes + TransitionalInsert
NegPolyHit dispatch should fire correctly and produce the block.
## Second symptom flagged by user (2026-05-25 evening)
User flagged: "we get run a bit into the door as well when it blocks.
That is not retail behavior."
Over-penetration before block = our BSP detects collision AFTER the
sphere has already moved into the surface (static overlap detection)
vs retail's swept-sphere collision (predicts the t-value of first
contact along the motion path and stops the sphere at the surface).
This is the SAME ROOT MECHANISM as the squeeze-through:
sphere_intersects_poly in retail does swept collision with the
motion vector (var_44 = sphere_center - prev_center). Our
`SphereIntersectsPolyInternal` takes a `movement` parameter but the
internal poly-test logic may not actually use it for swept detection.
Verifying: read SphereIntersectsPolyInternal and check whether it
uses the `movement` vector for swept-sphere-vs-poly intersection
testing (computes the t-value where sphere first contacts the poly
along motion), or just does static overlap (sphere center +/- radius
overlaps poly plane). Retail does swept (the `var_44` in
sphere_intersects_poly is the motion delta).
Single fix needed in next session: SphereIntersectsPolyInternal needs to:
1. Implement swept-sphere-vs-poly detection (use the motion vector)
2. Record the closest-considered polygon for near-miss handling
Both feed into the existing Path 5 + TransitionalInsert dispatch
(committed today). Once that single function does its job correctly,
both symptoms close at once.
## What the cdb trace proved
| Symbol | v1 hits | v2 hits | v3 hits |
|---|---|---|---|
| `CPhysicsObj::FindObjCollisions` | 161,081 | 196,608 | 196,608 |
| `CCylSphere::collides_with_sphere` | 35,527 | — | — |
| `SPHEREPATH::set_collide` | **0** | — | — |
| `COLLISIONINFO::set_collision_normal` | — | **0** | — |
| `COLLISIONINFO::set_sliding_normal` | — | **0** | — |
| `COLLISIONINFO::add_object` | — | **0** | — |
| `BSPTREE::slide_sphere` | — | — | **0** |
| `CTransition::cliff_slide` | — | — | **0** |
| **`SPHEREPATH::set_neg_poly_hit`** | — | — | **303+ (fires)** |
| `CTransition::insert_into_cell` | — | — | 3,652 |
Retail records collisions almost exclusively via
`SPHEREPATH::set_neg_poly_hit` during normal-grounded-motion. The
COLLISIONINFO normal/sliding setters fire essentially never for
walking-into-walls scenarios. Our investigation premise was wrong;
the cdb data forced the correction.
## Apparatus + scripts committed
- `tools/cdb/door-inside-out.cdb` — v1 (set_collide check)
- `tools/cdb/door-inside-out-v2.cdb` — v2 (COLLISIONINFO family)
- `tools/cdb/door-inside-out-v3.cdb` — v3 (wide net, found
set_neg_poly_hit)
- `tools/cdb/symbol-probe.cdb` — verifies symbol resolution
## Pickup prompt for next session
```
A6.P4 door inside-out: cdb trace + NegPolyHit dispatch landed
(BSPQuery.FindCollisions Path 5 + TransitionalInsert NegPolyHit
branch) but the fix doesn't fire because our SphereIntersectsPolyInternal
doesn't record near-miss polygons. Retail's sphere_intersects_poly
sets a "closest polygon" output even on non-hits via BSP traversal
side-effect; our equivalent only sets it on full hits.
Read docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 door bug — implement near-miss polygon
recording in SphereIntersectsPolyInternal.
TWO SYMPTOMS to fix simultaneously (same root cause):
(a) Off-center inside-out: sphere walks (or squeezes) past door
(b) When blocked: sphere visibly penetrates the door before stopping
Both = static overlap detection without near-miss recording.
Retail uses swept-sphere-vs-poly intersection (uses motion vector
to compute t-value of first contact, stops sphere at surface)
AND records the closest near-miss polygon during BSP traversal.
First move: read SphereIntersectsPolyInternal in
src/AcDream.Core/Physics/BSPQuery.cs. Check whether the `movement`
param is actually used for swept-sphere-vs-poly testing. If not
(just static overlap), that's symptom (b). Add swept detection
and a "closestPoly" output param set on ANY polygon considered
during traversal (not just hits). That closes symptom (a) too.
Then the Path 5 branch `if (hitPoly0 is not null)` will fire on
near-miss cases, NegPolyHitDispatch will set NegPolyHit, and the
TransitionalInsert dispatch (already landed) will block the sphere
at the surface (swept-detected t-value), not after penetration.
Retail oracle: BSPTREE::find_collisions + sphere_intersects_poly
vtable call at acclient_2013_pseudo_c.txt:0053a630-0053a6fb.
Visual verification: same scenario (Holtburg cottage door,
inside-out, ~50cm off-center). Should block fully, no squeeze-through.
Outside-in should still work. Issue #98 cellar cap must still pass.
```

View file

@ -1,265 +0,0 @@
# Door bug — inside-out walkthrough: missing cottage exterior wall (geometry gap)
2026-05-25, continuation of door-collision investigation
## TL;DR
The inside-out walkthrough that persisted after the
`AddAllOutsideCells` fix is **NOT a collision-detection bug**. It's a
**collision-geometry GAP**: the cottage's north exterior wall east
(and presumably west) of the doorway opening doesn't exist in any
registered entity our engine knows about. The sphere walks past the
door slab on its east side, clears the doorway alcove cell's small
east wall (Y range [16.5, 17.1]), and then has nothing in front of it
in the collision representation — even though the VISUAL cottage has
a wall there.
## Apparatus diagnostics
Three new tests landed (in `DoorBugTrajectoryReplayTests`):
1. `Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace` — sphere
south moving north blocks. PASSES.
2. `Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace` — sphere
north moving south blocks. PASSES.
3. `Geometric_DoorSlabAtSphereHeight_OverlapsInZ` — pins slab world Z
range = [94.139, 96.630]; sphere top at Z=95.20 IS within slab.
The slab is at sphere height — BSP collision is geometrically active.
4. `InsideOut_Tick3254_WithCottageWalls_ShouldBlock` — hypothesis test
adds cottage GfxObj 0x01000A2B. Result: cottage DID block but with
cn=(0,0,1) — a floor-cap response, NOT a wall response.
5. `Diagnostic_CottagePolys_NearWalkthroughPosition` — dumps cottage
polygons near sphere XY=(133.655, 17.59), any Z. **Result: ZERO
cottage polygons in that area.** The cottage GfxObj has no
geometry where the sphere walks through.
`DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection`
extended to dump cell 0xA9B40150's 4 physics polys in world frame:
```
[0] sides=Landblock X=[131.600, 133.500] Y=[16.500, 17.100] Z=[94.000, 94.000] FLOOR
[1] sides=Landblock X=[131.600, 131.600] Y=[16.500, 17.100] Z=[94.000, 96.500] WEST WALL
[2] sides=Landblock X=[131.600, 133.500] Y=[16.500, 17.100] Z=[96.500, 96.500] CEILING
[3] sides=Landblock X=[133.500, 133.500] Y=[16.500, 17.100] Z=[94.000, 96.500] EAST WALL
```
Cell 0xA9B40150 is the **doorway alcove** — a small ~1.9m × 0.6m × 2.5m
volume between the cottage interior and the outdoor area. Its east wall
only extends Y=[16.5, 17.1]. **North of Y=17.1, no wall** in this cell.
The captured failing sphere at (133.655, 17.59) is 0.155m east of the
east wall AND 0.49m NORTH of the wall's Y range. The wall doesn't
reach the sphere.
## The collision-geometry gap
Visual representation (in-client):
- Cottage has a north exterior wall east and west of the doorway opening
- The wall extends Y > 17.1 (north of the alcove)
- User sees their character partially clipping into this wall
Collision representation (what we register):
- Cottage GfxObj 0x01000A2B: **0 polygons** in the area (133.655, 17.59, 94-95.20)
- Cell 0xA9B40150 (alcove): walls only at Y=[16.5, 17.1]
- Door slab: only spans X=[131.635, 133.560] — too narrow to cover the cottage opening
- Outdoor cell 0xA9B40029: outdoor cell, no walls
**Net: no entity has wall polygons at (133.655, Y > 17.1).** Sphere can
walk there freely.
## Verification in production capture
`door-fix-inout2.launch.log` shows:
- Cottage GfxObj `[bsp-test]` fires 425 times during inside-out walking
(so visibility is correct post-fix)
- Door slab `[bsp-test]` fires 245 times
- Captured tick 3254: sphere at (133.655, 17.590), target (133.549,
17.599). Result: position X=133.655 unchanged (blocked westward),
position Y=17.599 (moved north freely). cn=(+1, 0, 0) = slab east
face normal.
- The slab east face blocks WEST motion correctly. The sphere is FREE
to move north because no geometry covers (133.655, Y > 17.1).
## UPDATE (2026-05-25 evening): the wall EXISTS, but isn't blocking
Continued investigation with a wider polygon search in
`Diagnostic_CottagePolys_NearWalkthroughPosition` revealed the cottage
DOES have the missing wall:
```
poly 0x0032 n=(0.00, +1.00, 0.00) X=[133.50, 136.30] Y=[17.10, 17.10] Z=[94.00, 97.00]
poly 0x0033 n=(0.00, +1.00, 0.00) X=[133.50, 136.30] Y=[17.10, 17.10] Z=[94.00, 97.00]
```
(Plus symmetric polys 0x0030, 0x0031, 0x0034, 0x0035 covering X<131.6,
0x0037, 0x0038, 0x003A, 0x003B above the doorway lintel.)
The cottage's north exterior wall east of doorway IS at world (X=[133.5,
136.3], Y=17.10, Z=[94, 97]), normal +Y. **This wall SHOULD block sphere
at X=133.655 (sphere west edge at 133.175 ≤ wall X range, sphere south
edge at 17.110 ≤ wall Y).**
The new question: WHY isn't the wall blocking in production?
Sphere at world (133.655, 17.59) at the captured failing tick:
- Sphere XY: X=[133.175, 134.135], Y=[17.110, 18.070]
- Sphere overlaps wall in X (133.175..134.135 vs 133.5..136.3) by 0.635m
- Sphere south edge at Y=17.110 ALIGNS with wall at Y=17.10 (0.010m past)
- Sphere CENTER at Y=17.59 is 0.49m north of wall
- Distance from sphere center to wall plane: 0.49m. Sphere radius 0.48m.
- |dist| (0.49) ≈ radius (0.48). Sphere is JUST grazing the wall plane.
At this exact tick the sphere CENTER is 0.49m north of wall; sphere
south edge is 0.01m north of wall. Sphere is BARELY past the wall.
So this tick isn't where the walkthrough happens. The walkthrough is
EARLIER — when sphere center Y went from 17.58 (just past wall by reach)
to 17.59. The crossing must have allowed the sphere through.
OR: the sphere never actually crossed the wall — it walked around it.
Cottage wall east of doorway is X=[133.5, 136.3]. Sphere at X=133.655
is barely in the wall's X range. If sphere came from X < 133.5 (where
no east wall exists) and shifted east while sliding along the slab,
it could end up at X > 133.5 having NEVER crossed the wall plane.
Cell transit data confirms: tick 1549 outdoor→indoor at X=132.859,
tick 2586 indoor→outdoor at X=134.022 (way past wall east edge).
**The sphere reached X=134.022 inside cottage geometry somehow.**
Sphere fitting through doorway opening requires center X in
[131.6+0.48, 133.5-0.48] = [132.08, 133.02]. Tight. The user's
off-center test (~50cm east) puts sphere at edge of opening or
past. Sphere is sliding against the slab east face (cn=(+1,0,0))
which gradually pushes it east. Eventually sphere center exceeds
X=133.5 — past the cottage east wall's start. From that position,
sphere can move north WITHOUT crossing the wall plane (sphere
center already north of Y=17.10 from prior sliding).
**This may be retail-faithful behavior** OR a bug in sphere-vs-corner
collision. The corner where alcove east wall (X=133.5, Y=[16.5,17.1])
meets cottage north wall (X=[133.5,136.3], Y=17.10) is a degenerate
edge. Sphere sliding along the alcove east wall (moving +Y) reaches
the corner at (133.5, 17.10) — should encounter the cottage wall
and be stopped. If our engine handles the corner transition
incorrectly, sphere slides past.
## What's next (revised AGAIN — corner test PASSED, bug is state-related)
**Corner-slide hypothesis: FALSIFIED.** `CornerSlide_AlcoveEastToCottageNorth_ShouldBlock`
test runs cottage GfxObj + cell 0x0150 BSP both registered. Places
sphere at (132.95, 16.8, 94) inside alcove near east wall. Walks +Y
50 times at 0.05 m/tick. **Sphere stays put at (132.95, 16.8) for all
50 ticks with cn=(0.71, -0.71, 0)** — the corner normal between
alcove east wall and cottage north wall. **The corner handling works
correctly in the harness.**
So production's walkthrough is **a STATE difference**, not a geometric
or collision-detection bug. The harness's sphere can't reach
X=133.655 inside the cottage geometry. Production's sphere does
reach it somehow.
Differences between harness and production:
- Harness uses identity walkable polygon (big quad). Production uses
real cell walkable polys (small, with edges).
- Harness has stub landblock terrain at Z=-1000. Production has real
terrain.
- Harness uses fresh body each tick. Production has accumulated state
from many prior ticks (velocity, contact plane history, etc.).
- Harness uses sphereRadius=0.48 + sphereHeight=1.20 exactly. Production
matches but might have different stepUp / stepDown.
**Next-session apparatus**: replay the EXACT captured tick 2586's body
state through the corner-blocking test setup. Tick 2586 was where
sphere went from indoor cell 0x0150 to outdoor cell 0x0029 at
PrevPy=17.586, Py=17.586 (no Y motion) with X=134.022 (way past alcove
east wall). That tick is the smoking-gun "how did sphere get to X=134
inside alcove" event. Load its body state into the harness, replay
the call, see what the engine reports about getting to that position.
If the harness blocks (sphere can't reach X=134), then production has
state we're not capturing — probably accumulated push/depenetration
across many earlier ticks. If the harness reproduces sphere at X=134,
the bug is in the specific body state at that moment.
The cleanest path forward is **cdb attach to retail** as the original
handoff recommended. Inspect what retail does FRAME-BY-FRAME at the
same doorway approach. If retail walks the user inside cottage at
off-center approach EXACTLY like we do — the bug isn't a bug, and
we should accept the behavior. If retail blocks cleanly — diff
retail's body state evolution vs ours to find the divergence.
## OLD (superseded) "what's next" candidates
**Identify which entity SHOULD own the cottage's north exterior wall
east of the doorway.** Three candidates:
1. **A different cottage GfxObj.** Holtburg cottages might be
multi-piece (separate GfxObjs for wall sections, doorway frame, roof).
The cottage we have (0x01000A2B) might be one of multiple. Check
the landblock's static-entity list for other GfxObjs at the cottage
position via `[entity-source]` log + Setup file.
2. **A landblock-baked "stab"** (separate static entity registered at
spawn time). LandblockLoader produces these. Check `LandBlockInfo`
dat record for landblock 0xA9B4 — what other entities are at world
(~133, ~18)?
3. **The cottage GfxObj's drawing geometry is wider than its physics.**
If 0x01000A2B has `Polygons` (visual) at the wall location but no
`PhysicsPolygons` (collision), the visual is wider than the
collision. This is a dat-data fact — not fixable without retail
re-engineering of the dat.
For candidates 1-2, the fix is "register the missing entity." For 3,
the bug is dat-side (or retail accepts the same walkthrough we do).
**Cheapest next-step test:** add a method to
`DoorSetupGfxObjInspectionTests` that loads `LandBlockInfo` 0xA9B4FFFE
(landblock-baked statics) and prints every static at world XY in
[131, 135] × [16, 19]. The output will name what other GfxObjs/Setups
are registered at the cottage doorway — if any include the missing
wall, we know what to register additionally.
## Apparatus committed
- `tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs`:
faithful door registration, directional collision tests, geometric
pin test, cottage GfxObj hypothesis test, cottage polygon dump.
- `tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs`:
HoltburgCottage_CellPortals_DatInspection extended with cell-poly
world-frame dump.
All tests under `DoorBugTrajectoryReplayTests` and the extended
`DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection`
PASS (skip on CI when dat dir absent).
## Pickup prompt for next session
```
A6.P4 door inside-out walkthrough: identified as collision-geometry
gap, NOT collision-detection bug. The cottage's north exterior wall
east+west of the doorway opening isn't represented in any registered
entity. Sphere walks freely at (133.655, 17.59) — no wall to block.
Read docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md
+ Diagnostic_CottagePolys_NearWalkthroughPosition test output
+ HoltburgCottage_CellPortals_DatInspection dump for cell 0x0150
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 door bug — find missing cottage wall entity.
The fix isn't in BSP, cells, or AddAllOutsideCells
— those are correct. The collision geometry has a
gap. Need to identify which entity SHOULD own the
wall and register it.
First move: add a LandblockStatics_DatInspection test to
DoorSetupGfxObjInspectionTests that loads LandBlockInfo 0xA9B4FFFE
+ iterates StaticObjects. Print every entity at world XY in
[131, 135] x [16, 19] — name + setup id + position. Will reveal
what other entities (if any) live at the cottage doorway.
If a wall-bearing entity exists but we're not registering it: fix
the registration path. If nothing exists: the dat doesn't have the
wall, and this might be retail-faithful behavior we have to accept
(or compensate for by widening the door slab via gameplay layer).
```

View file

@ -1,232 +0,0 @@
# Door bug — partial fix shipped (cell visibility), inside-out asymmetric collision remains
2026-05-25
## TL;DR
**Major root cause closed.** `CellTransit.AddAllOutsideCells` was
silently failing for every production caller because it assumed sphere
positions were in absolute world coordinates (subtracting the
landblock's "absolute" world origin `lbXf = 0xA9 * 192 = 32448`), while
production has used landblock-local coordinates since Phase A.1
(streaming-center landblock at world origin → `lbOffset = (0, 0)`).
For outdoor primary cells the bug was masked by `GetNearbyObjects`'s
radial sweep. For indoor primary cells (where issue #98's gate skips
the outdoor sweep), it meant **outdoor cells were never added to
`portalReachableCells`** → cottage door's outdoor cell `0xA9B40029`
invisible from indoor cell `0xA9B40150` → door's BSP never queried
→ player walked through.
**Outside→inside now blocks correctly. Inside→outside REMAINS BROKEN
asymmetrically.** Body partially intersects the door, slides through
visibly. Not retail-faithful. This is a SEPARATE bug in
BSP-collision-response for two-sided polygons — to investigate next
session.
## Apparatus shipped
Full trajectory-replay harness:
1. **Live capture** (`door-walkthrough.jsonl` from previous session; not
committed): 24,310 records of `PhysicsEngine.ResolveWithTransition`
calls including PhysicsBody snapshots before/after.
2. **Fixture extraction**
([tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl](../../tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl), 4 KB):
tick 13558 (the walkthrough) + tick 22760 (the working outdoor block)
as representative records.
3. **Replay harness**
([DoorBugTrajectoryReplayTests.cs](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs)):
- `LiveCompare_*` tests load the failing tick + replay through the
harness + diff result fields vs captured live values.
- `FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos`
— direct unit test for cell-portal traversal at the captured
sphere position. PASSES (cell graph is correct).
- `AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell`
— direct unit test that pinpointed the root cause. **Initially
failed** (`AddAllOutsideCells` returned empty when given
landblock-local sphere coords). **Now passes after fix.**
4. **Dat-direct cell-portal inspector**
([DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs)):
reads `EnvCell` + `Environment.Cells` + portal `Polygon.Plane` from the
real dat for cells `0xA9B40150` (doorway alcove), `0xA9B4013F`
(cottage interior), `0xA9B40029` (outdoor — confirmed NOT EnvCell).
Output: cell `0xA9B40150` HAS a 0xFFFF exit portal at poly `0x0005`
with plane `n_local=(0, +1, 0), d_local=+5.6`. The sphere-vs-plane
math (sphere world `(132.36, 16.81, 94)` → local `(-1.86, -5.31, 0)`
via 180° Z rotation → `dist = +0.29` within `±rad=0.5` → straddles)
confirmed `exitOutside` SHOULD fire — but `AddAllOutsideCells` then
silently dropped the outdoor cell.
## The fix
[src/AcDream.Core/Physics/CellTransit.cs](../../src/AcDream.Core/Physics/CellTransit.cs)
`AddAllOutsideCells` no longer subtracts the landblock's
"absolute" world origin from the sphere position. Treats
`worldSphereCenter` as landblock-local directly (matching retail's
`CLandCell::add_all_outside_cells` which uses the per-cell 6-byte
position struct, and matching production's universal convention since
Phase A.1).
Existing tests in
[CellTransitAddAllOutsideCellsTests.cs](../../tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs)
and
[CellTransitFindCellSetTests.cs](../../tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs)
updated to use landblock-local sphere coords (they were the only
callers using the world-coord convention; production never did).
## Visual verification
User tested all four combinations at a closed Holtburg cottage door,
~50cm off-center:
| Direction | Speed | Pre-fix | Post-fix |
|---|---|---|---|
| outside → inside | RUN | walks through | **BLOCKS** ✅ |
| outside → inside | WALK | walks through | (presumed BLOCKS — not retested) |
| inside → outside | RUN | walks through | **PARTIAL** ⚠️ body intersects door, sometimes through |
| inside → outside | WALK | walks through | **PARTIAL** ⚠️ same as run |
User quote: *"We have partial blocking from inside out. Can get
through some times. However, char is blocked a bit through the door.
So for example if I'm running towards this from the inside, I can see
parts of the body getting blocked a bit in to the door. This is not
per retail behavior and this is not how it looks when its block from
the outside"*.
The asymmetry is the new diagnostic: outside-in produces a clean block
(no body-into-door intersection visible); inside-out produces a partial
block with visible body intersection. This is the signature of an
**asymmetric collision response** to the door slab's two-sided
polygons (`SidesType=Landblock`), or a **BSP query that handles
sphere-already-overlapping-slab differently from sphere-approaching-slab**.
The `[bsp-test]` probe fires 245 times for the door entity during the
post-fix inside-out attempts — door IS being queried. The
collision-detection mechanics produce the wrong response.
## What's next (separate bug)
**Investigation status (corrected 2026-05-25 late evening).** Two new
directional tests + a geometric pin test all PASS:
- `Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace` PASSES.
- `Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace` PASSES.
- `Geometric_DoorSlabAtSphereHeight_OverlapsInZ` PASSES.
The geometric test reveals (correctly computed this time):
```
Setup 0x020019FF (cottage door) PhysicsPolygons local AABB:
min=(-0.954, -0.134, -1.236) max=(0.971, 0.127, 1.255)
(slab origin at GEOMETRIC CENTER, not the bottom)
partFrame[0].Origin = (-0.006, 0.125, 1.275) → lifts slab origin
1.275 m above entity Z
With entity at world (132.6, 17.1, 94.1) + 180° entity rotation:
partWorldPos = (132.606, 16.975, 95.375)
Slab WORLD AABB:
X: [131.635, 133.560] (1.925 m wide)
Y: [16.848, 17.109] (0.261 m thick)
Z: [94.139, 96.630] (2.491 m tall, bottom JUST above floor)
Player sphere at foot Z=94:
Z: [94, 95.20]
Slab DOES overlap sphere in Z (overlap Z=[94.139, 95.20] = 1.061 m).
```
**The slab IS at sphere height — it should collide.** Both directional
tests prove BSP collision response is symmetric for sphere-to-slab
approach. Yet production shows asymmetric inside-out walkthrough at
off-center positions. The bug must be in one of:
1. **The portal-reachable cells from indoor cell 0x0150 still miss the
door's shadow at certain sphere positions**, despite the
AddAllOutsideCells fix. The user's walkthrough at X=133.655 (1.05 m
east of door center) puts the sphere mostly east of slab X range
[131.635, 133.560]. The sphere's WEST edge (X=133.175) is barely
inside the slab. If GetNearbyObjects's outdoor radial sweep uses
sphere center XY for cell lookup, it computes
gridX = (int)(133.655 / 24) = 5 → cell 0xA9B40029. But AddAllOutsideCells
only adds cells based on the sphere's PRIMARY position. The east-cell
neighbor might not be added if the sphere is wholly within the primary
cell's grid XY. Worth verifying.
2. **The BSP polygon-level test for partial-overlap geometry.** Sphere
half-east-of-slab, sphere south edge at slab north edge, moving +Y:
sphere is on the verge of leaving the slab volume. BSPQuery's polygon
intersection might consider this a "leaving collision" with no
response, even though the sphere body still partially occupies the
slab volume. Retail might handle this as "depenetration push" to
resolve the overlap.
3. **Cell BSP (cell 0x0150's PhysicsPolygons) is missing**. The doorway
alcove cell has 4 physics polygons — likely walls + floor. If retail
relies on the cell's walls to catch sphere-vs-doorway-side-wall
collisions (in addition to the door slab), and we're not loading /
testing the cell BSP correctly for the player's foot at sphere
height, the side walls would miss.
Three candidate investigations, ranked by ROI:
**A. cdb attach to retail** at a Holtburg cottage doorway. Break on
`CTransition::FindObjCollisions` for the door entity. Inspect what
shapes retail actually tests against. THIS IS DEFINITIVE — answers
"what should we be doing differently" in 15-30 min. CLAUDE.md has the
toolchain ready.
**B. Reproduce inside-out walkthrough at unit-test speed.** Load real
cell 0x0150 BSP into the harness (via CacheCellStruct from dat) +
register door at faithful transform + replay captured tick 3262.
If walkthrough reproduces at unit speed, can iterate on the fix in
<500 ms.
**C. Audit GetNearbyObjects radial sweep + AddAllOutsideCells coverage**
for east-neighbor cell when sphere XY is at primary cell boundary.
Recommendation: **A first** (cdb), then **B** to validate the fix at
unit-test speed.
## Commits
[List the commit SHAs of the apparatus + fix once landed.]
## Pickup prompt for the next session
```
Door bug — major root cause closed (CellTransit.AddAllOutsideCells
landblock-local coord convention). Outside→inside now blocks. But
inside→outside has asymmetric BSP collision response: body partially
intersects the door slab, sphere slides through. Same behavior at run
+ walk speed. Bug is in BSP collision response for two-sided polygons
or sphere-already-overlapping-slab handling.
Read docs/research/2026-05-25-door-bug-partial-fix-shipped.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 door bug — inside-out asymmetric BSP collision
response. Apparatus is shipped (DoorBugTrajectoryReplayTests).
First major root cause closed. Remaining bug is in
BSP-collision-response mechanics, not cell visibility.
First move: extend the existing DoorBug apparatus with a more
faithful door registration (entity at the actual production world
pos + correct rotation; use the partFrame from the dat). Then write
TWO directional tests: sphere approaching the slab from the south
(outside-in) and sphere approaching from the north (inside-out).
Compare cn normal + resolution for each. The asymmetric response
will reproduce at unit-test speed. From there, inspect
BSPQuery.FindCollisions's handling of two-sided polygons and
sphere-already-overlapping cases. Retail oracle:
CBSPTree::find_collisions family at acclient_2013_pseudo_c.txt.
DO NOT:
- Re-investigate cell visibility (closed by AddAllOutsideCells fix)
- Re-do the registration shape (multi-part registration is correct)
- Speculate on the BSP fix without apparatus
```

View file

@ -1,313 +0,0 @@
# Issue #100 shipped + indoor-cell culling investigation handoff
**Date:** 2026-05-25 PM
**Status:** Issue #100 SHIPPED (visually verified for primary acceptance). Visual verification surfaced a NEW finding in the same family as issue #78 — outdoor terrain mesh visible inside cottage cellars at certain camera angles. Next session: deep investigation + plan + port retail's indoor-cell visibility culling to close the family.
**Branch:** `claude/strange-albattani-3fc83c` (worktree)
**Predecessor handoff:** [docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md](2026-05-25-issue-100-terrain-cutout-handoff.md) (the prior session's smoking-gun research that drove the #100 fix).
---
## TL;DR
**Shipped this session (3 commits):**
- `f48c74a` — Task 1: terrain shader Z nudge (retail `zFightTerrainAdjust = 0.00999999978`)
- `a64e6f2` — Task 2: removed ~50 LOC of `hiddenTerrainCells` / `BuildingTerrainCells` plumbing across 7 source files + 2 test files, closed #100 in ISSUES.md
- `84e3b72` — docs: stabilized Task 2's SHA reference in ISSUES.md (follow-up commit, not amend)
**Visual verification result (Holtburg, live ACE):**
- ✅ **Primary acceptance:** transparent rectangles around houses are GONE. Ground reads as continuous cobblestone / grass around every cottage observed. Issue #100's user-visible symptom is closed.
- ❌ **New finding:** standing inside a cottage cellar with the camera positioned such that the cottage walls don't fully occlude the view, the outdoor terrain mesh renders as a sharp-edged grass rectangle over the cellar stairs and floor. **Clears when the camera moves closer** (camera position changes such that cottage geometry properly occludes). **Gameplay unaffected** — player can walk down/up the stairs normally.
**Root cause hypothesis for the new finding (HIGH CONFIDENCE):** indoor-cell visibility culling is not gating outdoor terrain rendering. The outdoor terrain mesh is now (correctly per retail) rendered everywhere on the 192 m landblock — including in 3D regions occupied by indoor `CEnvCell` volumes. When the camera is in an indoor cell, the outdoor terrain mesh should be EXCLUDED from the draw set unless an outdoor cell is reachable via portal LOS from the camera's cell. acdream does not currently perform this culling.
**This is the same root cause as filed issue #78** ("Outdoor stabs/buildings visible through the rendered floor" at the inn), just with outdoor *terrain* affected instead of outdoor *stabs*. #78 was filed 2026-05-19 with the hypothesis "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')." We never returned to it.
---
## The visibility-culling issue family
Three filed/observed issues likely share infrastructure:
| ID | Symptom | Domain |
|---|---|---|
| **#78** (OPEN) | Inside Holtburg Inn, outdoor stabs/buildings visible THROUGH the floor and walls | Outdoor stabs not culled when camera in indoor cell |
| **Cellar-stairs** (NEW, observed 2026-05-25 PM) | Inside cottage cellar, outdoor terrain mesh visible covering stair geometry at certain camera angles | Outdoor terrain not culled when camera in indoor cell |
| **#95** (OPEN) | Entering dungeon via portal, `visibleCells` per cell jumps from ~4-7 to **135-145**, including cells from other landblocks; see-through walls, other-dungeon geometry visible | Indoor→indoor portal-graph traversal blowup (over-inclusion) |
#78 and the cellar-stairs finding are the **same bug** (outdoor geometry not culled when camera is in an indoor cell) with different geometry classes affected. **They should close together.**
#95 is a sibling — same visibility-culling SUBSYSTEM but different specific failure (indoor→indoor over-inclusion via unrooted portal recursion). It might or might not close as a side effect of the #78/cellar-stairs fix; the next session should determine if the infrastructure overlaps enough to fix both, or whether #95 needs its own work.
Additional adjacent issues (probably NOT same root cause but worth noting):
- **#79, #80, #81, #93, #94** — indoor lighting bugs. Filed under A7 (M1.5 lighting fidelity). Some may share visibility plumbing (e.g., if lights from outdoor entities leak into indoor cells, that's a visibility issue).
---
## Why I'm confident this is culling, not Z-fighting
Three signals, ordered by weight:
1. **Patch geometry is too large.** A Z-precision Z-fight at coplanar 1 cm separation would manifest as a thin ~0.3 m strip on the topmost stair tread (Z=94). The observed patch is sharp-edged rectangular geometry the size of a terrain cell footprint (likely 24 m × 24 m in landblock-local space), covering multiple stair steps and floor area. That's a polygon, not a precision artifact.
2. **"Clears when closer" matches geometric occlusion, not depth precision.** If 1 cm depth-buffer precision were failing, closer camera distance would PASS more cleanly (precision tightens). The user reports the patch clears as they approach the stairs — consistent with cottage walls + stair treads now occluding the terrain in screen space. At 2-5 m camera distance and 24-bit depth buffer, the 1 cm nudge has sub-millimeter resolving power; precision is not the bottleneck.
3. **Exact match for #78's hypothesis #2 mechanism.** #78 ("outdoor stabs visible through cell walls") was filed 2026-05-19 with hypothesis: outdoor stabs aren't culled when player is in an EnvCell; WB has a `RenderInsideOut` stencil pipeline that acdream never invokes. The cellar-stairs case is the same mechanism applied to outdoor terrain mesh.
**One test that could falsify culling-as-cause:** stand at the spot showing the artifact, look at the grass patch, rotate the camera slowly without moving the character. If the patch FLICKERS / shimmers as you turn, that's Z-fight (depth precision unstable across angles). If the patch stays geometrically stable (its polygon edges move predictably with the camera, but it doesn't flicker), that's culling. The screenshot suggested polygon-stable edges — consistent with culling — but rotating the camera is the definitive test, and the next session should do this in the first 60 seconds of visual checking before planning the fix.
---
## Existing apparatus the next session can use
### acdream's current visibility code
**[`src/AcDream.App/Rendering/CellVisibility.cs`](../../src/AcDream.App/Rendering/CellVisibility.cs)** — portal-based interior cell visibility system ported from ACME's `EnvCellManager.cs`. Exposes:
- `FindCameraCell(...)` — resolves which EnvCell the camera is in.
- `PointInCell(...)` — point-in-cell test with `PointInCellEpsilon = 0.01f`.
- `GetVisibleCells(...)` — returns `VisibleCellIds` set for the camera's current cell, via portal-chain traversal.
- `CellSwitchGraceFrameCount = 3` — anti-flicker grace period for cell transitions.
**[`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`](../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs)** — per-entity draw filter. Per ISSUES.md #78 line 165, "the dispatcher already filters by `entity.ParentCellId ∈ visibleCellIds` but outdoor stabs have `ParentCellId == null` so they always pass." This is the gate we need to extend.
**[`src/AcDream.App/Rendering/TerrainModernRenderer.cs`](../../src/AcDream.App/Rendering/TerrainModernRenderer.cs)** — terrain dispatcher. Currently renders ALL loaded landblocks unconditionally. Needs to learn about indoor-camera-state to optionally skip outdoor-cell terrain cells.
### Probes available
From CLAUDE.md "Diagnostic env vars":
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per `PlayerMovementController.CellId` change. Useful for verifying when the camera is in an indoor vs outdoor cell.
- `ACDREAM_PROBE_RESOLVE=1` — full physics resolver trace.
- Runtime-toggleable via the DebugPanel "Diagnostics" section.
No existing probe instruments the rendering visibility decision — the next session might add one (`ACDREAM_PROBE_VIS=1` that logs the camera's resolved cell + `VisibleCellIds` set per N frames).
### Retail oracle anchors
```
docs/research/named-retail/acclient_2013_pseudo_c.txt:311397
CEnvCell::find_visible_child_cell (address 0x0052dc50)
docs/research/named-retail/acclient_2013_pseudo_c.txt:280028
call site: eax_6 = CEnvCell::find_visible_child_cell(eax_5, &__return, arg5);
```
Grep further for `find_visible`, `visibility`, `cull`, `RenderDeviceD3D::DrawBlock`, `ACRender::draw`, etc. The retail render loop's visibility chain — pre-frame walk-down from the camera's cell through portal-visible neighbours — is the target to port.
### WorldBuilder reference
```
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs
references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs
```
WB has a `RenderInsideOut` mechanism in these files. Per #78's hypothesis, "acdream never invokes" this pipeline. The next session should determine whether to (a) invoke WB's existing code from our render path, (b) port the algorithm to acdream's namespaces, or (c) write a retail-faithful port from the named-retail decomp directly. CLAUDE.md's WB inventory policy applies — read [docs/architecture/worldbuilder-inventory.md](../architecture/worldbuilder-inventory.md) before deciding.
---
## Do-not-retry list for the next session
1. **Don't try to roll back the #100 fix.** The transparent-rectangle bug was a universal symptom on every Holtburg house. The cellar-stairs artifact is conditional and camera-angle-dependent. Reverting #100 trades a worse bug for a less-bad one.
2. **Don't try to solve the cellar-stairs case by lowering the terrain Z further** (e.g., bumping the shader nudge from 0.01 to 0.1 or 1.0). The visible terrain is rendered at its correct Z; the issue is that it's visible AT ALL inside the indoor cell. Bigger nudge doesn't help and would break coplanar-floor disambiguation elsewhere.
3. **Don't try to solve it by hiding terrain cells based on the building footprint again.** That was issue #100's bug — cell-level hiding is too coarse (cottage ~12 m × 12 m in a 24 m × 24 m cell). The right granularity is per-camera-state visibility, not per-cell mesh modification.
4. **Don't try to fix this with depth tricks** (disable depth-write for terrain, etc.) — those break elsewhere and aren't retail-faithful.
5. **Don't conflate this with #82** (some slope terrain lit incorrectly). #82 is about per-vertex normal calculation; the cellar-stairs artifact is about which polygons render at all, not how they're shaded.
6. **Don't try to land a 1-line fix for this.** Indoor-cell visibility culling is a real system to port. Single-line patches at the symptom site (e.g., "if camera in cellar, skip terrain") would close cellar-stairs but not #78 — and would be the kind of workaround CLAUDE.md prohibits. Per the project rule, fix the root cause: port the visibility computation properly.
7. **Don't trust the WB `RenderInsideOut` code blindly.** WB's editor view has known visibility quirks (per the predecessor handoff: "WB has a known Z-fighting issue in the editor view that nobody noticed because it's editor-only"). Cross-reference WB against retail before adopting.
---
## Open questions for the next session to answer
1. **Is the cellar-stairs artifact 100% culling, or partly Z-precision?** The first verification step is the camera-rotation test described above (rotate without moving — flicker = Z-fight, stable = culling). Until this is confirmed, the diagnosis remains "high confidence" but not certain.
2. **Does the #78 + cellar-stairs fix also close #95?** The two are in the same family but #95's specific failure (over-inclusion of indoor cells via portal recursion) might need a separate cap-traversal-depth fix. The next session should map the shared infrastructure before committing to a combined-or-split plan.
3. **What's the right Phase identifier?** M1.5 doesn't have a "visibility" sub-phase yet. A6 is physics; A7 is lighting. Visibility might warrant its own A-letter (A8?) or be slotted under whichever existing structure makes sense. Discuss with user at the start of the next session before naming the work.
4. **Should the cellar-stairs case be documented in #78** as additional evidence, or filed as a separate issue tied to #78? Per user direction (2026-05-25 PM session-end): don't file a new issue; treat as evidence for #78. The next session's investigation should formalize this — possibly by editing #78 to broaden its description to "outdoor geometry (stabs + terrain) visible inside EnvCells."
---
## Pickup prompt for the next session
```
Indoor-cell visibility culling — port retail's mechanism to close
issue #78 (outdoor stabs visible through inn floor) and the new
cellar-stairs visual artifact discovered while visual-verifying
the #100 fix on 2026-05-25.
Read first (in this order):
1. docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md
(this doc — full session handoff with the family map, root-cause
hypothesis, retail anchors, WB references, do-not-retry list)
2. docs/ISSUES.md #78 (the filed issue; same root cause as the
cellar-stairs finding)
3. docs/ISSUES.md #95 (sibling visibility issue; verify whether
it closes as a side effect)
4. CLAUDE.md — search "currently working toward" to refresh state
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: TBD (visibility culling; new sub-phase to name
with the user at session start — possibly A8 if A6=physics,
A7=lighting follow this naming, OR fits under an existing
A6 sub-phase)
## Session flow (three phases, in order)
### Phase 1 — Investigate (use the /investigate skill)
Independently verify the hypothesis and locate the retail mechanism.
Specifically:
a. Run the camera-rotation falsification test on the cellar-stairs
artifact. Stand in a Holtburg cottage cellar at a position where
the grass overlay is visible, rotate the camera slowly without
moving. If the patch stays geometrically stable (polygon edges
move predictably), confirms culling. If it flickers / shimmers,
pivot the diagnosis to Z-precision.
b. Grep named-retail for the visibility chain. Anchors to start
from:
acclient_2013_pseudo_c.txt:311397 — CEnvCell::find_visible_child_cell
acclient_2013_pseudo_c.txt:280028 — call site
Find: RenderDeviceD3D::DrawBlock (around line 430027 per the
#100 predecessor handoff), the visibility computation that
precedes it, and how it gates outdoor-cell rendering when the
camera is in an indoor cell.
c. Read WorldBuilder's visibility implementation:
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs
references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs
Specifically the RenderInsideOut stencil pipeline that #78
flags as "acdream never invokes." Decide whether to adopt
wholesale, port to our namespaces, or write fresh from
retail.
d. Read acdream's existing visibility code:
src/AcDream.App/Rendering/CellVisibility.cs
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
src/AcDream.App/Rendering/TerrainModernRenderer.cs
Understand the current per-entity gate (filters by
entity.ParentCellId ∈ visibleCellIds, but outdoor stabs
have null ParentCellId so they always pass — that's the bug).
e. Determine whether #95's symptom (visibleCells exploding to
135-145 at network hubs) closes as a side effect or needs
its own work. Read scen5 acdream.log if it's still in the
research tree.
Output of Phase 1: a short report — either "culling is confirmed
and here's the retail anchor / WB code / acdream extension point"
or "diagnosis pivot needed, here's the new shape." Plus a
fix-shape sketch. Get user approval before Phase 2.
### Phase 2 — Plan (use the superpowers:writing-plans skill)
Draft the implementation plan. The shape depends on Phase 1
findings, but likely 4-6 tasks:
- Task 1: Build the diagnostic probe (ACDREAM_PROBE_VIS=1 logging
camera cell + VisibleCellIds + which entities/cells get
rendered) — apparatus first, per CLAUDE.md's "apparatus for
physics bugs" memory note generalized to rendering.
- Task 2: Extend the WbDrawDispatcher per-entity gate to skip
outdoor entities (ParentCellId == null) when the camera's
current cell is indoor AND no outdoor cell is in VisibleCellIds.
- Task 3: Extend the TerrainModernRenderer to skip outdoor
landblocks under the same condition (or to skip individual
cells if the granularity matters — let the retail decomp
decide).
- Task 4: (Possibly) Port the portal-LOS chain that decides
which outdoor cells ARE visible from inside an indoor cell
via doors/windows — so transitions through doorways don't
abruptly cull and re-add geometry. Read retail's clip-plane
portal test for this.
- Task 5: (Possibly) Address #95's traversal-depth cap if
Phase 1 confirms it's not closed by the #78 fix.
- Task 6: Visual verification — at Holtburg cottages (cellar
stairs no longer show terrain), Holtburg Inn (outdoor stabs
no longer visible through walls), and a portal-entry dungeon
(visibleCells stays in a sane range if #95 is in scope).
### Phase 3 — Implement (use superpowers:subagent-driven-development)
Same pattern as the #100 session: fresh subagent per task,
two-stage review per task (spec + code quality), final review
across all commits, visual verification by user as the
acceptance test.
## Constraints
Per CLAUDE.md "no workarounds" rule — fix the root cause, do not
patch symptom sites. Visibility culling is a real system, not a
one-line gate.
Read the do-not-retry list in this handoff doc (7 items) before
starting Phase 2.
Visual verification is the acceptance test. The fix must close
the cellar-stairs artifact AND #78's "outdoor stabs through floor"
AND not regress #100's transparent-rectangle resolution. Be
honest about partial results.
## Reference repo hierarchy reminder
Per CLAUDE.md "Reference repos: cross-check the relevant ones" —
for visibility/culling work, the relevant references are:
- Retail decomp (docs/research/named-retail/) — primary oracle
- WorldBuilder VisibilityManager + GameScene — implementation reference
- ACE has minimal coverage here (it's server-side; client visibility
is not its concern)
- holtburger is TUI, no rendering visibility
- AC2D has fixed-function rendering — limited modern relevance
Cross-reference retail + WB. If they diverge, retail wins.
## What success looks like
After this work lands:
- Standing in a Holtburg cottage cellar at the exact spot of the
2026-05-25 screenshot artifact, no grass overlay on stairs from
ANY camera angle.
- Standing inside Holtburg Inn, no outdoor stabs visible through
floor or walls.
- Entering a dungeon via the Town Network portal, visibleCells
per cell stays in the ~4-15 range (if #95 in scope).
- No regression on issue #100 (no transparent rectangles around
houses).
- dotnet build green; dotnet test failures within the documented
14-23 flaky window.
```
---
## CLAUDE.md update (post-handoff)
Pending. The CLAUDE.md ship paragraph for #100 was deferred to "after visual verification confirms" — visual verification PARTIALLY confirmed (primary acceptance met, secondary artifact in same family as existing #78). The next session can either:
- Add a brief CLAUDE.md ship entry now mentioning #100 closed + cellar-stairs finding linked to #78
- Skip until #78 / cellar-stairs lands, then add a combined paragraph
Recommendation: add it now (issue #100 is genuinely closed by its own criteria). The cellar-stairs work is a NEW investigation, not a continuation of #100.
---
## Files state at session end
```
Branch: claude/strange-albattani-3fc83c
HEAD: 84e3b72 docs: #100 — stabilize Task 2 SHA reference in ISSUES.md
Parent: a64e6f2 refactor: #100 — remove hiddenTerrainCells / BuildingTerrainCells plumbing
Grandparent: f48c74a fix(render): #100 — render terrain 1 cm below physical Z (retail zFightTerrainAdjust)
Before #100: 2fc312e docs: #101 — fix fabricated content in Recently closed entry
Working tree: clean
Untracked: pre-flight-test-baseline.log, issue100-verify-launch.log (logs, can be deleted/gitignored)
```
Both log files are session-scoped; the next session can either delete them or ignore them. They aren't committed.

View file

@ -1,406 +0,0 @@
# Issue #100 — Transparent ground around buildings — investigation handoff
**Date:** 2026-05-25 PM (end of A6.P8 session)
**Status:** Initial research done; **next session is fix-design + implement**. The smoking gun is retail's per-draw `zFightTerrainAdjust = 0.01`. The current acdream code uses a wrong mechanism (cell-level terrain collapse) that creates the transparent rectangles around every Holtburg house.
**Predecessor issue entry:** [`docs/ISSUES.md` #100](../ISSUES.md) (filed 2026-05-24).
---
## TL;DR
The transparent rectangles around every Holtburg house are caused by acdream's
`hiddenTerrainCells` mechanism — a misfire on the Z-fighting problem. The
mechanism collapses entire 24m × 24m outdoor terrain cells to a zero-area
degenerate when any building's `Frame.Origin` lies in them, but cottages are
only ~12m × 12m, so ~75% of each "hidden" cell is bare framebuffer-clear
showing through.
**Retail's mechanism is different and almost trivially small:** retail
**always renders the full terrain mesh, then nudges every terrain vertex Z
down by `0.00999999978 m` (= ~0.01 m) at draw time.** That makes terrain
always lose the depth test against a coplanar building floor — Z-fight
solved, no cells hidden, no cutout polygon needed. Verbatim from the
2013 EoR retail decomp:
| Source | What |
|---|---|
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:1120769` | `float zFightTerrainAdjust = 0.00999999978;` |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:430113` | `DrawLandCell(esi_3)` — per-cell terrain draw |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:430124` | `DrawSortCell(esi_3)` — per-cell building draw, **same iteration** |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:427867` | `ACRender::landPolysDraw(arg2->polygons, 2)` — the `arg2=2` path |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:006b6402` | `edi_4[1] = (float)((long double)esi_1[2] - (long double)zFightTerrainAdjust);` — the terrain-Z nudge |
**WorldBuilder also renders full terrain** — it does **not** hide cells.
WB has a known Z-fighting issue in the editor view that nobody noticed
because it's editor-only.
[`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs:123-141`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs) iterates all 64 cells unconditionally.
**The fix is path 2 from the issue #100 entry**, refined: drop
`hiddenTerrainCells` entirely + apply `gl_Position.z -= 0.01` (or
equivalent world-Z nudge) in `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`
at line 139. Estimated change: ~15 LOC across 1-2 commits, including
removal of the dead `BuildingTerrainCells` / `hiddenTerrainCells`
plumbing.
---
## Symptom (concrete evidence)
User screenshot 2026-05-25: standing next to a Holtburg cottage. The ground
in a rectangular footprint around the building appears as a flat dark
pink/light patch (the framebuffer clear color) instead of cobblestone /
grass terrain. Visible as a sharp-edged rectangle the size of the
**outdoor terrain cell** (24 × 24 m), not the size of the **cottage's
building footprint** (~12 × 12 m). Same shape on every house observed.
User wording from 2026-05-24 report: "around every house now I missing
the ground texture, it is transparent. I can see through the ground."
---
## Root cause (now confirmed via decomp cross-reference)
### The acdream code that produces the bug
Commit `35b37df` (2026-05-23, A6.P3 #98 triage) kept the
`hiddenTerrainCells` mechanism. The path:
1. **`LandblockLoader.BuildBuildingTerrainCells(LandBlockInfo info)`**
([`src/AcDream.Core/World/LandblockLoader.cs:39-50`](../../src/AcDream.Core/World/LandblockLoader.cs:39))
reads `info.Buildings`, computes
`int cx = clamp(building.Frame.Origin.X / 24f, 0, 7)`,
`int cy = clamp(building.Frame.Origin.Y / 24f, 0, 7)`, and emits
`cy * 8 + cx` per building. Granularity: **one 24m cell per building**.
2. **`LandblockMesh.Build`**
([`src/AcDream.Core/Terrain/LandblockMesh.cs:175-185`](../../src/AcDream.Core/Terrain/LandblockMesh.cs:175))
replaces every index in those cells with the cell's first-vertex index,
producing degenerate (zero-area) triangles that the GPU rasterizer skips.
3. Result: a **24m × 24m hole** in the terrain mesh per building, regardless
of the building's actual size.
A cottage at, say, world `(110, 26)` has `Frame.Origin` at landblock-local
`(110, 26)``cx = 4`, `cy = 1` → outdoor cell index `12`. The hidden
area is `(cx*24, cy*24)` to `((cx+1)*24, (cy+1)*24)` = `(96, 24)` to
`(120, 48)` — a 24×24m square. The cottage footprint is closer to
~12×12m centred near `(110, 26)`. ~75% of the hidden area has no
building geometry to cover it → framebuffer-clear visible.
### What the existing comments said the intent was
[`src/AcDream.Core/Terrain/LandblockMesh.cs:171-174`](../../src/AcDream.Core/Terrain/LandblockMesh.cs:171):
> Indices are trivial 0..383 since we don't deduplicate verts. When a
> building owns an outdoor terrain cell, **keep the fixed 384-index
> contract but collapse its two triangles so the building/stair mesh can
> visually own the hole.**
[`src/AcDream.Core/World/LandblockLoader.cs:33-37`](../../src/AcDream.Core/World/LandblockLoader.cs:33):
> Map LandBlockInfo.Buildings to 8x8 terrain mesh cells (cy * 8 + cx).
> **Retail attaches each CBuildingObj to its outside landcell during
> CLandBlock::init_buildings;** keep this signal separate from stabs so
> ordinary static props do not punch holes in terrain.
The first comment shows the intent: avoid Z-fighting between the building
floor and the terrain below. The second is correct but irrelevant — retail
attaches buildings to a cell for render-order (the `DrawSortCell` step),
NOT to hide that cell's terrain. Our author misread the retail intent.
---
## Retail mechanism (verbatim)
Per the research-agent dispatch this session, the full retail render
sequence is at `RenderDeviceD3D::DrawBlock`
([`acclient_2013_pseudo_c.txt:430027`](../research/named-retail/acclient_2013_pseudo_c.txt)
onwards):
```
for each CLandCell in draw_array (all 64 cells): // line 430113
DrawLandCell(esi_3) // → ACRender::landPolysDraw(polygons, 2)
DrawSortCell(esi_3) // → DrawBuilding(...) for any CBuildingObj attached
// to this cell + the cell's object list
```
`landPolysDraw(polygons, 2)` selects the path that subtracts
`zFightTerrainAdjust` from every terrain vertex Z at upload time. The
constant:
```c
float zFightTerrainAdjust = 0.00999999978; // acclient_2013_pseudo_c.txt:1120769
```
And the application
([`acclient_2013_pseudo_c.txt:006b6402`](../research/named-retail/acclient_2013_pseudo_c.txt)):
```c
edi_4[1] = ((float)(((long double)esi_1[2]) - ((long double)zFightTerrainAdjust)));
```
Where `edi_4[1]` is the output vertex Z and `esi_1[2]` is the source
vertex Z. So every terrain vertex's `Z` becomes `Z - 0.01` at draw time.
**Result:** terrain is uniformly 1 cm lower than its physical height (the
physics path uses the un-nudged Z; only the render path nudges). Building
floors at the physically-correct height always win the depth test
because they're 1 cm higher than the rendered terrain. No cells are
hidden. No cutout is computed. The world reads as one continuous surface.
### Retail's `CLandBlock::init_buildings`
[`acclient_2013_pseudo_c.txt:313854`](../research/named-retail/acclient_2013_pseudo_c.txt)
iterates `lbi->buildings`, calls
`CBuildingObj::makeBuilding(building_id, ...)`, then
`CBuildingObj::add_to_cell(eax_4, landcell)` — attaches the building to
whichever `CLandCell` it physically belongs to. **This is for render
ordering (sort) and physics scoping, not for terrain cutout.** No terrain
modification happens here.
### `BuildInfo` data fields (acclient.h:32035)
```c
struct __cppobj BuildInfo {
IDClass<_tagDataID,32,0> building_id; // Setup DID (0x02xxxxxx)
Frame building_frame; // position + rotation
unsigned int num_leaves; // portal leaf count
unsigned int num_portals;
CBldPortal **portals;
};
```
**There is no explicit footprint polygon, AABB, or terrain-cell list.**
The only geometric anchor is `building_frame.Origin`. Building footprint
must be derived from the Setup's `parts[0]` GfxObj geometry if you needed
it — retail never does, because the depth-nudge mechanism makes it
unnecessary.
---
## Recommended fix shape
### Path 2 (refined) — retail-faithful terrain Z-nudge
**Site:** [`src/AcDream.App/Rendering/Shaders/terrain_modern.vert`](../../src/AcDream.App/Rendering/Shaders/terrain_modern.vert) line 139.
**Change:** replace
```glsl
gl_Position = uProjection * uView * vec4(aPos, 1.0);
```
with
```glsl
// Retail zFightTerrainAdjust (acclient_2013_pseudo_c.txt:1120769, value
// 0.00999999978). Lower terrain by 1 cm so coplanar building floors
// (at the un-nudged physically-correct Z) always win the depth test.
// Cross-ref: docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md.
vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
gl_Position = uProjection * uView * vec4(terrainPos, 1.0);
```
**Cleanup (same commit or follow-up):**
1. Delete `hiddenTerrainCells` parameter and the collapse block at
`LandblockMesh.cs:175-185`.
2. Delete `LoadedLandblock.BuildingTerrainCells` field at
`src/AcDream.Core/World/LoadedLandblock.cs`.
3. Delete `BuildBuildingTerrainCells` at
`LandblockLoader.cs:33-50`.
4. Delete the threading through `GameWindow.cs:1808, 5366, 8761` and
`src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs`.
5. Delete `tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs`'s
hiddenTerrainCells test cases. Delete or rewrite
`tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs`'s
`BuildBuildingTerrainCells_*` cases.
**Test plan:**
- Add a tiny shader-vertex unit test if there's a precedent (look in
`tests/AcDream.App.Tests/Rendering/` for any shader-correctness tests).
- Visual verification at Holtburg: terrain renders continuously under
cottages, no transparent rectangles. Z-fighting between building floor
and terrain not visible.
- Run the full focused test suite (now 23 tests, will likely shrink by 2-4
when the dead `BuildBuildingTerrainCells` / `LandblockMesh.hiddenTerrainCells`
tests are removed) and confirm green.
**Why this is right:**
- Matches retail mechanism verbatim (1 cm Z nudge on terrain at draw time).
- Removes ~50 LOC of dead plumbing (`BuildingTerrainCells` threading
through 5 files).
- Avoids the per-building-footprint computation that the current code
cannot do correctly without loading the Setup mesh.
### Why NOT path 1 (polygon-level cutout)
- Retail doesn't do this — there is no precedent in the named decomp.
- Building footprint isn't in `BuildInfo` — would require loading the
Setup AND computing a 2D XY footprint polygon from `parts[0]`'s
geometry. Engineering-heavy.
- Even if computed, mesh modifications break the fixed 384-index contract
in `LandblockMesh.Build`.
### Why NOT path 3 (building yard mesh)
- Retail doesn't have this. `BuildInfo` carries no yard polygon.
- Cottage Setups don't appear to include a yard mesh in their geometry
(would need confirmation by dumping a cottage Setup, but the retail
mechanism makes this question moot).
---
## Do-not-retry list
1. **Don't try to compute the building's tight footprint** from
`LandBlockInfo.Buildings`. The struct doesn't carry one. Retail doesn't
either. Any computation would require loading the Setup mesh and
building an XY hull from `parts[0]` — pure engineering with no retail
anchor.
2. **Don't shift the 0.02 m EnvCell render lift** at
`GameWindow.cs:5400` (or equivalent). That lift is for indoor-cell
floor rendering and is correct as-is. The terrain Z nudge is the
reverse direction (lower terrain) and is independent.
3. **Don't disable depth testing** on terrain or building draws. Retail
uses standard depth test (`GL_LESS` equivalent); the Z nudge alone is
the disambiguator.
4. **Don't apply `glPolygonOffset`** to terrain. Retail uses a vertex Z
nudge, not GPU-side polygon offset. Polygon offset has hardware-specific
slope-dependent behavior; the constant 1 cm world-Z is uniform and
well-defined.
5. **Don't keep `hiddenTerrainCells` and add the Z nudge as a "belt and
suspenders"** safety. The hidden-cells path is wrong and should be
deleted in the same commit. Two mechanisms for the same problem is
future technical debt.
6. **Don't touch the physics path.** The Z nudge is render-only. Physics
already uses the un-nudged terrain Z. This is the same render-vs-physics
split that `35b37df` correctly introduced for the `0.02m` EnvCell render
lift (kept item in that commit's "Kept" list).
---
## Files involved (for the next session)
| File | What's there | Action |
|---|---|---|
| `src/AcDream.Core/Terrain/LandblockMesh.cs:175-185` | `hiddenTerrainCells` collapse block | Delete |
| `src/AcDream.Core/Terrain/LandblockMesh.cs:Build` signature | `IReadOnlySet<int>? hiddenTerrainCells` param | Delete param |
| `src/AcDream.Core/World/LoadedLandblock.cs` | `BuildingTerrainCells` field | Delete |
| `src/AcDream.Core/World/LandblockLoader.cs:33-50` | `BuildBuildingTerrainCells` method | Delete |
| `src/AcDream.Core/World/LandblockLoader.cs:Load` | `buildingTerrainCells` local + threading into `LoadedLandblock` ctor | Delete locals + simplify ctor call |
| `src/AcDream.App/Rendering/GameWindow.cs` ~lines 1808, 5366, 8761 | `LandblockMesh.Build(..., lb.BuildingTerrainCells)` call sites | Drop the `hiddenTerrainCells` argument |
| `src/AcDream.App/Streaming/GpuWorldState.cs` | `BuildingTerrainCells` threading | Drop |
| `src/AcDream.App/Streaming/LandblockStreamer.cs` | `BuildingTerrainCells` threading | Drop |
| `src/AcDream.App/Rendering/Shaders/terrain_modern.vert:139` | `gl_Position = ...` | Insert `aPos.z - 0.01` nudge above |
| `tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs` | `hiddenTerrainCells` test cases | Delete |
| `tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs` | `BuildBuildingTerrainCells_*` cases | Delete |
---
## Open questions
1. **Old terrain shader removed?** There's a `terrain_modern.vert` and the
build-output mirrors. Confirm there's no older `terrain.vert` that
also needs the nudge applied (the comment at line 4-5 says "Math
identical to terrain.vert"; check whether the legacy shader is still
compiled into the binary or has been fully retired post-N.5b).
2. **Sky / water shaders** — confirm the Z-nudge doesn't accidentally
affect anything else. Should be limited to the terrain shader only.
3. **Building floor render order** — retail also relies on the
`DrawSortCell` per-cell building draw happening after `DrawLandCell`.
Does acdream's current draw order put buildings after terrain? If yes,
nothing else needed. If the order is reversed, the depth-nudge still
works because depth-test is positional, not order-dependent. Just
verify for completeness.
4. **Does WB have a different shader Z nudge we should crib?** The
research agent says no — WB renders full terrain without nudge and
has Z-fighting in the editor view. So we should NOT crib from WB
here; this is one of the cases where WB and retail diverge and
retail wins.
---
## Pickup prompt for next session
```
Issue #100 — Transparent ground around buildings.
Initial research is done by the prior session (the smoking gun is
retail's zFightTerrainAdjust = 0.01). This session: VALIDATE the
research first, then plan, then implement.
Read first (in this order):
1. docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
(the handoff doc — symptom, retail mechanism, proposed fix
shape, do-not-retry list, files involved)
2. docs/ISSUES.md #100
3. CLAUDE.md — search "currently working toward" to refresh state
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6 follow-up — fix issue #100 visual regression
## Session flow (three phases, in order)
### Phase 1 — Investigate (use the /investigate skill)
Independently verify the handoff's claims before committing to the
fix shape. Specifically:
a. Confirm zFightTerrainAdjust = 0.00999999978 at
docs/research/named-retail/acclient_2013_pseudo_c.txt:1120769
and the nudge-application at line 006b6402. The handoff cites
these — read them yourself and cross-check the surrounding
context.
b. Confirm WorldBuilder renders all 64 cells unconditionally at
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
TerrainGeometryGenerator.cs (handoff says lines 123-141).
c. Read src/AcDream.App/Rendering/Shaders/terrain_modern.vert in
full and confirm line 139 is the right injection point. Check
for any older terrain shader still compiled into the binary
(the handoff flags this as an open question).
d. Check that physics uses the un-nudged Z. Render-vs-physics
split must hold; we cannot let the Z nudge leak into collision.
e. Confirm there's no precedent for glPolygonOffset on terrain
in our codebase (handoff says no, but verify).
Output of this phase: a short report in chat — either "research
confirmed, fix shape stands" or "found X divergence, here's the
revised fix shape." If the research holds, proceed to Phase 2.
### Phase 2 — Plan (use the superpowers:writing-plans skill)
Draft the implementation plan. Expect 3-4 tasks:
Task 1: terrain_modern.vert Z nudge (the one substantive change).
Task 2: delete hiddenTerrainCells / BuildingTerrainCells plumbing
(LandblockMesh.cs, LoadedLandblock.cs, LandblockLoader.cs,
GameWindow.cs call sites, GpuWorldState.cs,
LandblockStreamer.cs). Pure removal — no behavioral
change beyond what Task 1 introduces.
Task 3: delete corresponding tests in LandblockMeshTests +
LandblockLoaderTests that exercise the dead plumbing.
Task 4: visual verification — terrain renders continuously at
Holtburg cottages, no transparent rectangles, no obvious
Z-fighting at building floors.
The handoff doc has a file-by-file action table to seed the plan.
### Phase 3 — Implement (use superpowers:subagent-driven-development)
Execute the plan with fresh subagents per task, two-stage review
between (spec + code quality), final review across all commits.
Pre-flight verification: full focused test suite green. Build clean.
## Constraints
Do-not-retry list in the handoff doc (6 items). Read it before
starting Phase 2.
Visual verification is the acceptance test — the M1.5 milestone is
at stake and any new visual regression in this area would be
obvious. Be honest about what visual verification shows; don't
declare success on partial regressions.
```

View file

@ -1,206 +0,0 @@
# Issue #78 + cellar-stairs visibility culling — investigation report
**Date:** 2026-05-25 PM (continuation session)
**Status:** REPORT-ONLY. Awaiting user (a) camera-rotation falsification test and (b) approach selection before any code work.
**Predecessor handoff:** [`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](2026-05-25-issue-100-shipped-and-culling-handoff.md)
---
## Symptom
Two visible defects share one root cause:
1. **Cellar-stairs (observed 2026-05-25 PM, evidence for #78):** standing in a Holtburg cottage cellar with the camera at certain angles, the outdoor terrain mesh renders as a sharp-edged grass rectangle covering the cellar stair geometry. **Clears when camera moves closer** (cottage walls + stair treads geometrically occlude). Gameplay unaffected — player can walk up/down normally.
2. **Inn-wall stabs (#78, filed 2026-05-19):** standing inside the Holtburg Inn looking at the floor or walls, the user sees other buildings in the distance at their correct world position + scale, visible THROUGH the floor and walls.
The user has NOT yet run the camera-rotation falsification test (Phase 1a of the handoff). Until they do, the diagnosis below is "high confidence" but not certain.
Sibling: **#95** (dungeon portal-graph blowup) is the same visibility subsystem but a different specific failure (over-inclusion). scen5 log shows `visibleCells` per cell reaching **295** (worse than the 135-145 filed).
---
## Hypotheses (ranked)
### H1 — Indoor-camera gate missing on outdoor render passes (HIGH confidence)
**Mechanism:** `TerrainModernRenderer.Draw` and `WbDrawDispatcher` render outdoor geometry unconditionally regardless of whether the camera is inside an EnvCell. Retail and WorldBuilder both gate the outdoor passes by the indoor portal-walk result. acdream does neither.
**Evidence FOR (strong):**
- Retail anchor verified: `PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709` gates `LScape::draw` (outdoor terrain dispatch) by `if (outside_view.view_count > 0)`. `outside_view.view_count` is only incremented during the indoor portal BFS (`PView::ConstructView`) when a portal targets `other_cell_id == 0xFFFFFFFF` (outdoor sentinel). When no portal sees outside, the entire outdoor pass is skipped.
- Retail's per-mesh draw (`RenderDeviceD3D::DrawMesh` line 429245) iterates `Render::PortalList->view_count` and skips meshes that straddle 0 sub-views. **No stencil** — retail uses screen-space polygon clipping via `PView::GetClip`.
- WB anchor verified: `VisibilityManager.RenderInsideOut` (lines 73-239) uses **stencil**: mark current-building portals stencil=1, punch portal regions to far depth, draw EnvCells unconditionally, then `terrain/scenery/statics` gated by `glStencilFunc(Equal, 1, 0x01)`. The top-level loop already skips the unconditional terrain draw via `if (!isInside) terrainManager.Render(...)` at GameScene.cs:965.
- acdream audit verified the gate is missing: `WbDrawDispatcher.cs:360-362` gates by `entity.ParentCellId.HasValue && !visibleCellIds.Contains(...)`. When `ParentCellId == null` (outdoor stabs, scenery, live-spawned entities), the boolean short-circuits to `cellInVis = true` — the entity passes regardless of `visibleCellIds`.
- `TerrainModernRenderer.Draw` (lines 191-208) only does per-slot frustum cull. No `visibleCellIds` parameter, no indoor-camera awareness.
- Patch geometry size (~24 m × 24 m rectangle) matches a terrain cell footprint — that's a polygon, not a precision artifact.
- "Clears when closer" matches geometric occlusion: cottage walls + stair treads come to occlude the offending terrain cells screen-space as the camera approaches. A 1 cm depth-buffer Z-fight (#100's nudge) at 2-5 m camera distance with 24-bit depth has sub-millimeter resolving power; precision is not the bottleneck.
**Evidence AGAINST:**
- User has not yet run the camera-rotation test. If the patch flickers/shimmers when rotating the camera in place, the diagnosis pivots to Z-precision.
**How to falsify:** Stand at the spot showing the cellar-stairs artifact, look at the grass patch, rotate the camera slowly without moving the character. Polygon-stable edges that track predictably with the view = culling (H1). Flickering / shimmering = Z-precision (H2).
### H2 — Residual Z-fight from #100's nudge (LOW confidence)
The 1 cm shader nudge from issue #100 might be insufficient at certain Z values or with shader precision quirks.
**Evidence FOR:** Same code area was just touched.
**Evidence AGAINST:** Predecessor research already established 1 cm @ 24-bit depth has sub-mm resolving at gameplay camera distances. Patch is rectangular polygon, not thin Z-fight strip. "Clears when closer" reverses precision direction.
**How to falsify:** Same camera-rotation test.
### H3 — #95 portal-traversal blowup is independent of H1 (HIGH confidence it IS independent)
**Mechanism:** `CellVisibility.GetVisibleCells` BFS over portals lacks termination/cap-depth logic. Network hubs expose 100+ outbound portals to disconnected dungeons, all marked visible. scen5 log shows up to 295 cells in one visible set.
**Evidence FOR independence:**
- H1 is an **asymmetric over-render** (outdoor passes ignore indoor state).
- H3 is a **symmetric over-inclusion** (BFS doesn't terminate properly).
- A fix to H1 would gate WHEN to render outdoor; H3's fix is to bound WHICH indoor cells the BFS includes.
- Different code paths: H1 lives in `TerrainModernRenderer.Draw` + `WbDrawDispatcher`; H3 lives in `CellVisibility.GetVisibleCells`.
**Conclusion:** H1 and H3 should be **separate fixes**. Closing H1 will close cellar-stairs + the outdoor-stab side of #78 but NOT close #95. The next phase should plan H1 in scope and decide whether H3 fits in the same milestone (M1.5).
---
## What we've ruled out
- **It's not the #100 cell-collapse bug returning.** `hiddenTerrainCells` plumbing was fully removed in `a64e6f2`; terrain mesh now correctly renders everywhere on the landblock per retail. The new artifact's mechanism is "outdoor geometry visible at all when indoor," not "incorrect terrain mesh shape."
- **It's not a depth-precision issue (high confidence, pending falsification).** Patch shape + "clears closer" both contradict Z-fight.
- **It's not a `ParentCellId` propagation bug.** Audit confirmed that interior cell static objects (`GameWindow.BuildInteriorEntitiesForStreaming:5476`) and cell-mesh entities (line 5416) both receive non-null `ParentCellId = envCellId`. The dispatcher's existing filter already correctly culls them when the camera is in a different building. The bug is the OPPOSITE direction (outdoor entities w/ `ParentCellId == null` always pass).
- **It's not WB extraction divergence.** Phase O extracted ~33 WB files into `src/AcDream.App/Rendering/Wb/` but the `VisibilityManager` / `RenderInsideOut` pipeline was NOT extracted — that code never existed in our tree.
- **It's not a missing camera-cell signal at the render layer.** `cameraInsideCell`, `visibility.VisibleCellIds`, and `visibility.HasExitPortalVisible` are all already computed in `GameWindow.cs:6970-6984` and live in scope at the two `Draw` call sites (lines 7074 + 7110). No new plumbing required.
---
## Approach options for the fix
Three viable approaches, with tradeoffs:
### Approach A — WB-style stencil (recommended for first ship)
Port `VisibilityManager.RenderInsideOut`'s stencil pipeline to acdream. Two-pass render: (1) mark current-building portal silhouettes in stencil, (2) gate outdoor passes by `glStencilFunc(Equal, 1, 0x01)`.
**Pros:**
- Closest to acdream's existing modern GL pipeline (we already use stencil for nothing else; adding one stencil bit is cheap).
- WB is acdream's documented rendering base (per CLAUDE.md). Cross-reference checked against retail confirms WB's intent matches retail's, just via a different mechanism.
- Handles the "see outside through open door" case correctly — terrain renders through portal silhouettes only.
- Reusable for both outdoor terrain AND outdoor entities (single stencil gate applies to all subsequent draws).
**Cons:**
- Multi-pass render adds GPU cost (small — one stencil pass per current-building's portals).
- Requires a portal-mesh upload pipeline (WB has one in `PortalRenderManager.cs:488-628`; we'd port it).
- More LOC than Approach C.
**Estimated scope:** 4-6 tasks, 1-2 weeks of implementation + verification.
### Approach B — Retail-faithful polygon-clip sub-views
Port `PView::ConstructView` + `PView::GetClip` + `Render::PortalList` from retail. Per-mesh viewport set to clipped portal polygon.
**Pros:**
- 100% retail-faithful.
**Cons:**
- Requires per-draw viewport scissor changes — current rendering uses bindless + MDI with one viewport per pass. Wedging per-mesh viewport in would break the modern pipeline's batching.
- Multi-week port. Out of scope for one session.
**Estimated scope:** 8-12 tasks, 4-6 weeks. Defer to a future milestone if needed.
### Approach C — Ship-now binary gate
When `cameraInsideCell && !visibility.HasExitPortalVisible`, skip outdoor terrain pass entirely and gate `WbDrawDispatcher` to exclude `ParentCellId == null` entities.
**Pros:**
- Smallest change. ~2-3 tasks. Closes the cellar-stairs symptom and the sealed-interior side of #78 immediately.
- All required state already computed (`HasExitPortalVisible` from `CellVisibility.GetVisibleCells` line 404).
**Cons:**
- Under-renders when player can see outside through an open door/window (renders nothing instead of clipping correctly). This is regressive vs. today for the doorway-view case.
- Per CLAUDE.md "no workarounds": this *is* a symptom-gate rather than a root-cause fix. **Would need explicit user approval.** Approach A is the correct shape; Approach C is a temporary patch.
**Estimated scope:** 2-3 tasks, 1-2 days.
---
## Recommended next step
1. **User runs the camera-rotation falsification test (~60 seconds).** Spawn at Holtburg, walk into a cottage cellar, find the angle showing the grass patch, rotate the camera in place without moving. Report what happens.
- Polygon-stable → confirms H1, proceed.
- Flickering → pivots to H2, this report needs major revision.
2. **If H1 confirmed: user picks Approach A vs C.** Recommendation: **Approach A (WB-style stencil)**. Per CLAUDE.md's "no workarounds" rule, the right thing is to port the stencil pipeline, not gate at the symptom site. Approach C is offered only if the user wants to close cellar-stairs immediately and defer doorway-view correctness as known-incomplete; that's an explicit workaround that needs user sign-off.
3. **#95 should NOT be in scope for this work.** Different mechanism, different code path. File continues as separate work in M1.5.
4. **Phase identifier:** the handoff proposes A8 (visibility) alongside A6 (physics) and A7 (lighting). I'll defer naming to the user.
5. **CLAUDE.md update for #100 ship:** the handoff calls this out as pending. Recommendation: add a brief #100 ship entry mentioning the cellar-stairs finding linked to #78. Out of scope for investigate mode; will happen at the start of the implementation session.
---
## What this is NOT
This is NOT a #100 regression. The terrain Z-nudge ship works correctly; the new artifact has a different root cause (indoor-camera gate on outdoor passes was already missing pre-#100#100 just made it more visible by removing the terrain-cell hide mechanism that incidentally masked it inside building footprints).
This is NOT a depth-precision fix. The 1cm nudge is correctly sized; larger nudges would break coplanar-floor disambiguation elsewhere.
This is NOT a `ParentCellId` data fix. Interior entities are correctly tagged.
This is NOT covered by Phase O's WB extraction. The visibility-management code was deliberately NOT extracted.
---
## Reference appendix
### Retail anchors (acclient_2013_pseudo_c.txt)
| Line | Symbol | Role |
|---|---|---|
| 92635 | `SmartBox::RenderNormalMode` | Per-frame top-level dispatcher (indoor vs outdoor branch) |
| 267912 | `LScape::draw` | Outdoor terrain dispatch |
| 311397 | `CEnvCell::find_visible_child_cell` | Point-in-visible-cell query |
| 311878 | `CEnvCell::grab_visible_cells` | Loads outdoor on `seen_outside` |
| 427843 | `RenderDeviceD3D::DrawInside` | Indoor entry point |
| 429245 | `RenderDeviceD3D::DrawMesh` | **Per-mesh portal-sub-view loop** |
| 430027 | `RenderDeviceD3D::DrawBlock` | Outdoor landblock dispatch |
| 432709 | **`PView::DrawCells`** | **The `outside_view.view_count > 0` gate** |
| 433750 | `PView::ConstructView` | BFS portal walk |
### WorldBuilder anchors
| File:Line | Role |
|---|---|
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` | `RenderInsideOut` — full stencil pipeline |
| Same file:241-359 | `RenderOutsideIn` — outdoor branch |
| Same file:47-71 | `PrepareVisibility` — visible cell set |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs:880-1008` | Main render dispatch (lines 965, 988 are the gates) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628` | Portal mesh upload |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/CameraController.cs:142-174` | Camera-cell tracking (portal raycasts) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/PortalStencil.frag:7-16` | Stencil shader (writes `gl_FragDepth = 1.0`) |
### acdream extension points (audit-verified)
| File:Line | Current behavior | Extension required |
|---|---|---|
| `src/AcDream.App/Rendering/CellVisibility.cs:222-232` | Returns `VisibilityResult` with `VisibleCellIds`, `HasExitPortalVisible`, `CameraCell` | None — state already in place |
| `src/AcDream.App/Rendering/GameWindow.cs:6970-6984` | Computes `cameraInsideCell` and `playerInsideCell` per frame | None — values already in scope |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:360-374` | Gates by `ParentCellId ∈ visibleCellIds`; outdoor entities (null) always pass | Add second gate: when `cameraInsideCell == true` and entity is outdoor (`ParentCellId == null`), require stencil pass or skip entirely |
| `src/AcDream.App/Rendering/TerrainModernRenderer.cs:191-208` | Frustum-only cull; renders all loaded landblocks | Add parameter for stencil pass / indoor-camera state |
| `src/AcDream.App/Rendering/GameWindow.cs:7074` | `_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb)` | Add `cameraInsideCell` (or equivalent) parameter |
| `src/AcDream.App/Rendering/GameWindow.cs:7110` | `WbDrawDispatcher.Draw(... visibleCellIds: visibility?.VisibleCellIds, ...)` | Add `cameraInsideCell` parameter |
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs:75-77` | Existing probe flag registry (mirror of `PhysicsDiagnostics`) | Add `ProbeVisibilityEnabled` from `ACDREAM_PROBE_VIS=1` |
### Issues family map
| ID | Symptom | Closes with H1 fix? |
|---|---|---|
| #78 | Outdoor stabs visible through inn floor/walls | YES (same root cause) |
| Cellar-stairs (NEW) | Outdoor terrain visible inside cottage cellar | YES (same root cause; new evidence for #78) |
| #95 | Portal-graph visibility blowup (visibleCells up to 295) | NO — independent (different code path) |
| #79/#80/#81/#93/#94 | Indoor lighting bugs | Maybe — #93 explicitly suspects "indoor visibility culling for lights" sub-cause; lighting subsystem may share infrastructure with visibility-gate but not directly impacted |
### Workflow notes (from CLAUDE.md "How to operate")
- "No workarounds without explicit approval" — Approach C is a workaround; Approach A is the correct shape.
- Visual verification is the user's job; can't be automated.
- Phase ID for visibility work is undecided. User picks at implementation-session start.
- Per the milestones doc, this is M1.5 scope; cellar-stairs is on the M1.5 critical path because it blocks the building/cellar half of the M1.5 demo.

View file

@ -1,339 +0,0 @@
# M1.5 — Broken stairs (cyl-only multi-part entity) — investigation handoff
**Date:** 2026-05-25 PM
**Status:** Filed as issue #101 (post-A6.P7 visual verification surfaced a NEW
bug, not the closed door bug). **Research-only next session.** No
implementation until we know what retail does at this exact stair location.
**Predecessor handoff:** [`2026-05-25-a6-door-cyl-investigation-handoff.md`](2026-05-25-a6-door-cyl-investigation-handoff.md)
(closed by A6.P7 commit `888272a`).
---
## TL;DR
A6.P7 visual verification at Holtburg confirmed the cottage door is fixed.
While exploring, the user found **a different staircase that doesn't work**
sphere can't climb at all. Captures show:
- Stairs are in cells `0xA9B40159` + `0xA9B4015A` (NOT the cottage-cellar
cells `0xA9B40143/146/147` that work post-A6.P3 cellar fix).
- Geometry is a **multi-part entity** `0x0040B500` (entityId; ~150 parts in
the setup; 10 of them are stair-step cylinders).
- Each step is a separate cylinder (`r=0.80m, h=0.80m`) at `Y=26.60`, stepping
up in X and Z (0.25 m per step, Z: 94.22 → 96.47).
- `state=0x00000000` on each cyl part — **no `HAS_PHYSICS_BSP_PS` flag**, so
A6.P7's dispatch gate (`Transition.BspOnlyDispatch`) does NOT skip them.
- The cyls fire 284 `result=Slid` with diagonal radial normals like
`(0.88, -0.47, 0)` — the same phantom shape A6.P7 closed for the cottage
door, but here the cause is per-cyl-without-BSP, not per-entity-with-both.
- **Player Z stayed at 94.00 for the entire 4216-record capture** — never
gained altitude.
This is **NOT** a regression of A6.P7. The fix did exactly what retail does
for entities with `HAS_PHYSICS_BSP_PS`. The stair bug is a separate class:
**cyl-only entities (no BSP) whose cyl geometry shouldn't physically block
the player but does.**
---
## What today shipped (DO NOT redo)
### A6.P7 — retail-binary cyl/BSP dispatch (commit `888272a`)
- File: `src/AcDream.Core/Physics/PhysicsBody.cs` (added
`PhysicsStateFlags.HasPhysicsBsp = 0x00010000`)
- File: `src/AcDream.Core/Physics/TransitionTypes.cs` (added
`Transition.BspOnlyDispatch(uint)` predicate + per-entry guard at the
cyl/sphere branch)
- Test: `tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs` (7 tests)
- Investigation:
[`docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md`](2026-05-25-a6-door-cyl-retail-dispatch-investigation.md).
- **Visual-verified at Holtburg cottage door 2026-05-25.** Captures:
`launch-a6p7.log`, `launch-a6p7-v2.log` — 1187 `[cyl-skip-bsp]`, 0
`[cyl-test]` on the door, 30 axis-aligned hits, no phantom diagonals.
---
## The new bug — captures + evidence
### Captures (on disk, gitignored — DO NOT commit them; treat as live data)
- **Working baseline** (cellar stairs that work): `stairs-working.jsonl`
(16.9 MB, ~22K records). Z range 90.95 ↔ 94.00 (full cellar climb). 12
cell transitions; only 23 `hit=yes` events; no diagonal normals; user
ran up + down twice. Cells `0xA9B40143/146/147`.
- **Broken stairs**: `stairs-broken.jsonl` (8.1 MB, 4216 records). Z stayed
at 94.00 for the entire capture. Cells `0xA9B40159` + `0xA9B4015A`. The
player tried multiple approach angles; never climbed any step.
- **Launch logs with probes**: `stairs-working.launch.log`,
`stairs-broken.launch.log`. Contain `[cyl-test]`, `[cyl-skip-bsp]`,
`[bsp-test]`, `[resolve]`, `[resolve-bldg]` probe lines.
### Reproduction
Login as `+Acdream` at Holtburg. The cellar stairs work (verified). The
broken stairs the user found are at world XY around (110, 26), Z range
94 → 96. Walk west into them — sphere hits something diagonal and gets
stuck oscillating between `n=(0, 1, 0)` and `n=(0.87, -0.49, 0)` slides.
### Geometry summary (from `stairs-broken.launch.log`)
The blocker is multi-part entity `entityId=0x0040B500`. Ten of its parts
are cylinders forming a staircase at `Y=26.60`:
| Part | World XY | Z (cyl bottom) |
|---|---|---|
| `0x40B5008C` (part 140) | (108.72, 26.60) | 96.47 |
| `0x40B5008D` (part 141) | (108.97, 26.60) | 96.22 |
| `0x40B5008E` (part 142) | (109.22, 26.60) | 95.97 |
| `0x40B5008F` (part 143) | (109.47, 26.60) | 95.72 |
| `0x40B50090` (part 144) | (109.72, 26.60) | 95.47 |
| `0x40B50091` (part 145) | (109.97, 26.60) | 95.22 |
| `0x40B50092` (part 146) | (110.22, 26.60) | 94.97 |
| `0x40B50093` (part 147) | (110.47, 26.60) | 94.72 |
| `0x40B50094` (part 148) | (110.72, 26.60) | 94.47 |
| `0x40B50095` (part 149) | (110.97, 26.60) | 94.22 |
Each cyl: `radius=0.80, height=0.80, state=0x00000000`. The entity also
has a BSP part `obj=0xB5008900 gfx=0x01000C16 radius=2.645 pos=(109.30,
26.30, 95.75)` but it's effectively non-physics
(`hasPhys=False bspR=0.00 vAabbR=0.82`) — the `vAabbR` here is the
**visual** AABB radius being borrowed as a cylinder fallback because the
underlying `GfxObj` has no physics BSP.
### What's blocking the player
Sphere at `(112.115, 25.995, 94.00)` wants to move west. The closest cyl
`0x40B50095` is at `(110.97, 26.60, 94.22)`:
- `distXY = 1.295m` (just barely outside reach `0.80 + 0.48 = 1.28m`)
- But during sub-stepping the sphere center crosses 1.28m → cyl overlaps
- Radial normal direction from cyl center to sphere: `(0.884, -0.467, 0)`
matches observed phantom hits `(0.88, -0.47)`, `(0.86, -0.51)`, etc.
The cyl is **too tall (0.80m) to step over** under A6.P6's grounded
step-over check (step-up budget = 0.60m). Falls through to the
wall-slide branch which produces the diagonal radial normal that drives
the sphere's slide tangent into the perpendicular cell wall, then
re-blocks. Net: stuck.
### Why A6.P7 doesn't help
A6.P7 gates the cyl branch on `(state & 0x10000) != 0`. These stair cyls
have `state=0x00000000` — bit not set. Guard does NOT fire. Cyls are
tested. Sphere blocks.
---
## What this session needs — retail investigation
**Mandate:** report-only research, NO implementation. Use the `/investigate`
skill. The fix design comes in a subsequent session once the retail
behavior is settled.
### Question 1 — What does retail DO at this exact staircase?
**Use cdb.** The toolchain in `CLAUDE.md` "Retail debugger toolchain" is
ready. The matching binary + PDB are verified.
Concrete experiment:
1. Have the user run the retail acclient.exe (Microsoft AC official build
v11.4186) at the same world location (cells `0xA9B40159` + `0xA9B4015A`,
XY ≈ (110, 26)). The user needs to be IN the building, AT the foot of
these stairs.
2. Attach cdb with breakpoints:
- `acclient!CCylSphere::collides_with_sphere` at `0x53a880` — counter
`$t0`, log every 100 hits with the `this` pointer and the moving
sphere's position, `gc`. Auto-detach after 5000.
- `acclient!CCylSphere::intersects_sphere` (the dispatch from
`CPhysicsObj::FindObjCollisions` cyl branch) — counter `$t1`, log
entity address.
- `acclient!CObjCell::find_env_collisions` — counter `$t2`. Tells us if
retail uses cell BSP for stair collision.
- `acclient!CPartArray::FindObjCollisions` — counter `$t3`. Confirms BSP
dispatch path.
3. Have the user walk straight into the broken stairs from outside, then
try to climb them. Capture 30 seconds.
4. Detach. Analyze:
- Does `CCylSphere::collides_with_sphere` fire on the stair entity? If
yes → retail's cyls ARE active here, and retail somehow handles them
differently (different step-up threshold? cell-context-aware?). If
no → retail's cyls are excluded by something we don't replicate.
- Does `CObjCell::find_env_collisions` fire heavily? If yes → retail
might be using cell BSP polygons for the stairs (and the entity cyls
are decorative/click-targets only).
### Question 2 — What's the Setup ID? Compare retail's PhysicsObj construction
Our `[resolve-bldg]` lines show the entity is built from GfxObj
`0x0100081A` with `hasPhys=False`. **What's the Setup ID for entity
`0x0040B500`?** Trace through our streaming code to find which Setup
emitted the 150-part build.
Steps:
1. Grep `src/AcDream.App/Rendering/GameWindow.cs` for the
`BuildInteriorEntitiesForStreaming` path (CLAUDE.md says it hydrates
EnvCell static objects with id `0x40xxxxxx`).
2. Add a temporary `[entity-source]` probe that logs the Setup id when an
entity gets registered. Or check existing diagnostic output — the
`gfxObj=0x0100081A` is the part's GfxObj, but we need the parent Setup.
3. With the Setup id in hand, look up retail's behavior:
- Decompile / grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`
for `CPhysicsObj::InitPartArrayFromSetup` or similar to see how retail
builds the part_array from a Setup. Does retail include every part as
a collision shape, or filter by some flag?
### Question 3 — Why is `vAabbR` becoming a cylinder?
The `[resolve-bldg]` line shows `gfxObj=0x0100081A hasPhys=False bspR=0.00
vAabbR=0.82`. We registered a `r=0.80` cyl. The 0.80 ≈ 0.82 match is
suspicious — we're using the **visual AABB radius** as a fallback cyl
radius when there's no physics BSP.
Steps:
1. Find the code path in our tree that does this fallback. Likely in
`src/AcDream.Core/Physics/ShadowShapeBuilder.cs` `FromSetup` or in
`RegisterMultiPart`. Look for cases where `GfxObj.PhysicsBSP` is null
and a cyl is synthesized.
2. Cross-reference retail: does retail synthesize a cyl from visual bounds
when physics is null? Or does retail skip such parts entirely for
collision (visual-only)?
3. ACE check: `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs`
how does ACE construct the part_array from a Setup with mixed
physics/visual-only parts?
### Question 4 — Cell BSP fallback
If retail's stairs are walked via cell BSP polygons (not entity cyls),
what's in cell `0xA9B40159`'s BSP at this XY/Z? Is there a walkable
polygon staircase that we're not iterating?
Steps:
1. Use `ACDREAM_DUMP_CELLS=0xA9B40159,0xA9B4015A` to dump the cell BSPs to
JSON. (Confirm the env var path; see existing `CellDump` infra near
issue #98's apparatus.)
2. Look for inclined polygons in the dump that form the staircase. If
present → retail likely uses these for collision; our entity cyls are
either a setup misinterpretation or redundant.
---
## Files to read FIRST next session
| Path | Why |
|---|---|
| `docs/ISSUES.md` (#101) | The filed issue with severity + acceptance |
| `docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md` | A6.P7 background (closed; companion bug) |
| `docs/research/named-retail/acclient_2013_pseudo_c.txt:276776` | `CPhysicsObj::FindObjCollisions` |
| Setup dat reader path in `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | Cyl synthesis from Setup; the suspected fallback |
| `src/AcDream.App/Rendering/GameWindow.cs::BuildInteriorEntitiesForStreaming` | Entity hydration for EnvCell statics |
| `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | ACE PartArray construction |
| `references/ACE/Source/ACE.Server/Physics/Common/Setup.cs` | ACE Setup → PartArray pipeline |
---
## Tests that must stay green
Same as A6.P7 list:
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build -c Debug --filter "FullyQualifiedName=AcDream.Core.Tests.Physics.CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.CornerSlide_AlcoveEastToCottageNorth_ShouldBlock|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Geometric_DoorSlabAtSphereHeight_OverlapsInZ|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.InsideOut_Tick3254_WithCottageWalls_ShouldBlock|FullyQualifiedName~BSPQueryTests.FindCollisions_Path5|FullyQualifiedName~CellTransitTests.A6P5|FullyQualifiedName~DoorCollisionApparatusTests.Apparatus_DeadCenter|FullyQualifiedName~A6P7DispatchRulesTests"
```
Expected: 20/20 pass.
---
## Things NOT to do (do-not-retry)
1. **Don't lower step-up height** to make A6.P6's grounded step-over fit the
0.80m cyl. Step-up budget = 0.60m is retail-faithful. Tweaking it would
regress every other surface where 0.60m is correct (curbs, low ledges).
2. **Don't extend A6.P7's `BspOnlyDispatch` to entities with `state=0`.**
That gate is retail-specific (`HAS_PHYSICS_BSP_PS`). Skipping cyls
purely because peer parts exist with BSP would diverge from retail and
break NPC cyl-only entities.
3. **Don't disable cyl fallback when `hasPhys=False` without checking
retail.** Until we know how retail handles `GfxObj` with no physics
BSP, "just skip the cyl" might break other content (small decorative
items that DO collide in retail).
4. **Don't add per-entity workarounds** ("if entity id 0x0040B500, skip
cyls"). Per CLAUDE.md no-workarounds rule.
5. **Don't enlarge the sphere's step-up budget for tall cyls.** Retail's
threshold is what it is. If retail steps over 0.80m cyls in this
scenario, the mechanism is something else.
---
## Three fix-shape candidates (for the FOLLOWING session, not this one)
Listed in rough order of retail-faithfulness based on the limited evidence
we have. The retail investigation will decide which is right.
1. **Don't synthesize cyls from visual AABB when `GfxObj.PhysicsBSP` is
null.** Suppress at registration time in `ShadowShapeBuilder.FromSetup`.
Retail-anchored: if retail's `CPartArray` doesn't include such parts in
the collision list, our registration shouldn't either. The cell BSP
would then be the only collision source.
2. **Use cell BSP polygons** for stair geometry; entity cyls are
decorative-only for this entity class. Requires: (a) confirming cell
`0xA9B40159` BSP has walkable stair polys, (b) ensuring our cell BSP
query iterates them. Likely a no-op on our side once (1) is done.
3. **Make `step_sphere_up` cyl-height-tolerant** — if the sphere is on a
walkable plane and a cyl is detected, attempt step-up even when cyl
height > step-up budget IF a walkable surface exists at the top of the
cyl. Retail-anchored ONLY if cdb shows retail does this on these
specific stairs.
---
## Pickup prompt for next session
```
A6 — Broken stairs cyl investigation (issue #101). Investigation-only session.
Read first (in this order):
1. docs/research/2026-05-25-stairs-cyl-investigation-handoff.md
(this file — full context, captures, geometry, do-not-retry list)
2. docs/ISSUES.md #101
3. docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md
(A6.P7 background — closed; companion bug)
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6 — broken-stairs investigation (issue #101)
Session mandate: retail investigation, NOT implementation. Use the
/investigate skill. Specific questions (each must be answered with cited
evidence — retail line numbers, cdb traces, dat dumps):
1. Does retail's CCylSphere::collides_with_sphere fire on the stair-step
cylinders at cells 0xA9B40159/0xA9B4015A when a player walks in to
climb them? If yes — how does retail walk past 0.80m-tall cyls? If
no — what excludes them?
2. What's the Setup ID for entity 0x0040B500? Trace from
GameWindow.cs::BuildInteriorEntitiesForStreaming. Cross-reference how
retail's CPhysicsObj::InitPartArrayFromSetup (or equivalent) builds
the collision shape list — does retail include parts with
hasPhys=False?
3. Why does our ShadowShapeBuilder synthesize an r=0.80 cyl from
vAabbR=0.82 when GfxObj.PhysicsBSP is null? Identify the code path.
Does retail do this?
4. Dump cell 0xA9B40159's BSP polygons (ACDREAM_DUMP_CELLS). Does the
cell BSP have walkable stair polygons? If yes — retail's stair
collision is the cell BSP, not the entity cyls.
Deliverable: a short report (~2-3 pages) covering the 4 questions with
retail line numbers, cdb trace excerpts, code citations. Then propose
which of the 3 fix-shape candidates is most retail-faithful (or a fifth
shape that emerges from the research).
DO NOT implement the fix this session. Save it for the session after.
Do-not-retry list (in handoff doc) — read it before starting.
Tests to keep green if any code changes happen (none expected this
session): see handoff doc.
Reproduction setup for the broken scenario:
ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1
ACDREAM_CAPTURE_RESOLVE=<path>.jsonl
walk to cells 0xA9B40159/A in Holtburg (XY ≈ 110, 26)
```

View file

@ -1,402 +0,0 @@
# Phase A8 RR2 — `BuildingInfo` data shape + interior-portal walk
**Date:** 2026-05-26 (PM, RR2 spike)
**Predecessor:** [docs/research/2026-05-26-a8-wb-full-port-rr1-shipped-handoff.md](2026-05-26-a8-wb-full-port-rr1-shipped-handoff.md)
**Successor:** RR3 — `Building` + `BuildingRegistry` + `BuildingLoader` implementation
**Status:** SHIPPED — gate at end says "compatible → proceed to RR3"
## TL;DR
The DRW v2.1.7 `BuildingInfo` type exposes everything the design needs, with the field names already used elsewhere in our codebase. WB's `PortalService.GetPortalsByBuilding` (referenced by `PortalRenderManager.GeneratePortalsForLandblock` at lines 488-561) implements a clear BFS walk that translates 1:1 to our `LoadedCell.Portals` graph. Gate decision: **data shape compatible — proceed to RR3.**
Two refinements vs the plan's RR3 pseudocode worth noting (both are minor wording fixes, not algorithm changes):
1. The DRW type for each entry of `BuildingInfo.Portals` is `BuildingPortal` (NOT `BldPortal` as the plan's RR3-S9 test file uses). Plan's RR3 tests should rename `new BldPortal { ... }``new BuildingPortal { ... }`.
2. The exit-portal sentinel `0xFFFF` is the **value of `OtherCellId`** itself (ushort), not "low word of a 32-bit value." Plan code already treats it correctly.
## 1. `BuildingInfo` field shape (DRW v2.1.7)
Verbatim from `ilspycmd "%USERPROFILE%\.nuget\packages\chorizite.datreaderwriter\2.1.7\lib\net8.0\DatReaderWriter.dll" -t DatReaderWriter.Types.BuildingInfo`:
```csharp
namespace DatReaderWriter.Types;
public class BuildingInfo : IDatObjType, IUnpackable, IPackable
{
/// <summary>Either a SetupModel (0x02xxxxxx) or GfxObj (0x01xxxxxx) id.</summary>
public uint ModelId;
/// <summary>The position information (Origin: Vector3, Orientation: Quaternion).</summary>
public Frame Frame;
public uint NumLeaves;
public List<BuildingPortal> Portals = new List<BuildingPortal>();
}
```
Note: **fields, not properties**. All four are mutable but in practice are only populated by `Unpack(DatBinReader)` during dat-file load. `Portals` is initialized inline (never `null`).
`BuildingPortal` (same DLL):
```csharp
public class BuildingPortal : IDatObjType, IUnpackable, IPackable
{
public PortalFlags Flags; // 16-bit enum (PortalFlags.ExactMatch = 0x0001)
public ushort OtherCellId; // LOW WORD of cell id; landblock prefix ORs in
public ushort OtherPortalId;
public List<ushort> StabList = new List<ushort>();
}
```
`Frame` (lives at `DatReaderWriter.Types.Frame`, also a field-based class):
```csharp
public class Frame : IUnpackable, IPackable
{
public Vector3 Origin;
public Quaternion Orientation;
}
```
### What our codebase already does with this
In `src/AcDream.Core/World/LandblockLoader.cs:74-89`, the post-Phase-2 loop already iterates `info.Buildings` and consumes `building.ModelId`, `building.Frame.Origin`, `building.Frame.Orientation`. Plain field access — no surprises.
In `src/AcDream.App/Rendering/GameWindow.cs:5789-5803`, the indoor portal cell-tracking phase already builds our internal `BldPortalInfo` from `BuildingInfo.Portals`. The construction confirms the field types in practice:
```csharp
foreach (var building in lbInfo.Buildings)
{
if (building.Portals.Count == 0) continue; // .Count works → IList
foreach (var bp in building.Portals)
{
bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
otherCellId: lbPrefix | (uint)bp.OtherCellId, // ushort → uint cast
otherPortalId: bp.OtherPortalId, // ushort
flags: (ushort)bp.Flags)); // PortalFlags → ushort cast
}
}
```
Conclusion: every field the RR3 design assumes already exists with the assumed semantics; the existing physics phase has been consuming them since 2026-05-19.
## 2. Holtburg cottage `BuildingInfo` — live dump
Live-inspect via `Console.WriteLine` diagnostic at `LandblockLoader.cs:74-89` (reverted after capture, see git diff in this commit's parent). Login at `+Acdream` (server guid `0x5000000A`, pos `(131.7, 26.1, 94.0) @ 0xA9B4002A`); the diagnostic fired for every landblock streamed during initial entry. Captured to `a6-rr2-s3-buildings.log` (gitignored — not committed).
### Holtburg town landblock `0xA9B4FFFF` — 12 BuildingInfo entries
```
idx=0 ModelId=0x01000C1E Frame.Origin=(84.1,131.5,66.0) NumLeaves=64 Portals=10
portal -> OtherCellId=0x0100 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0100 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0100 OtherPortalId=0x0005 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0103 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0102 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0106 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0107 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x0109 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x010A OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
portal -> OtherCellId=0x010B OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
idx=1 ModelId=0x01000BC3 Frame.Origin=(31.5,159.5,66.0) NumLeaves=36 Portals=3
portal -> OtherCellId=0x0113 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=5
portal -> OtherCellId=0x0114 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=5
portal -> OtherCellId=0x0115 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=5
idx=2 ModelId=0x0100082E Frame.Origin=(154.1,132.7,66.0) NumLeaves=30 Portals=4
portal -> OtherCellId=0x0116 OtherPortalId=0x0001 Flags=0x0003 StabList.Count=9
portal -> OtherCellId=0x0118 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=9
portal -> OtherCellId=0x0119 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=9
portal -> OtherCellId=0x011D OtherPortalId=0x0001 Flags=0x0001 StabList.Count=9
idx=3 ModelId=0x01000830 Frame.Origin=(104.5,135.5,66.0) NumLeaves=31 Portals=5
portal -> 0x011F, 0x0120 (F=0x0003), 0x0122, 0x0124, 0x0125 — all StabList.Count=8
idx=4 ModelId=0x01000827 Frame.Origin=(57.5,133.5,66.0) NumLeaves=101 Portals=8
portal -> 0x012D, 0x0133, 0x0134, 0x0135, 0x0129, 0x012B, 0x012C, 0x0137 — all StabList.Count=17
idx=5 ModelId=0x0100081C Frame.Origin=(132.5,154.0,66.0) NumLeaves=34 Portals=4
portal -> 0x0139, 0x013B, 0x013C, 0x013D — all StabList.Count=7
idx=6 ModelId=0x01000A2B Frame.Origin=(130.5,11.5,94.0) NumLeaves=29 Portals=5
portal -> 0x0145, 0x014C, 0x014E, 0x014F, 0x0150 — all StabList.Count=18
[cottage from issue #98 cellar saga; entry cells 0x0145 + cellar cells 0x014C-0x0150]
idx=7 ModelId=0x01000C17 Frame.Origin=(107.5,36.0,94.0) NumLeaves=39 Portals=3
portal -> 0x0164, 0x0165, 0x015E — all StabList.Count=25
[Holtburg Inn vestibule + ground floor]
idx=8 ModelId=0x01000BC3 Frame.Origin=(79.5,37.5,94.0) NumLeaves=36 Portals=3
portal -> 0x016C, 0x016D, 0x016E — all StabList.Count=5
idx=9 ModelId=0x01002232 Frame.Origin=(161.9,7.5,94.0) NumLeaves=209 Portals=2
portal -> 0x016F (F=0x0003), 0x0170 (F=0x0003) — all StabList.Count=7
[largest building, 209 leaves — probably a multi-floor structure or unique Holtburg landmark]
idx=10 ModelId=0x01002A1B Frame.Origin=(65.2,156.6,66.0) NumLeaves=48 Portals=2
portal -> 0x0178, 0x0177 — both F=0x0003 StabList.Count=3
idx=11 ModelId=0x01000F69 Frame.Origin=(158.2,37.7,94.0) NumLeaves=43 Portals=1
portal -> 0x0179 (F=0x0003) StabList.Count=2
[single-portal building — likely a simple shed / outhouse]
```
### What the dump confirms
| Confirmation | Evidence |
|---|---|
| Every `BuildingInfo.Portals` has `.Count > 0` (no empty buildings observed) | All 12 idx entries Portals=1..10 |
| Every `OtherCellId` is a real interior cell (none == `0xFFFF`) | Inspected 60 portal lines; all OtherCellId in 0x0100-0x0179 range |
| `Flags` values: `0x0001` (ExactMatch) for ~85% of portals; `0x0003` (ExactMatch + bit 1) for ~15% — likely the "exterior-facing portal side" bit | Per `DatReaderWriter.Enums.PortalFlags`: `ExactMatch = 0x0001`; bit 1 is `Side` per our `BldPortalInfo` ctor (which already handles it) |
| All `ModelId` values are GfxObjs (`0x01xxxxxx`) — NO Setups in Holtburg | Matches `LandblockLoader.IsSupported` (currently accepts both — Setup ids would survive the filter if they appeared) |
| `NumLeaves` correlates with building size — 209 for the largest, 29 for a small cottage | The DRW field is metadata; we don't consume it in `BuildingLoader` (only WB's offscreen mesh path uses it) |
| `StabList` populated (3-25 entries per portal) — indices into `LandBlockInfo.Objects` for the stabs (decorations) inside the building's cells | Not used by `BuildingLoader`; informational only |
| Cottage at idx=6 matches the issue #98 cellar saga's geometry: `Frame.Origin=(130.5,11.5,94.0)` is the cottage entry cell `0xA9B40145`; cellar cells `0xA9B4014C/014E/014F/0150` are reached via interior portals (this is exactly the BFS walk WB does in §3) | Cross-ref `docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md` |
### Implications for `BuildingLoader`
1. **No empty-portal building edge case in production data** — the `envCellIds.Count == 0` short-circuit at the end of Step C will essentially never fire for Holtburg. Still wire it (matches WB §89 + handles future content with empty buildings).
2. **Defensive `if (portal.OtherCellId == 0xFFFF) continue` in Step A** — never fires for BuildingInfo.Portals (confirmed across 60 portals); keeping it matches WB's defensive style.
3. **Cottage idx=6 is a known small multi-cell building** — perfect first verification target for RR8 visual gate ("cellar walls solid; cottage floor solid"). The cells it owns (0xA9B40145, 0xA9B4014C, 0xA9B4014E, 0xA9B4014F, 0xA9B40150) are the exact ones from the #98 saga.
## 3. WB's interior-portal walk algorithm
Source: [references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:43-97](../../references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs).
```csharp
public IEnumerable<BuildingPortalGroup> GetPortalsByBuilding(uint regionId, ushort landblockId)
{
var lbFileId = ((uint)landblockId << 16) | 0xFFFE;
if (!_dats.CellRegions.TryGetValue(regionId, out var cellDb)) yield break;
if (!cellDb.TryGet<LandBlockInfo>(lbFileId, out var lbi)) yield break;
for (int buildingIdx = 0; buildingIdx < lbi.Buildings.Count; buildingIdx++)
{
var bInfo = lbi.Buildings[buildingIdx];
// --- Step A: seed with BuildingInfo.Portals (entry portals) ---
var discoveredCellIds = new HashSet<uint>();
var cellsToProcess = new Queue<uint>();
foreach (var portal in bInfo.Portals)
{
if (portal.OtherCellId != 0xFFFF)
{
var cellId = ((uint)landblockId << 16) | portal.OtherCellId;
if (discoveredCellIds.Add(cellId))
cellsToProcess.Enqueue(cellId);
}
}
// --- Step B: BFS through interior CellPortals ---
while (cellsToProcess.Count > 0)
{
var cellId = cellsToProcess.Dequeue();
if (cellDb.TryGet<EnvCell>(cellId, out var envCell))
{
foreach (var cellPortal in envCell.CellPortals)
{
if (cellPortal.OtherCellId != 0xFFFF)
{
var neighborId = ((uint)landblockId << 16) | cellPortal.OtherCellId;
if (discoveredCellIds.Add(neighborId))
cellsToProcess.Enqueue(neighborId);
}
}
}
}
// --- Step C: collect EXIT portals from every discovered cell ---
var outsidePortals = new List<PortalData>();
foreach (var cellId in discoveredCellIds)
foreach (var portal in GetPortalsForCell(cellDb, cellId)) // OtherCellId == 0xFFFF
outsidePortals.Add(portal);
if (discoveredCellIds.Count > 0)
yield return new BuildingPortalGroup
{
BuildingIndex = buildingIdx,
Portals = outsidePortals,
EnvCellIds = discoveredCellIds,
};
}
}
```
Where `GetPortalsForCell` walks each cell's `CellPortals`, picks the entries with `OtherCellId == 0xFFFF` (the "to outside" sentinel), looks up the portal polygon via:
```
_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId, out var environment)
environment.Cells[envCell.CellStructure].Polygons[portal.PolygonId]
```
…then transforms each vertex by:
```
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
Matrix4x4.CreateTranslation(envCell.Position.Origin)
```
…and yields `PortalData { Vertices = worldVertices, BoundingBox = ... }`.
### Key invariants extracted from the WB code
| Invariant | Evidence (WB line) |
|---|---|
| `0xFFFF` is the exit-portal sentinel for both `BuildingPortal.OtherCellId` and `CellPortal.OtherCellId` | `if (portal.OtherCellId != 0xFFFF)` (l. 58) and `if (cellPortal.OtherCellId != 0xFFFF)` (l. 71) and `if (portal.OtherCellId == 0xFFFF) /* Portal to outside! */` (l. 103) |
| Cell-id full form: `((uint)landblockId << 16) \| portal.OtherCellId` (NOT `landblockId & 0xFFFF0000u \| otherCellId` — but functionally equivalent because the high 16 bits of `landblockId` already encode the landblock x/y) | Lines 59, 72 |
| BFS uses dat-loaded `EnvCell.CellPortals`, NOT pre-resolved cell instances | Line 70 |
| Building's cell set comes from BOTH the entry portals AND the BFS extension — entry portals alone would miss most of a multi-cell building | Lines 57-64 (seed) + 67-79 (BFS) |
| A building with zero entry portals (`bInfo.Portals.Count == 0`) yields nothing — the `discoveredCellIds.Count > 0` gate at l. 89 short-circuits the `yield return` | Line 89 |
| `BuildingPortalGroup` instances correspond 1:1 with `BuildingInfo` entries (via `BuildingIndex`) | Line 91 |
### Edge cases observed in WB
- A cell shared between two `BuildingInfo` entries would be discovered TWICE (once per BFS). WB's `HashSet<uint> discoveredCellIds` is per-building, so each building gets its own copy. The plan's `BuildingRegistry.GetBuildingsContainingCell` already handles the "shared cell" case via `List<Building>`.
- WB walks the dat database (`cellDb.TryGet<EnvCell>(cellId, ...)`) DIRECTLY, regardless of whether cells are already loaded. Our `BuildingLoader.Build` will take `IReadOnlyDictionary<uint, LoadedCell>` so it walks pre-loaded cells. **Difference matters when streaming hasn't loaded a building's cells yet** — see §4.
## 4. Resolved algorithm for acdream's `BuildingLoader`
The plan's RR3-S11 pseudocode is correct in shape. Two updates pin it down precisely:
### 4.1 Type rename
Plan's RR3-S9 test file uses `BldPortal` and `BldPortal.OtherCellId`. Rename to `BuildingPortal` (the actual DRW type) and keep `OtherCellId` (matches DRW). The test helper signature becomes:
```csharp
var portalList = new List<BuildingPortal>();
foreach (var ocid in portals)
{
portalList.Add(new BuildingPortal
{
OtherCellId = (ushort)(ocid & 0xFFFFu),
Flags = 0,
OtherPortalId = 0,
StabList = new List<ushort>(),
});
}
```
### 4.2 Pre-loaded cells vs dat-direct walk
The plan's BuildingLoader walks `IReadOnlyDictionary<uint, LoadedCell> cellsByCellId`, NOT the dat database. This is the correct choice for acdream because:
- Our `LoadedCell.Portals` is already populated with `CellPortalInfo` records (one per `EnvCell.CellPortals` entry) at landblock-load time by `CellMesh` / `PhysicsDataCache.CacheCellStruct`.
- The streaming pipeline (`LandblockStreamer.LoadNear`) loads ALL of a landblock's `EnvCell`s into the dict before `BuildingLoader.Build` runs. So the dict is complete at registry-build time for the loaded landblock.
- Walking the dict avoids a duplicate dat fetch + EnvCell decode per BFS step (perf bonus).
The plan's empty-dict guard (`if (cellsByCellId.Count > 0)`) covers the unit-test case where the loader is invoked without cells. Production never hits that path.
### 4.3 Final pseudocode (carbon copy of plan's RR3-S11 modulo the rename)
```csharp
public static BuildingRegistry Build(
LandBlockInfo info,
uint landblockId,
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
{
var reg = new BuildingRegistry();
if (info.Buildings is null || info.Buildings.Count == 0)
return reg;
uint lbMask = landblockId & 0xFFFF0000u;
uint nextId = 1;
foreach (var b in info.Buildings)
{
var envCellIds = new HashSet<uint>();
var exitPortalPolys = new List<Vector3[]>();
// Step A: seed from BuildingInfo.Portals
if (b.Portals is not null)
foreach (var portal in b.Portals)
{
if (portal.OtherCellId == 0xFFFF) continue;
envCellIds.Add(lbMask | portal.OtherCellId);
}
// Step B: BFS through interior CellPortals (preferred — uses pre-loaded LoadedCell.Portals)
var queue = new Queue<uint>(envCellIds);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!cellsByCellId.TryGetValue(current, out var cell)) continue;
foreach (var p in cell.Portals)
{
if (p.OtherCellId == 0xFFFF) continue;
uint neighbourId = lbMask | p.OtherCellId;
if (envCellIds.Add(neighbourId))
queue.Enqueue(neighbourId);
}
}
// Step C: collect EXIT portal polygons in world space
foreach (var cellId in envCellIds)
{
if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue;
for (int pi = 0; pi < cell.Portals.Count; pi++)
{
if (cell.Portals[pi].OtherCellId != 0xFFFF) continue;
if (pi >= cell.PortalPolygons.Count) continue;
var localPoly = cell.PortalPolygons[pi];
if (localPoly.Length < 3) continue;
var worldPoly = new Vector3[localPoly.Length];
for (int v = 0; v < localPoly.Length; v++)
worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform);
exitPortalPolys.Add(worldPoly);
}
}
if (envCellIds.Count == 0) continue; // building has no interior — skip (matches WB §89)
var building = new Building
{
BuildingId = nextId++,
EnvCellIds = envCellIds,
ExitPortalPolygons = exitPortalPolys,
};
reg.Add(building);
foreach (var cellId in envCellIds)
if (cellsByCellId.TryGetValue(cellId, out var cell))
cell.BuildingId = building.BuildingId;
}
return reg;
}
```
`cell.PortalPolygons` is already populated by `CellMesh.Build` / `PhysicsDataCache.CacheCellStruct` from the same dat lookup chain (`Environment.Cells[CellStructure].Polygons[PolygonId]`) — RR3 doesn't have to re-derive it.
## 5. Edge cases
1. **Building with zero portals** — skipped (matches WB `discoveredCellIds.Count > 0` gate at l. 89). The building entity (the cottage shell mesh) still ships via the existing `LandblockLoader` path with `IsBuildingShell = true`; the `BuildingRegistry` just doesn't list it.
2. **Cell shared between two buildings** — handled by `BuildingRegistry._byCellId: Dictionary<uint, List<Building>>` (plan's RR3-S7). `LoadedCell.BuildingId` will be stamped with the LAST building's id; consumers requiring all owners must use `BuildingRegistry.GetBuildingsContainingCell` (plural). RR7's render-path uses the plural lookup.
3. **Building with portals pointing to unloaded cells** — Step B's BFS bails out at the unloaded cell (`!cellsByCellId.TryGetValue`); the building's `EnvCellIds` is short by however many cells weren't loaded. In production this doesn't happen (streaming loads all cells before the registry builds). In tests, the loader still returns a valid (possibly partial) building. Worth a doc comment in RR3's `BuildingLoader.cs`.
4. **`BuildingInfo.Portals[i].OtherCellId == 0xFFFF`** — defensively skipped at Step A. Empirically WB's code includes the same defensive check (l. 58), so the case is anticipated even if not common.
5. **Multi-landblock buildings** — none observed. `BuildingPortal.OtherCellId` is a 16-bit value scoped to the same landblock; the dat-level encoding can't reference a different landblock. Buildings are LB-local.
6. **Dungeon cells** — dungeons are NOT enumerated in `LandBlockInfo.Buildings`. Their cells have `BuildingId == null` and flow through the outdoor render path. The plan calls this out explicitly; nothing changes here.
## 6. Gate decision
✅ **Data shape compatible — proceed to RR3.**
The two corrections vs the plan's RR3 pseudocode (`BuildingPortal` rename, `cell.BuildingId` setter timing) are minor and confined to RR3's test-helper + setter call site. The algorithm is unchanged from the plan's expectation. No re-brainstorm needed.
## 7. References
- DRW v2.1.7 `BuildingInfo`: `%USERPROFILE%\.nuget\packages\chorizite.datreaderwriter\2.1.7\lib\net8.0\DatReaderWriter.dll` (decompiled via `ilspycmd -t DatReaderWriter.Types.BuildingInfo`)
- DRW v2.1.7 `BuildingPortal`: ditto, `-t DatReaderWriter.Types.BuildingPortal`
- DRW v2.1.7 `CellPortal`: ditto, `-t DatReaderWriter.Types.CellPortal`
- WB walk: [references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:43-97](../../references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs)
- WB upload: [references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs)
- Retail header for `BuildInfo` (renamed in DRW to `BuildingInfo`): [docs/research/named-retail/acclient.h:32035-32042](../research/named-retail/acclient.h)
- Retail header for `CBldPortal` (renamed to `BuildingPortal`): [docs/research/named-retail/acclient.h:32094-32103](../research/named-retail/acclient.h)
- Existing acdream consumer pattern: [src/AcDream.App/Rendering/GameWindow.cs:5789-5803](../../src/AcDream.App/Rendering/GameWindow.cs)
- Existing `LoadedCell.Portals` shape: [src/AcDream.App/Rendering/CellVisibility.cs:51,79](../../src/AcDream.App/Rendering/CellVisibility.cs)

View file

@ -1,137 +0,0 @@
# 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<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.
---
## 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`

View file

@ -1,271 +0,0 @@
# Phase A8 — R3.5 transition-flicker iteration PAUSED. Handoff for restructure session.
**Date:** 2026-05-26 (PM)
**Status:** R1 + R2 + R3 + R3.5 v1 + R3.5 v2 all shipped. Primary #78 indoor fix WORKS. Three distinct transition/sky issues surfaced during R4 visual verification that resist symptom-level patching. **Paused for proper brainstorm → write-plan → execute-plan workflow in a fresh session.**
**Branch:** `claude/strange-albattani-3fc83c` (worktree)
**HEAD:** `2bfeafd`
**Predecessor handoff:** [docs/research/2026-05-26-a8-revert-handoff.md](2026-05-26-a8-revert-handoff.md)
**Original re-plan:** [docs/superpowers/plans/2026-05-26-phase-a8-replan.md](../superpowers/plans/2026-05-26-phase-a8-replan.md)
**Entity-taxonomy fix-shape (approved):** [docs/research/2026-05-26-a8-entity-taxonomy.md](2026-05-26-a8-entity-taxonomy.md)
---
## TL;DR
R1 (IsBuildingShell flag), R2 (EntitySet partition reshape), R3 (render-frame integration of WB-order stencil pipeline) all shipped clean. The primary #78 fix WORKS: standing inside a Holtburg cottage, the walls now block outdoor visibility — no see-through buildings, no see-through scenery. M1.5's "indoor world feels right" is partially achieved.
Visual verification (R4) surfaced **three remaining issues** that are NOT individual bugs — they're symptoms of an **architectural mismatch** between our render frame and WB's `RenderInsideOut` reference. Specifically: we draw terrain unconditionally before the stencil work and use depth-clear-if-inside as a workaround, while WB skips initial terrain entirely when inside and renders terrain ONLY at the stencil-gated step. Two patch attempts (R3.5 v1 and R3.5 v2) papered over parts of the symptom but kept producing new edge cases — the exact "patching symptoms" anti-pattern CLAUDE.md and the predecessor revert handoff explicitly call out.
**Next session must brainstorm the right architecture, write a plan, and execute.** Do NOT continue inline patches.
---
## What shipped this session (5 commits)
| Commit | Task | What it does |
|---|---|---|
| `ed72704` | R1 | Adds `WorldEntity.IsBuildingShell: bool init` set in `LandblockLoader.Buildings` loop; propagated through `GameWindow.cs:5129-5136` hydration. 2 LandblockLoader tests lock the data-layer guarantee. |
| `55f26f2` | R2 (amended) | Reshapes `WbDrawDispatcher.EntitySet` from `IndoorOnly`/`OutdoorOnly` to taxonomy-aware `IndoorPass` / `OutdoorScenery` / `LiveDynamic`. Adds `private static bool EntityMatchesSet(WorldEntity, EntitySet)` truth-table predicate. 7 tests cover the partition. |
| `60f07bc` | R3 | Wires the stencil pipeline into `GameWindow` render frame with WB-order: `MarkAndPunch → IndoorPass → EnableOutdoorPass → terrain re-draw → OutdoorScenery → DisableStencil → LiveDynamic`. Stencil-marks **camera's own cell's exit portals only** (WB Step 5 deferred). |
| `38d5374` | R3.5 v1 | Adds `cameraReallyInside = PointInCell(camPos, visibility.CameraCell)` gate for the stencil branch (kept `cameraInsideCell` for sky / lighting / depth-clear). Attempt to close the exit-transition flicker. |
| `2bfeafd` | R3.5 v2 | Also gates the depth-clear-if-inside on `cameraReallyInside`. Attempt to close the "objects through ground" symptom the v1 fix exposed. |
All 5 commits are kept; none are reverted. Build green at HEAD. Test failures within the documented 14-23 pre-existing flaky window.
---
## What's WORKING (the primary fix)
Standing inside any Holtburg cottage (ground floor or cellar), looking around:
- **Walls are solid.** No outdoor scenery visible through walls. No buildings visible through walls.
- **The original #78 symptom is gone.** This is the primary acceptance criterion for the A8 phase.
- User confirmed: *"Ok better. ... When I look out now from inside it is not showing buildings below or any windows inside the house."*
The architectural win is real:
- `WorldEntity.IsBuildingShell` correctly tags cottage walls at the dat-source boundary (`LandblockLoader.Buildings` loop).
- `WbDrawDispatcher.EntitySet.IndoorPass` correctly routes cell mesh + cell statics + building shells together — fixing the previous Round-3 regression where cottage walls disappeared.
- Camera's-own-cell-portals-only approximation (Step 5 deferred) avoids the "see through wall to another room's outdoor" regression from previous Round 2.
---
## What's NOT WORKING (3 transition/sky issues)
Verbatim user reports from R4 visual verification (post R3.5 v2):
### Issue A — Exit indoor→outdoor: "objects through ground + building parts missing"
> "If I stand outside or just pass outside I get the flicker where objects are visible through ground and walls of other buildings are missing"
**My diagnosis:** during the 3-frame grace window after camera physically exits a cell (`CellVisibility._cellSwitchGraceFrames`), `cameraInsideCell` stays true but `cameraReallyInside` becomes false (PointInCell on the previous cell returns false). With v2:
- Sky still skipped (cameraInsideCell)
- Initial terrain still drawn (unconditional, line 7115)
- depth-clear NOT fired (cameraReallyInside)
- Stencil branch NOT taken (cameraReallyInside)
- Outdoor branch (`Draw(set: All)`) runs
This *should* be correct — terrain depth preserved, all entities depth-tested. But the user still sees the symptom. **Working hypothesis:** with the depth buffer holding terrain Z (~99.99 post the -0.01 nudge from f48c74a), entities at world Z below terrain may still win depth tests in certain camera angles. Or the issue is something else entirely that the v2 didn't address.
### Issue B — Inside looking through window: "Sky don't render"
> "Sky dont render when I look from inside to outside"
**My diagnosis:** when inside, sky pass is skipped (`if (!cameraInsideCell) { _skyRenderer?.RenderSky(...); ... }` at line 7079). The stencil-gated outdoor pass re-draws terrain + outdoor scenery in portal silhouettes, but **NOT sky**. Through a window, the user sees terrain (where it projects in the portal silhouette) and beyond the terrain horizon — fog color (the framebuffer clear color is set to fog haze at line 6894, not sky color).
This is a **known WB-pipeline limitation** — WB itself doesn't draw sky inside-out. To fix in acdream we'd add a stencil-gated `_skyRenderer.RenderSky` call inside the indoor branch between `EnableOutdoorPass` and the terrain re-draw. Not done in any R3.5 patch.
### Issue C — Entry outdoor→indoor: "floor transparent showing cellar + wrong texture"
> "When going from outside to inside flickering so that parts of the floor is transparent so I see the cellar from above and wrong texture on the floor"
**My diagnosis (LOWER CONFIDENCE):** the cottage floor and cellar ceiling are at adjacent world Z values. Both meshes are loaded (cottage cell + cellar cell both in `VisibleCellIds` when standing in the cottage). During the entry transition frame, depth-fight may occur between cottage floor (Z=100.02 with the +0.02 cell origin bump) and cellar ceiling (whatever Z that mesh sits at). "Wrong texture" suggests the cellar ceiling is winning depth at floor pixels and its texture is showing through. This is **likely a pre-existing data-model / multi-cell-Z artifact, not strictly an A8 bug**, but it became visible because the new pipeline doesn't have the depth-clear-if-inside masking it on every frame anymore.
---
## Architectural diagnosis — the root cause
Reading `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` carefully:
**WB's RenderInsideOut order:**
1. (No initial terrain. Depth buffer is empty from frame-start `glClear`.)
2. MarkAndPunch — stencil bit 1 + depth = 1.0 at exit-portal silhouettes only.
3. Render interior EnvCells with stencil OFF, normal `DepthFunc.Less`. Cell mesh wins fresh depth at most pixels.
4. Enable stencil restriction (`StencilFunc Equal 1, 0x01`).
5. **Render terrain + scenery + static objects** — at portal silhouettes ONLY (stencil-restricted). Terrain depth (close, ~99.99) wins against the 1.0 punch in portal areas → outdoor visible through windows.
6. (Step 5: WB's 3-stencil-bit pipeline for cross-building visibility — deferred.)
**Our R3.5 v2 order:**
1. **Terrain drawn unconditionally** (line 7115; color + depth at ~99.99).
2. depth-clear-if-cameraReallyInside (depth → 1.0; redundant with MarkAndPunch).
3. MarkAndPunch (no-op against the depth-cleared 1.0).
4. IndoorPass — cell mesh + statics + building shells.
5. EnableOutdoorPass + terrain RE-draw + OutdoorScenery (stencil-gated).
6. DisableStencil + LiveDynamic.
**The mismatch:** we draw terrain TWICE (initial + re-draw) and have a depth-clear that's a workaround for the initial terrain draw. WB avoids both by skipping the initial terrain entirely when inside. Our pipeline is a "FRANKENSTEIN" — it works in the steady-state indoor case (the primary #78 fix) but breaks at transitions and during grace frames because the interactions between (initial terrain + depth-clear + grace + cameraInsideCell vs cameraReallyInside flag asymmetry) keep producing new edge cases.
**The R3.5 v1 and v2 patches were symptom-fixes**, not root-cause fixes. CLAUDE.md is explicit about this: *"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."* The user has now correctly pulled the emergency brake.
---
## Recommended next-session approach
Use the **superpowers full workflow**:
### Phase 1: BRAINSTORM (use `superpowers:brainstorming`)
Settle the design BEFORE writing a plan. Key brainstorm questions:
1. **Should the initial terrain draw be conditional?**
- WB faithfully: yes, skip when `cameraReallyInside`. Terrain draws only at stencil-gated step.
- Hybrid: keep initial terrain unconditional but remove the depth-clear so terrain depth wins against indoor cells at non-portal pixels. *(Would break the #78 fix — cottage floor at +0.02 would lose to terrain at -0.01.)*
- **Probably WB-faithful is the right call.**
2. **Should sky be re-drawn stencil-gated when inside?**
- WB: no. Sky color shows as fog-clear-color through windows.
- acdream enhancement: yes, render `_skyRenderer.RenderSky` between `EnableOutdoorPass` and the terrain re-draw inside the indoor branch.
- **Tradeoff:** WB-faithfulness vs. user's expectation that windows show sky. Retail probably shows sky through windows; investigate retail's polygon-clip scissor approach.
3. **What's the deal with the entry-flicker "floor transparent showing cellar"?**
- Is it depth-fight between cottage floor mesh (Z=100.02) and cellar ceiling mesh (Z=?)? Need a brief investigation to confirm.
- Is it a one-frame visibility-update lag where cottage cell isn't yet in VisibleCellIds during the entry transition frame?
- Is it pre-existing in main (test by reverting all of A8 and entering a cottage on main)?
- **Don't try to fix this in A8.** Identify, file as separate follow-up (likely candidate for #103 family or new #106).
4. **Should we eliminate `cameraInsideCell` vs `cameraReallyInside` asymmetry?**
- Today: `cameraInsideCell` (grace-aware) gates sky/lighting; `cameraReallyInside` (PointInCell, no grace) gates depth-clear + stencil branch.
- The split is a workaround for the grace-mechanism conflict with the render path. With WB-faithful order (no initial terrain, no depth-clear), can we use `cameraReallyInside` everywhere? Or does that introduce sky flicker at the threshold?
- The grace mechanism was added to prevent cell-id flicker at doorways. Does PointInCell with its existing epsilon already provide enough hysteresis?
- **Likely path: unify on `cameraReallyInside` and remove the grace mechanism entirely.** Simpler is better.
5. **Are R3.5 v1 + v2 patches worth keeping or should we revert them before the restructure?**
- v1 (stencil branch gate): subsumed by the restructure since the stencil branch will use `cameraReallyInside`.
- v2 (depth-clear gate): subsumed since depth-clear gets DELETED entirely.
- **Recommendation:** revert v1 and v2 (`git revert 2bfeafd 38d5374` or new commits) at the start of the implementation session, work from the R3 baseline. Cleaner diff, easier review.
### Phase 2: WRITE-PLAN (use `superpowers:writing-plans`)
Expected plan shape (TDD where possible):
- **Task RR1**: Revert R3.5 v1 + v2 (`git revert 38d5374 2bfeafd`). Result: HEAD at logical state of `60f07bc` (R3 baseline).
- **Task RR2**: Restructure render frame to WB-faithful order. Sub-steps:
- Move `cameraReallyInside` computation up next to `cameraInsideCell` (~line 7011-7014).
- Gate the initial terrain draw (line 7115) on `!cameraReallyInside`.
- Delete the depth-clear-if-inside block entirely.
- Decide on `cameraInsideCell` vs `cameraReallyInside` unification (per Phase 1 brainstorm Q4).
- Inside branch: keep existing structure (MarkAndPunch → IndoorPass → EnableOutdoorPass → terrain → OutdoorScenery → DisableStencil → LiveDynamic).
- **Task RR3 (optional)**: Add stencil-gated sky pass for sky-through-windows (per Phase 1 brainstorm Q2). Or defer as #105.
- **Task RR4**: Visual verification matrix (same as R4: cottage interior, cellar, inn, dungeon; PLUS exit transition, entry transition, sky-through-windows).
- **Task RR5**: Ship docs (R5 from original plan; file the genuine follow-ups; close #78).
GL integration tasks are visual-verification-only by nature (the partition logic + EntitySet are already unit-tested). Don't burn cycles writing unit tests for GL state — the existing infrastructure tests (26 dispatcher + 5 stencil + 2 PortalPolygons + 1 ProbeVisibility = 34) already lock the non-GL bits.
### Phase 3: EXECUTE-PLAN (use `superpowers:subagent-driven-development`)
Same pattern as this session: fresh Sonnet subagent per task, two-stage review (spec compliance + code quality). The CRITICAL extra review check beyond default — **add to the spec reviewer prompt**: *"Does the implementation match WB's RenderInsideOut order at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239`? Specifically: NO initial terrain draw when inside, NO depth-clear, terrain rendered ONLY stencil-gated?"*
---
## Pickup prompt for next session
```
Phase A8 — render frame restructure to match WB's RenderInsideOut order
faithfully. R1+R2+R3+R3.5 v1+v2 shipped this session (commits ed72704 →
2bfeafd). Primary #78 fix works (cottage interior solid walls). Three
transition/sky issues remain that resist symptom patching.
Read first (in this order — REQUIRED):
1. docs/research/2026-05-26-a8-r3.5-restructure-handoff.md (this doc — full
story of why we paused; the architectural mismatch; recommended path)
2. docs/research/2026-05-26-a8-entity-taxonomy.md (approved fix-shape)
3. docs/research/2026-05-26-a8-revert-handoff.md (predecessor; the original
A8 attempt's revert lessons — still applies)
4. docs/superpowers/plans/2026-05-26-phase-a8-replan.md (this session's
plan — R1+R2+R3 still apply; R3.5 patches and the WB-faithful
restructure are NEW work)
5. references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239
(the proven reference — read it verbatim BEFORE designing the restructure)
6. CLAUDE.md — find "currently working toward" to refresh state
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A8 — render frame restructure to WB-faithful order
HEAD: 2bfeafd (R3.5 v2)
Clean revert points: 60f07bc (R3 baseline) or 55f26f2 (R2)
Test baseline: build green; 1238 pass / 14 fail (documented flaky window)
Session flow — MUST use full superpowers workflow:
### Phase 1 — BRAINSTORM (use superpowers:brainstorming)
Settle the design. Do NOT skip this. The previous session jumped to
patching after R3 and that produced this handoff. Five questions in the
recommended-next-session-approach section of this handoff doc must be
answered before any code is written.
Brainstorm output: a short design note in chat + an updated entry in the
entity-taxonomy doc OR a fresh design doc. Get user approval before
Phase 2.
### Phase 2 — WRITE-PLAN (use superpowers:writing-plans)
Expected: tasks RR1 (revert R3.5), RR2 (restructure render frame), RR3
(optional sky-through-windows), RR4 (visual verification), RR5 (ship
docs). Plan path: docs/superpowers/plans/2026-05-2X-phase-a8-restructure.md
(date when written).
### Phase 3 — EXECUTE (use superpowers:subagent-driven-development)
Fresh Sonnet subagent per task with two-stage review. Add the WB-order
check to the spec reviewer prompt (see handoff doc).
## Constraints
- Per CLAUDE.md "no workarounds without approval" — fix the root cause.
The R3.5 v1+v2 patches were symptom fixes. Do not repeat that pattern.
- Visual verification is the acceptance test. Test scenarios in the
handoff's "What's NOT working" section MUST all be re-tested.
- Existing infrastructure (Tasks 1-6 + R1 + R2 + R3) is correct and
shipped. The restructure is a render-frame surgery, not a partition
reshape or data-layer change.
## What success looks like
After this restructure ships:
- Standing INSIDE cottage / cellar / inn / dungeon: solid walls
(unchanged from this session's R3 win).
- EXITING indoor → outdoor: clean transition. No "objects through
ground." No "buildings missing." Brief lighting transition is OK if
sky-on-cameraInsideCell is kept, otherwise no lighting transition.
- ENTERING outdoor → indoor: clean transition. No floor-transparent
showing cellar. If the floor-cellar-z-fight is pre-existing on
main, file as a separate issue and accept it as not-A8-scope.
- LOOKING THROUGH WINDOWS from inside: terrain visible at the
portal silhouette. Sky visible (if RR3 included) OR fog color (if
RR3 deferred and noted in #105).
- dotnet build green; test failures within the documented 14-23
flaky window.
```
---
## Files state at session end
```
Branch: claude/strange-albattani-3fc83c
HEAD: 2bfeafd fix(render): Phase A8 R3.5 v2 — gate depth-clear on cameraReallyInside too
Parent: 38d5374 fix(render): Phase A8 R3.5 — gate stencil branch on PointInCell containment
GP: 60f07bc feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order)
GGP: 55f26f2 feat(render): Phase A8 R2 — WbDrawDispatcher.EntitySet taxonomy partition
GGGP: ed72704 feat(world): Phase A8 R1 — tag WorldEntity.IsBuildingShell at LandblockLoader
Working tree: clean
Build: green (0 warnings, 0 errors)
Tests: 1238 pass / 14 fail (all within documented 14-23 flaky window;
zero new failures attributable to A8 R1/R2/R3/R3.5)
Untracked log files: launch-a8-verify*.log (deletable)
```
The five commits are all NEW additions to main; no destructive history rewrites. Next session can:
- Continue from HEAD with the restructure layered on top (R3.5 patches subsumed by it).
- OR `git revert 38d5374 2bfeafd` for a cleaner diff against R3 baseline.
Either path is valid — pick whichever the brainstorm settles on.

View file

@ -1,447 +0,0 @@
# Phase A8 — Indoor-cell visibility culling — REVERTED. Handoff for re-plan.
**Date:** 2026-05-26 (session began 2026-05-25 PM, continued into 2026-05-26)
**Status:** Task 7 integration REVERTED after three rounds of visual verification surfaced cascading bugs. Infrastructure (Tasks 1-6) RETAINED — all dead-but-correct code, ready to be re-integrated under a different design.
**Branch:** `claude/strange-albattani-3fc83c` (worktree)
**Predecessor handoff:** [docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md](2026-05-25-issue-100-shipped-and-culling-handoff.md)
**Original plan:** [docs/superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md](../superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md)
**Investigation report (Phase 1):** [docs/research/2026-05-25-issue-78-visibility-culling-investigation.md](2026-05-25-issue-78-visibility-culling-investigation.md)
---
## TL;DR
We tried to close issue #78 (outdoor stabs visible through inn floor/walls) and the cellar-stairs grass-overlay artifact by porting WorldBuilder's stencil-based `RenderInsideOut` pipeline. The plan executed cleanly through 7 tasks (1029 lines of plan, 11 commits including hardening + fixes), and the H1 hypothesis from the investigation was correct — the cellar-stairs artifact IS a culling problem, NOT a Z-fight problem, confirmed by the camera-rotation falsification test.
But the WB stencil approach has **architectural assumptions that don't match acdream's data model**, and three rounds of visual verification surfaced compounding bugs that aren't fixable by patching:
1. **Round 1** (commit `41c2e67` initial integration) — character disappeared indoors. Root cause: player/NPCs have `ParentCellId == null`, got classified as outdoor scenery, stencil-gated to portal silhouettes only.
2. **Round 2** (commit `a2ad5c1` animated-entity fix) — character now visible, but closed doors leaked outside, walls between rooms showed far-side portal openings, character body bled to terrain where it overlapped a portal silhouette on screen.
3. **Round 3** (commit `b76f6d1` order swap — Mark+Punch BEFORE indoor draw, matching WB's actual order) — closed doors now correctly blocked, BUT cottage walls completely disappeared, character rendered head-inside-out, see-through everything. Root cause: cottage walls are **landblock-baked stabs** (`LandBlockInfo.Objects`) with `ParentCellId == null`, classified as outdoor scenery, stencil-gated → visible only at portal silhouettes (windows/doors).
The integration commits `41c2e67`, `a2ad5c1`, `b76f6d1` are now reverted by `fef6c61`, `96f8bd2`, `c897a17`. Tasks 1-6 (infrastructure: `PortalPolygons` field, `RenderingDiagnostics.ProbeVisibilityEnabled`, portal_stencil shaders, `IndoorCellStencilPipeline`, `PortalMeshBuilder`, `WbDrawDispatcher.EntitySet` enum) remain committed and tested. They're dormant — nothing in the runtime invokes them — but they're correct, tested, and ready for a different integration design.
**Current HEAD: `fef6c61`** — render frame back to pre-A8 behavior (terrain → depth-clear-if-inside → dispatcher with all entities). Build green, all infrastructure tests passing (26 dispatcher + 5 stencil-pipeline + 2 PortalPolygons data-class + 1 ProbeVisibilityEnabled toggle).
The next session needs to **re-investigate the entity taxonomy** before re-planning the integration. The plan's binary `IndoorOnly vs OutdoorOnly` partition is wrong; AC's data model has at least four distinct entity classes that need different treatment.
---
## What was tried (chronological)
### Phase 1: Investigation (REPORT-ONLY, before any code)
Dispatched four parallel research agents:
1. Retail decomp visibility chain (`PView::DrawCells`, `RenderInsideOut`, `CEnvCell::find_visible_child_cell`)
2. WorldBuilder `VisibilityManager.RenderInsideOut` reference implementation
3. acdream's existing visibility code (`CellVisibility`, `WbDrawDispatcher`, `TerrainModernRenderer`, render frame integration points)
4. ISSUES.md context for #78, #95, and the lighting family
Findings consolidated in [`docs/research/2026-05-25-issue-78-visibility-culling-investigation.md`](2026-05-25-issue-78-visibility-culling-investigation.md). Two main outputs:
- Confirmed retail and WB use different mechanisms (retail = screen-space polygon-clip scissor, WB = stencil mask), but achieve the same observable behavior. WB's stencil approach is the right fit for acdream's modern GL pipeline.
- Three approach options sketched: A (WB stencil port), B (retail polygon-clip — multi-week), C (binary gate — workaround).
User chose **Approach A** (WB stencil port).
### Phase 1a: Falsification test (visual)
User stood in a Holtburg cottage cellar at the artifact spot and rotated the camera in place. Reported: **no flickering around the edges.** This confirmed H1 (culling) over H2 (Z-fight). The artifact IS a rendered polygon that needs to be culled, not a depth-precision issue.
### Phase 2: Plan written
[`docs/superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md`](../superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md). 8 tasks, TDD-shaped where unit-testable. Architecture: split entities into `IndoorOnly` (`ParentCellId.HasValue`) and `OutdoorOnly` (`ParentCellId == null`); stencil-mark current building's exit portals; gate terrain + outdoor entities by `glStencilFunc(Equal, 1, 0x01)`.
### Phase 3: Subagent-driven execution
Tasks 1-7 implemented by Sonnet subagents, each with two-stage review (spec-compliance + code-quality). Task 4 was sent back once for over-engineering (the implementer added speculative `pos.w` clamp and `out FragColor` declarations not in the spec; subtractively reverted in commit `344034b`). Task 5 received a hardening pass (`a1c393e`) for explicit `Enable(DepthTest)`, `readonly` fields, and an `AllocateVbo` comment.
All 7 implementation tasks shipped clean. Built green, ~36 unit tests added across tasks, all passing.
### Phase 4: Visual verification — three rounds, three regressions
**Round 1 — commit `41c2e67` (initial integration)**
User scenarios:
- Cellar stairs: not visible from outside-to-in (but this turned out to be a NOT-A8 artifact — separate)
- Inn walls: solid (no see-through buildings) ✅
- Character: **DISAPPEARED inside cottages**
- Character at doorway: only parts of body visible, **head rendered backwards**
- Flickering on enter/exit
Diagnosis: animated entities (player, NPCs) have `ParentCellId == null` (server-spawned, not statically tied to a cell). EntitySet partition classified them as OutdoorOnly, so the stencil-gated outdoor pass only let them render where stencil bit 1 was set (= portal silhouettes). Walking around inside, character body crossed in and out of portal silhouettes → partial body visible briefly at doorways, head-on-backwards artifacts where stencil clipped one part of body but not another, fully invisible most of the time.
**Round 2 — commit `a2ad5c1` (animated-entity fix)**
Fix: `animatedEntityIds` overrides the partition. Animated entities go into `IndoorOnly` (stencil OFF), excluded from `OutdoorOnly`.
User scenarios:
- Character: **VISIBLE**
- Closed door: **OUTSIDE STILL VISIBLE through closed door**
- Door from adjacent room: **VISIBLE THROUGH WALL** between rooms ❌
- Character at door opening overlap: **outside bleeds through character body** where body covers the portal silhouette on screen ❌
Diagnosis: my plan had the GL state order WRONG. I had `IndoorOnly draw → MarkAndPunch → terrain stencil-gated`. The `MarkAndPunch` step writes `gl_FragDepth = 1.0` at all stencil-1 pixels, destroying any indoor depth that was just written there. Then terrain at 0.99 wins every depth test at portal-silhouette pixels. WB's actual order is `MarkAndPunch FIRST → indoor cells → terrain stencil-gated`. With WB's order, indoor cells write depth AFTER the punch, so their depth survives and correctly occludes the subsequent stencil-gated terrain pass.
**Round 3 — commit `b76f6d1` (order swap to match WB)**
Fix: swap `IndoorOnly draw` and `MarkAndPunch` so MarkAndPunch runs first.
User scenarios:
- Closed door: **NOW BLOCKS OUTSIDE**
- Door from adjacent room through wall: **STILL VISIBLE** ❌ (worse than expected)
- Character at door: **TOTALLY BROKEN** — character rendered head-inside-out, see-through to distant outdoor objects through where walls should be ❌
Screenshot evidence: user stood on what appeared to be the upper floor of a Holtburg cottage. Visible in the frame: wood stairs/floor (indoor cell mesh), player character in armor, and a small window-shaped opening showing outdoor terrain (correct portal behavior). Beyond that: GREY expanse (clear color) with NPCs and decorations floating in space (= distant outdoor entities visible THROUGH where walls should be).
Diagnosis (the showstopper): **cottage walls are landblock-baked stabs** stored in `LandBlockInfo.Objects`, NOT in the EnvCell's mesh or `StaticObjects`. They're `WorldEntity` instances with `ParentCellId == null`. The EntitySet partition treats them as outdoor scenery and stencil-gates them. Result: cottage interior walls only render at portal silhouettes — i.e., framed in the window openings. The rest of the wall area is just the cleared framebuffer (grey), with distant entities (which DO render unconditionally because they happen to be in screen positions not occluded by walls that don't exist) bleeding through.
The head-inside-out artifact is a cascade — with the depth buffer state and framebuffer being so broken (walls absent, terrain stencil-gated in unexpected places, depth punched then partially overwritten by terrain), the character mesh rendering interacts with these broken depths in ways producing the impossible-anatomy effect. I don't have a single-call explanation; it's "the depth + stencil state is so far from sane that character vertex shader + fragment depth tests produce nonsense."
### Phase 5: REVERT
Decision: continuing to patch was going to keep surfacing edge cases. The fundamental issue (EntitySet partition by `ParentCellId.HasValue` is wrong) requires re-design, not patching.
Three revert commits:
- `c897a17` reverts `b76f6d1` (order swap)
- `96f8bd2` reverts `a2ad5c1` (animated-entity fix)
- `fef6c61` reverts `41c2e67` (Task 7 integration)
After reverts: HEAD = `fef6c61`. GameWindow.cs render frame is back to pre-A8 (terrain → depth-clear-if-inside → dispatcher with all entities). Build green. All infrastructure tests passing.
---
## What was kept (the infrastructure)
Six commits NOT reverted. All internally consistent, all tested, all dormant (nothing invokes them at runtime):
| Commit | What it adds | Status |
|---|---|---|
| `fee878f` | `LoadedCell.PortalPolygons: List<Vector3[]>` field | dormant, tested |
| `d834188` | `BuildLoadedCell` populates `PortalPolygons` from `cellStruct.Polygons[portal.PolygonId].VertexIds` | dormant (data populated, nothing reads it) |
| `6577c0a` | `RenderingDiagnostics.ProbeVisibilityEnabled` flag + DebugVM mirror | dormant (no probe code uses it) |
| `2d31d49``344034b``f3d7b13` | `portal_stencil.vert/.frag` shader pair | dormant (no code loads them) |
| `3973596``a1c393e` | `IndoorCellStencilPipeline` class + `PortalMeshBuilder` static helper, with hardening | dormant, 5 unit tests pass |
| `dcf69a1` | `WbDrawDispatcher.EntitySet { All, IndoorOnly, OutdoorOnly }` enum + `set` parameter on `Draw` + `WalkEntitiesForTest` helper | dormant (`Draw` always called with default `EntitySet.All`), 26 dispatcher tests pass |
These are all correct and useful. They don't need to be re-shipped in the re-plan — they're ready for a new integration to consume.
**However**, the re-plan may want to reshape some of them:
- `EntitySet` enum's binary `IndoorOnly/OutdoorOnly` partition is the load-bearing wrong assumption. The re-plan likely needs more partition values (e.g. `IndoorStatic`, `BuildingShell`, `OutdoorScenery`, `LiveDynamic`) or a different mechanism entirely. The enum can be extended or replaced.
- `IndoorCellStencilPipeline` is correct as a primitive but its current usage assumption ("mark exit portals, gate outdoor passes") may need refinement. For example, it might want a "draw building-shell stabs unconditionally THEN stencil-gate outdoor scenery" split.
---
## Root cause taxonomy (the architectural lesson)
acdream's `WorldEntity` data model has more entity classes than the plan accounted for. The classes encountered:
| Class | `ParentCellId` | Source | Examples | Stencil treatment needed |
|---|---|---|---|---|
| **Cell mesh** | set | `EnvCell` geometry | inn walls, dungeon corridor walls, cellar floor | Always render (unconditional) |
| **Cell statics** | set | `EnvCell.StaticObjects` | inn furniture, dungeon braziers | Always render (unconditional) |
| **Building shell stab** | **null** | `LandBlockInfo.Objects` | cottage walls/roof, smithy walls | Always render WHEN camera is inside the building |
| **Outdoor scenery stab** | null | `LandBlockInfo.Objects` | trees, fences, lampposts, rocks, hitching posts | Stencil-gate (only visible through portals from inside) |
| **Live animated** | null | server `CreateObject` + in `animatedEntityIds` | player, NPCs, monsters, doors mid-animation | Always render (unconditional) |
| **Live static** | null | server `CreateObject`, NOT animated | dropped items, sigils, idle doors after animation ends | Probably always render? Hard to say |
The plan's binary `IndoorOnly = HasValue, OutdoorOnly = !HasValue` partition lumps "building shell stab" with "outdoor scenery stab" — both have null `ParentCellId`. But they need OPPOSITE stencil treatment.
**The 3rd-round disaster came from this conflation specifically.** When camera is inside a cottage, the cottage's walls (building shell stab) need to render UNCONDITIONALLY (just like cell mesh would). My plan classified them with the trees and lampposts → stencil-gated → invisible.
WB handles this via a "building" concept: `BuildingPortalGPU` tracks which `EnvCellIds` belong to each building, and the building's portal mesh + occlusion is treated separately from generic scenery. acdream doesn't have this concept — all landblock stabs go into the same `WorldEntity` pool with no "is-building-shell" flag.
### Why Tasks 1-6 review missed this
The spec / code review focused on:
- Spec compliance (did the implementation match the spec?)
- Code quality (well-structured, clean, etc.)
Neither addressed: **is the spec's architectural premise correct?** The plan stated the partition as a binary based on `ParentCellId`, the reviewers verified the implementation followed that, but no one questioned whether the premise was right. Investigation (Phase 1) didn't catch it either — the audit focused on the EXISTING code paths and didn't go deep on the `WorldEntity` lifecycle / classification.
This is the kind of issue where the plan's "self-review" step + investigation's "what we've ruled out" section should have included an entity-taxonomy audit. Future plans for rendering-pipeline changes should include: "List every kind of `WorldEntity` and what classification it gets, then verify the pipeline treats each correctly."
### A second architectural issue (deferred but real)
Even with the cottage-walls case solved, the WB stencil approach has a known limitation that the predecessor handoff already flagged: **all exit portals in `VisibleCellIds` get marked**, including portals on cells far from the camera. From inside a cottage, if the camera looks at a wall, the portals BEHIND the wall (on the other side of the room) ARE marked in stencil (their silhouettes project to screen positions covered by the wall). Then far-depth is punched at those positions. Then terrain stencil-gated wins over indoor wall depth → "outdoor visible through window on the other side of the room behind a wall."
In Round 2 testing, this surfaced as "I can see the door of the adjacent room through the wall." It's a real geometric over-marking issue.
WB handles it with a 3-stencil-bit pipeline ("Step 5" in WB's RenderInsideOut). My plan explicitly DEFERRED Step 5. With Round 2's order, the issue was masked because indoor wall depth was being destroyed by the late MarkAndPunch anyway, so the punch's far-depth happened to coincide with the bug. With Round 3's order, the punch happens before walls draw, so walls correctly write depth — but now the far-side-portal issue is unmasked.
The re-plan needs to address Step 5 OR accept it as a documented limitation OR find a different mechanism (camera-frustum portal filtering, occlusion query for portals behind walls, etc.).
---
## Things the re-plan must consider (the "do-not-miss" list)
1. **Building shell stabs are NOT outdoor scenery.** They have `ParentCellId == null` but must render unconditionally when the camera is inside the building. The fix is one of:
- (a) **AABB-encloses-camera heuristic**: when an entity's `[AabbMin, AabbMax]` contains `cameraPos`, treat it as building shell. Quick to implement, ~30 min. Works for cottages and inns. Edge cases: very tall buildings with low camera, or buildings the camera isn't quite inside.
- (b) **Tag building stabs at hydration time**: when reading `LandBlockInfo.Objects`, identify objects that have associated `EnvCellIds` (i.e., they're the building shell of those cells). Add `WorldEntity.IsBuildingShell: bool` (or similar). Correct, but requires understanding LandBlockInfo's structure.
- (c) **WB-style building concept**: full `BuildingPortalGPU.EnvCellIds` model. Heavy lift; probably overkill for first ship.
- **Recommended: (a) for first ship, (b) as a follow-up if the heuristic has misses.**
2. **Live entities (player, NPCs, dropped items) need a "always render" path.** Today's `animatedEntityIds` covers the animated subset. Dropped items / idle doors are NOT animated but ARE live. The cleanest model: add `WorldEntity.IsLiveDynamic` flag set at hydration when the entity has a `ServerGuid` (vs landblock-baked). All live entities skip stencil-gating entirely.
3. **GL state order matters: MarkAndPunch BEFORE indoor cells.** Confirmed by Round 3. The far-depth punch must run before indoor geometry draws, so indoor geometry writes depth on top of the 1.0 punch and correctly occludes the subsequent stencil-gated terrain. The Plan had the order wrong; the order-swap (Round 3) is the correct order. Re-plan must reflect this.
4. **Animated/live entities should draw AFTER all stencil work**, with stencil disabled, so their depth never interacts with the punch or the stencil-gated pass. Round 2 showed character body bleeding to terrain when drawn BEFORE the punch (depth destroyed by punch). Drawing them last fixes this naturally.
5. **The "far-side portal visible through wall" problem (WB Step 5)** is real and won't be fixed by the order swap alone. Either implement Step 5 (complex), accept it as a known limitation for first ship, or add a camera-frustum filter on portal triangles (only stencil-mark portals the camera could plausibly see directly).
6. **Cellar-stairs grass artifact from outside-to-in is NOT A8 scope.** This was reported by the user in Round 1 and persisted across all rounds. From outside, no stencil work runs; the artifact is purely terrain-Z-fight against the cellar geometry. The cellar floor is meters below terrain Z; #100's 1cm shader nudge doesn't help. File as a separate issue OR roll into a future "deep-cell terrain occlusion" phase.
7. **Closed doors must block outdoor visibility.** The Round 3 order successfully delivers this — door entities (`ParentCellId == null` but inside the building's AABB) need to draw in the indoor pass AFTER MarkAndPunch, so their depth wins over terrain. Doors actually map cleanly to the building-shell stab solution: a door is functionally part of the building when closed.
8. **The `EntitySet` enum may need refactoring or replacement.** Today it has `All, IndoorOnly, OutdoorOnly`. The taxonomy suggests at least:
- `All` (pre-A8 default)
- `IndoorPass` — cell mesh + cell statics + building shell stabs + live entities (essentially everything that should draw unconditionally when inside)
- `OutdoorPass` — outdoor scenery only, stencil-gated
- `LivePass` (optional) — separate pass for live entities at the very end, no stencil
Or replace the enum with a callback / filter delegate. The current enum is a quick prototype; the production design should reflect the actual taxonomy.
9. **Visual verification scenarios must cover MORE buildings.** Round 1 tested at the inn (which has cell mesh walls); Round 3 tested at a cottage (which has stab walls). Different bugs surfaced. The re-plan's Task 8 must explicitly test: inn, cottage (interior + cellar), dungeon (portal-entry), and at least one mid-size building with multiple rooms. Each likely has a different geometry classification mix.
10. **The flickering on enter/exit** reported across all rounds is unexplained. Likely the `CellSwitchGraceFrameCount = 3` interacting with stencil setup timing — when camera transits a cell boundary, the visibility result toggles between cells over the grace frames, and the stencil mask flips with it. Investigate during re-plan.
---
## Existing apparatus the next session inherits
### Code (committed, dormant)
- **`src/AcDream.App/Rendering/CellVisibility.cs`** — `LoadedCell.PortalPolygons` field populated by `BuildLoadedCell`. The data is ready; nothing reads it.
- **`src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`** — `PortalMeshBuilder.BuildTriangles(...)` (pure-math, tested) + `IndoorCellStencilPipeline` (GL class, untested at runtime but the GL state machine has been reviewed twice). The `MarkAndPunch` GL sequence is correct per WB; the cleanup state is correct for either pre- or post-indoor-draw scheduling. Re-usable as-is.
- **`src/AcDream.App/Rendering/Shaders/portal_stencil.vert/.frag`** — minimal MVP + `gl_FragDepth = 1.0` writer. Re-usable.
- **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** — `EntitySet` enum + `WalkEntitiesForTest` helper + partition logic in `WalkEntitiesInto`. **The current partition is the load-bearing wrong assumption.** Re-plan likely modifies this.
- **`src/AcDream.Core/Rendering/RenderingDiagnostics.cs`** — `ProbeVisibilityEnabled` flag. Re-usable.
### Tests (passing)
- `tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs` — 2 tests, data-class invariants
- `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` — 5 tests, triangle-fan math
- `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs` — 3 tests, EntitySet partition
- `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs` — 1 test, flag toggle
### Documents
- The original plan: [`docs/superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md`](../superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md) — read for Task 1-6 implementation reference; **do NOT re-execute Task 7** as written.
- The investigation report: [`docs/research/2026-05-25-issue-78-visibility-culling-investigation.md`](2026-05-25-issue-78-visibility-culling-investigation.md) — the H1 confirmation + WB/retail/acdream code anchors still apply.
- The original handoff: [`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](2026-05-25-issue-100-shipped-and-culling-handoff.md) — the family map (#78 + cellar-stairs + #95) is unchanged.
### Reference anchors (still valid)
- **WB stencil:** `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` (RenderInsideOut). **Note: WB's order is MarkAndPunch FIRST, then indoor cells — confirmed by Round 3.**
- **WB building concept:** `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs``BuildingPortalGPU.EnvCellIds` is the "this stab belongs to a building" association we're missing.
- **Retail:** `acclient_2013_pseudo_c.txt:432709` (`PView::DrawCells`, `outside_view.view_count > 0` gate). Polygon-clip scissor, not stencil — equivalent observable behavior.
### Issue state
- **#78** — still OPEN. Not fixed by A8 attempt.
- **Cellar-stairs artifact (NEW evidence for #78)** — still happening from outside-to-in (NOT A8 scope) AND from inside (was A8 scope; not fixed).
- **#95** (portal-graph blowup at network hubs) — out of scope, separate work.
- **#79/#80/#81/#93/#94** (indoor lighting family) — unchanged.
---
## Pickup prompt for the next session
```
Phase A8 — Indoor-cell visibility culling — RE-PLAN after revert.
Read first (in this order — REQUIRED):
1. docs/research/2026-05-26-a8-revert-handoff.md (this doc — full
story of the 3-round visual verification failure + reverts)
2. docs/superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md
(original plan — reference Tasks 1-6 implementation; do NOT
re-execute Task 7 as written)
3. docs/research/2026-05-25-issue-78-visibility-culling-investigation.md
(original investigation; H1 culling diagnosis is confirmed)
4. CLAUDE.md — find "currently working toward" to refresh state
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A8 — Indoor-cell visibility culling RE-PLAN
Previous attempt at HEAD: fef6c61 (reverts of 41c2e67, a2ad5c1, b76f6d1)
Infrastructure preserved: Tasks 1-6 commits (fee878f → dcf69a1 + a1c393e)
Test baseline: build green; 36 A8-infrastructure tests pass dormant
Session flow:
### Phase 1 — RE-INVESTIGATE the entity taxonomy (USE /investigate skill)
DO NOT skip to planning. The original plan's binary IndoorOnly/OutdoorOnly
partition was the load-bearing wrong assumption. Before any new plan:
a. Read src/AcDream.App/Rendering/GameWindow.cs around the entity
hydration paths (BuildInteriorEntitiesForStreaming around line
5409+, and the LandBlockInfo.Objects iteration). Document every
code path that constructs a WorldEntity and what ParentCellId it
gets.
b. Enumerate the actual entity classes that exist in acdream's runtime:
- Cell mesh (ParentCellId set, from EnvCell)
- Cell statics (ParentCellId set, from EnvCell.StaticObjects)
- Building shell stab (ParentCellId == null, from LandBlockInfo.Objects,
represents inn walls / cottage walls / etc)
- Outdoor scenery stab (ParentCellId == null, from LandBlockInfo.Objects,
represents trees / fences / lampposts)
- Live animated (ParentCellId == null, server-spawned, in
animatedEntityIds — player, NPCs, monsters, mid-animation doors)
- Live static (ParentCellId == null, server-spawned, NOT animated —
dropped items, idle doors after animation ends, sigils)
c. For each class, determine: how can the renderer distinguish it from
the other null-ParentCellId classes? Today only animatedEntityIds
separates one class. The re-plan needs distinguishers for the others.
Options:
- WorldEntity.IsBuildingShell (set at LandBlockInfo hydration)
- WorldEntity.IsLiveDynamic (set when ServerGuid != 0)
- AABB-encloses-camera heuristic (runtime, no new field)
- WB-style building association (per-cell building registry)
Spike each option's cost + correctness.
d. Read WB's reference to confirm how WB handles each class.
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs
has the BuildingPortalGPU.EnvCellIds association we're missing.
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/StaticObjectRenderManager.cs
might show how outdoor scenery is treated separately from buildings.
e. Decide the entity-distinguisher approach. The cheapest option that
handles cottages + inns + dungeons is likely AABB-encloses-camera
for building shells, animatedEntityIds for live animated, and
accept "dropped items invisible from inside" as a known limitation
for first ship (defer to a follow-up task with a real IsLiveDynamic
flag).
f. Re-confirm the GL state order: MarkAndPunch FIRST, then indoor
cells (including building shells + live animated), then terrain
stencil-gated, then outdoor scenery stencil-gated. Confirmed by
Round 3 of the original A8 attempt.
g. Decide whether to address the "far-side portal visible through wall"
issue (WB Step 5 territory) in this phase or defer. The simplest
ship-now approximation: only stencil-mark portals on the CAMERA'S
OWN CELL (not BFS-extended VisibleCellIds). This restricts stencil
to portals directly adjacent to the camera. Loses cross-cell-portal
visibility (probably acceptable for first ship).
Phase 1 output: a short report (<400 tokens chat, full doc to
docs/research/YYYY-MM-DD-a8-entity-taxonomy.md). Plus a fix-shape
sketch covering all 6 entity classes. Get user approval before Phase 2.
### Phase 2 — Re-PLAN Task 7 (USE superpowers:writing-plans skill)
The re-plan replaces Task 7 (and may reshape `EntitySet` enum semantics
or add new partition values). Expected shape:
- Task R1: Add the entity distinguisher (e.g. AABB-encloses-camera
helper on WbDrawDispatcher, or new WorldEntity.IsBuildingShell field
if going that route).
- Task R2: Update WbDrawDispatcher.EntitySet partition to use the
distinguisher. May rename enum values to reflect new taxonomy.
Update unit tests.
- Task R3: Add a third dispatcher call for live entities AFTER the
stencil work. Either new EntitySet value or a flag parameter.
- Task R4: Re-wire GameWindow render frame with MarkAndPunch FIRST
order. Three (or four) dispatcher calls when inside:
1. Indoor pass (cell mesh + cell statics + building shell stabs)
2. MarkAndPunch
3. Terrain stencil-gated
4. Outdoor scenery pass (stencil-gated)
5. Live entity pass (no stencil, AFTER everything)
- Task R5: Visual verification — MUST test at cottage interior + cellar,
inn interior, AND a dungeon (portal-entry). Each likely surfaces
different bugs.
- Task R6: Ship docs (close #78, update CLAUDE.md A8 paragraph) —
only if all three visual scenarios pass clean.
The infrastructure from Tasks 1-6 is ready. The re-plan only needs to
ship the new integration. Keep tasks small (TDD-shaped where possible);
the GL integration tasks are visual-verification-only by nature.
### Phase 3 — Implement (USE superpowers:subagent-driven-development)
Same pattern as original. Fresh Sonnet subagent per task with two-stage
review. CRITICAL: spec reviewer must ALSO check "does the spec's
architectural premise match the actual entity taxonomy?" Don't repeat
the original's mistake of reviewing implementation without questioning
the spec's foundational assumption.
## Constraints
- Per CLAUDE.md "no workarounds" rule — fix the root cause, do not
patch symptom sites. The re-plan IS the root-cause fix for the
taxonomy issue; Round 1-3 patches were band-aids that didn't address
the underlying classification gap.
- Visual verification is the acceptance test. Test at AT LEAST THREE
building types (cottage, inn, dungeon) before declaring success.
- The cellar-stairs grass artifact FROM OUTSIDE is NOT A8 scope (no
stencil work happens when camera is outside). File as a separate
issue if not already filed, with a note that it's a deep-cell
terrain Z-fight (not solvable by #100's 1cm nudge).
- The "far-side portal visible through wall" issue may be addressed
in this phase or deferred to A8.P2. Decide explicitly during Phase 1.
- DON'T re-revert the infrastructure. Tasks 1-6 commits are kept
intentionally; the re-plan consumes them. The only thing being
re-shipped is the integration design.
## What success looks like
After this re-plan ships:
- Standing inside a Holtburg cottage (any room), all walls are
SOLID — no see-through to outdoor objects, no see-through to
adjacent rooms.
- Standing inside Holtburg Inn, same. No outdoor stabs through
walls/floor (#78's primary acceptance).
- Standing in cottage cellar, no grass overlay on stair geometry
(the cellar-stairs in-to-out half of the artifact; the
out-to-in half is separate).
- Player character + NPCs are FULLY VISIBLE indoors at all camera
angles. No partial body, no head-backwards, no flickering on
enter/exit (or document any residual flickering as a known
issue).
- Closed doors BLOCK outdoor visibility. Open doors SHOW outdoor
through the opening, occluded properly by surrounding wall.
- No regression on issue #100 (no transparent rectangles around
cottages).
- dotnet build green; dotnet test failures within the documented
14-23 flaky window.
## Reference repo hierarchy reminder
Per CLAUDE.md "Reference repos: cross-check the relevant ones" —
for the entity taxonomy / building shell question:
- WB's PortalRenderManager + StaticObjectRenderManager (how WB
splits buildings from outdoor scenery)
- WB's VisibilityManager (the proven stencil pipeline with the
correct GL state order)
- Retail decomp for CLandBlock::init_buildings (the data-model
source — how retail tags building objects vs. scenery)
- ACE (server) has minimal coverage here — buildings are
client-side decoration
Cross-reference WB + retail. The acdream-specific question is HOW
acdream's WorldEntity model can express the building-vs-scenery
distinction.
```
---
## Files state at session end
```
Branch: claude/strange-albattani-3fc83c
HEAD: fef6c61 Revert "feat(render): Phase A8 — wire stencil pipeline into render frame"
Parent: 96f8bd2 Revert "fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass"
Grandparent: c897a17 Revert "fix(render): Phase A8 — mark-and-punch BEFORE indoor draw (correct WB order)"
Before reverts: b76f6d1 fix(render): Phase A8 — mark-and-punch BEFORE indoor draw
Infrastructure base: dcf69a1 → a1c393e → 3973596 → 344034b/f3d7b13 → 2d31d49 → 6577c0a → d834188 → fee878f
Working tree: clean
Build: green (0 warnings, 0 errors)
Tests: 26 WbDrawDispatcher + 5 IndoorCellStencilPipeline + 2 PortalPolygons + 1 ProbeVisibility = 34 A8 infrastructure tests passing
Untracked: launch-a8-verify*.log (session logs, can be deleted)
```
The reverts are NEW commits (not destructive history rewrites — original commits remain in history for evidence). The re-plan can `git log b76f6d1..fef6c61` to see exactly what was reverted, or `git diff dcf69a1..fef6c61` to see the net effect on the codebase (should be: only test file is at slightly different state; everything else from Tasks 1-6 is in place).

View file

@ -1,69 +0,0 @@
# A8 RR0 falsification — are Issues A and C pre-existing or A8-caused?
**Date:** 2026-05-26 (PM)
**Method:** three-branch launch + visual repro at Holtburg cottage entry / exit transitions.
## Observations
| Branch | Commit | Issue C (entry transparent floor) | Issue A (exit through-ground / walls missing) | #78 (constant houses-below-terrain visible from inside) |
|---|---|---|---|---|
| HEAD | `2bfeafd` (R3.5 v2) | **YES** | **YES** (varies by building) | (gone — R1+R2+R3 fixed) |
| R3 baseline | `60f07bc` | **YES** | **YES** (same as HEAD) | (gone — R1+R2+R3 fixed) |
| main | `7034be9` | **NO** | **NO flicker** — but DIFFERENT SYMPTOM: houses below terrain visible from inside, constant (not transition) | **YES, constant** |
User screenshots from HEAD captured during the spike:
1. Cottage interior: floor partly see-through to outdoor grass; misplaced textured panel visible
2. Cottage exterior: brown floor/wall panel floating in space; surrounding building walls missing
User quote on main observation:
> "No floor is not transparent."
> "When I now stand in the cottage and look out I can see houses below the terrain. There is no flick when I pass out. They are just visible all the time"
## Diagnosis
Issues A and C are **NOT pre-existing.** They are caused by **R3 (the stencil pipeline wire-in)**:
- R3 successfully closes the original #78 symptom (constant houses-below-terrain visibility from inside) ✓
- R3 introduces two new artifacts as side-effects:
- Issue C — cottage floor transparent showing cellar during entry transition
- Issue A — through-ground objects + walls-missing flicker during exit transition
The R3.5 v1+v2 patches were attempts to mitigate, didn't help (R3 baseline and HEAD show identical A+C symptoms).
## Decision
Per the design's decision gate at RR0-S5:
- [x] **Outcome 2 selected:** Only R3 + HEAD reproduce → A and/or C caused by R3 work specifically. **PAUSE the plan.** Re-brainstorm via `superpowers:brainstorming` to address them; update the design doc; resume.
The original restructure design assumed Issues A and C might be pre-existing and could be filed as separate out-of-A8-scope issues. RR0 invalidates that assumption. The restructure must address them OR accept that A8 trades one bug class for another (which the user has not approved).
## Open questions for the re-brainstorm
1. **Mechanism of Issue C (entry transparent floor):** what about R3's stencil work makes cottage floor transparent during entry? Hypotheses:
- Stencil bit 1 set on portal silhouettes but cleared next frame; during the entry the camera-cell hadn't yet promoted, so VisibleCellIds was empty, so MarkAndPunch had no portals to mark → outdoor pass effectively ungated, terrain re-draw beats indoor cell mesh at the floor pixels.
- Depth-clear-if-inside firing too early or too late, leaving the depth buffer in a bad state.
- The cottage cell's mesh + the cellar cell's mesh both included in IndoorPass at adjacent Z values, Z-fight is fundamental.
2. **Mechanism of Issue A (exit through-ground flicker):** during grace frames after exit, `cameraInsideCell=true` but `cameraReallyInside=false`. Sky skipped, terrain drawn, depth-clear skipped, stencil branch skipped, outdoor Draw(All) runs. Why do entities below terrain win the depth test in these specific frames?
3. **Will the WB-faithful restructure help, hurt, or be neutral on A and C?** The restructure removes the depth-clear and initial-terrain workarounds. During grace frames after exit, it gates terrain on `!cameraInside` (true since cameraInside is strict). So terrain DRAWS unconditionally during grace (because !cameraInside = !false = true → draws). Behavior identical to main during these frames → likely re-introduces #78 main symptom for ~3 grace frames after exit. Trade-off: 3 frames of #78 vs 3 frames of Issue A.
4. **Should we shorten or eliminate the cell-switch grace mechanism?** Currently 3 frames. If 0 frames, the gate flips strict and cleanly at the threshold. PointInCell epsilon (0.01f) provides minimal hysteresis but might be enough.
5. **Is there a third option** between "stencil pipeline gates outdoor visibility" (causes A+C) and "no stencil work" (causes #78)? Possibilities:
- Stencil work but with different cell-set scoping (only camera-cell's portals, not BFS-extended; already in R3).
- Hybrid: stencil-gate outdoor scenery but NOT terrain (let terrain draw unconditionally + accept #78 leak for terrain only).
- Frame-based heuristic: skip stencil for first N frames after entry/exit to mask the transition artifact.
## Logs
- `launch-a8-rr0-head.log` / `launch-a8-rr0-head-take2.log` / `launch-a8-rr0-head-take3.log` — HEAD launches (2bfeafd)
- `launch-a8-rr0-r3.log` — R3 baseline launch (60f07bc, GameWindow.cs single-file checkout)
- `launch-a8-rr0-main.log` — main launch (7034be9, side worktree at .claude/worktrees/tmp-main-baseline with WorldBuilder ref junction)
## Cleanup performed
- Restored HEAD's GameWindow.cs in this worktree (no working-tree changes left)
- Removed Windows junction `tmp-main-baseline/references/WorldBuilder` → strange-albattani-3fc83c
- Removed side worktree `.claude/worktrees/tmp-main-baseline`

View file

@ -1,250 +0,0 @@
# Phase A8 — RR1 cleanup SHIPPED. Handoff for RR2 spike in fresh session.
**Date:** 2026-05-26 (PM, end of session)
**Branch:** `claude/strange-albattani-3fc83c` (worktree at `.claude/worktrees/strange-albattani-3fc83c`)
**HEAD:** `29e306b` (RR1 footer-marks)
**Build:** green (0 errors, 0 warnings in App; 6 warnings in test projects are pre-existing lint)
**Tests:** within documented 14-23 flaky window
---
## TL;DR
This session pivoted Phase A8 from "WB-faithful restructure" to "**full WorldBuilder RenderInsideOut + RenderOutsideIn port**" after RR0 falsification revealed R3+R3.5's bugs were structural (BFS-wide cell rendering), not just workaround-induced.
Shipped this session:
- ✅ RR0 falsification spike (proved Issues A+C are R3-caused, not pre-existing on main)
- ✅ Re-brainstorm with new design doc
- ✅ 12-task implementation plan
- ✅ RR1 cleanup: `[vis]` probe committed; R3+R3.5 v1+v2 reverted; old design+plan footer-marked as superseded
Next: **RR2 spike** — inspect `LandBlockInfo.Buildings` data shape + WB's interior-portal walk algorithm before implementing `BuildingLoader` in RR3.
---
## State altitudes
- **Currently working toward:** M1.5 — Indoor world feels right
- **Current phase:** A8 — full WorldBuilder RenderInsideOut + RenderOutsideIn port
- **Tasks remaining:** RR2 (spike) → RR3-RR11 (impl + visual gates) → RR12 (ship)
- **Estimated remaining:** 8-10 sessions / 1.5-2 weeks calendar
---
## What shipped this session (8 commits)
| SHA | What |
|---|---|
| `f9bab50` | docs(research): RR0 findings — A+C caused by R3, NOT pre-existing on main |
| `ea60d1f` | docs(spec): Full WB RenderInsideOut + RenderOutsideIn port design |
| `651e7e2` | docs(plan): 12-task implementation plan |
| `84c4a70` | diag(render): `[vis]` probe — light up dormant `ProbeVisibilityEnabled` |
| `664ca9c` | Revert R3.5 v2 (`2bfeafd`) |
| `b931038` | Revert R3.5 v1 (`38d5374`) |
| `fd721af` | Revert R3 (`60f07bc`) — with `[vis]` probe preserved through conflict |
| `29e306b` | docs: superseded the prior restructure design + plan |
Kept (NOT reverted): R1 (`ed72704` IsBuildingShell tag) + R2 (`55f26f2` EntitySet partition) + Tasks 1-6 infrastructure (PortalPolygons, IndoorCellStencilPipeline, portal_stencil shaders, ProbeVisibilityEnabled). All consumed as-is by the upcoming work.
---
## Canonical pickup docs (READ FIRST in fresh session)
In order:
1. **[docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md](../superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md)** — the approved design. Single source of truth for what's being built.
2. **[docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md](../superpowers/plans/2026-05-26-phase-a8-wb-full-port.md)** — 12-task plan. Pickup at RR2.
3. **[docs/research/2026-05-26-a8-rr0-falsification-findings.md](2026-05-26-a8-rr0-falsification-findings.md)** — evidence that triggered the scope expansion.
4. **[references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs)** — the proven reference (RenderInsideOut Steps 1-5 at 73-239; RenderOutsideIn at 241-330).
5. **[CLAUDE.md](../../CLAUDE.md)** — "Currently working toward" section.
---
## RR2 — the spike
### Goal
Before implementing `BuildingLoader` in RR3, verify (a) what fields `DatReaderWriter.Types.BuildingInfo` exposes (specifically the portal list field name + the per-portal `OtherCellId`); (b) how WB's `PortalRenderManager` actually computes a building's full cell set from BuildingInfo entries (the interior-portal walk algorithm).
The plan's RR3 tests reference `building.Portals` and `BldPortal.OtherCellId` — RR2 confirms or corrects those names.
### Steps (per plan §RR2, 6 sub-steps)
1. **RR2-S1** — Inspect `BuildingInfo` struct shape:
```bash
grep -rn "class BuildingInfo\|struct BuildingInfo\|record BuildingInfo" references/Chorizite.DatReaderWriter/ 2>/dev/null | head -5
```
Or look in NuGet cache: `~/.nuget/packages/chorizite.datreaderwriter/*/lib/*/BuildingInfo.cs`. Also document what `LandblockLoader.cs:74-87` references.
2. **RR2-S2** — Read WB `PortalRenderManager.cs:518-551` (or grep `BuildingPortalGPU` + `EnvCellIds`):
```bash
grep -n "BuildingPortalGPU\|EnvCellIds" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs | head -10
```
Document the interior-portal walk algorithm.
3. **RR2-S3** — Live-inspect a Holtburg cottage's BuildingInfo. Add a temporary diagnostic in `src/AcDream.Core/World/LandblockLoader.cs:74-87` (the Buildings loop):
```csharp
Console.WriteLine($"[building-shape] lb=0x{landblockId:X8} idx={i} ModelId=0x{building.ModelId:X8} " +
$"Frame.Origin=({building.Frame.Origin.X:F1},{building.Frame.Origin.Y:F1},{building.Frame.Origin.Z:F1}) " +
$"Portals={building.Portals?.Count ?? 0}");
if (building.Portals is not null)
{
foreach (var p in building.Portals)
Console.WriteLine($"[building-shape] portal -> OtherCellId=0x{p.OtherCellId:X8} " +
$"(remaining fields: {p.GetType().GetProperties().Length} total)");
}
```
Build + launch + walk to Holtburg. Capture log. Then `git checkout HEAD -- src/AcDream.Core/World/LandblockLoader.cs` to revert.
4. **RR2-S4** — Write `docs/research/2026-05-26-a8-buildings-data-shape.md` with 5 sections (per plan).
5. **RR2-S5** — Commit findings.
6. **RR2-S6** — Gate decision:
- Data shape compatible with design → proceed to RR3.
- Data shape incompatible → STOP, re-brainstorm.
### Expected duration
~30-60 minutes including live-inspect cycle.
### Human-in-the-loop step
RR2-S3 (live-inspect) needs the user to launch + walk into a cottage + report.
---
## Quick context primer for a fresh session
### Why this phase
RR0 falsification proved:
- HEAD (R3.5 v2): Issues A + C reproduce
- R3 baseline (60f07bc): same A + C (R3.5 patches didn't help)
- main (7034be9, no A8 work): A + C don't reproduce, BUT constant #78 (houses below terrain from inside)
R3's stencil work fixed #78 but introduced A + C by rendering all 16 BFS-reachable cells at full screen extent. The fix is **per-portal recursive culling** (what retail + WB both do). For acdream's stack, WB's stencil approach is closer to existing infrastructure than retail's polygon-clip scissor.
### Why full WB port (not minimum)
The user explicitly chose "full WB port now" over (a) revert all A8 and live with #78 or (b) keep R1+R2 and revert only R3+R3.5. Decision recorded in the design doc's "Brainstorm outcomes" section.
### What's in scope (per design)
- WB `RenderInsideOut` Steps 1-5 (including 3-stencil-bit cross-building visibility, Step 5)
- WB `RenderOutsideIn` (cottage interiors visible through windows from outside)
- Per-building cell association (Building + BuildingRegistry + LoadedCell.BuildingId — Option C dual-indexed per user's Q2 answer)
- Single strict `cameraInsideBuilding` gate (drop grace for render path; CellVisibility's grace stays alive for non-render consumers)
- Stencil-gated sky inside indoor branch (acdream enhancement over WB)
### What's NOT in scope
- Retail polygon-clip scissor port (multi-week alternative)
- Cell-side `BuildingId` as SOLE data source (Option B — rejected for awkward API)
- Reverting R1+R2 (kept — orthogonal infrastructure)
---
## Files state at session end
```
Branch: claude/strange-albattani-3fc83c
HEAD: 29e306b (RR1 footer-marks)
Working tree: clean (only untracked log files + research docs from prior sessions)
Build: green
Tests: within flaky window
Uncommitted predecessor docs (intentionally not committed by previous sessions):
docs/research/2026-05-26-a8-entity-taxonomy.md
docs/superpowers/plans/2026-05-26-phase-a8-indoor-cell-visibility-culling.md
docs/superpowers/plans/2026-05-26-phase-a8-replan.md
(plus several A6 / issue-78 / issue-101 / cellar saga docs)
These are not blocking — they were referenced by handoff docs that DID get
committed, so the chain works at the file-system level. If a fresh session
wants tidy git history, run a single tidy-up commit gathering these.
```
---
## Pickup prompt for fresh session
```
Phase A8 — full WB RenderInsideOut + RenderOutsideIn port. RR1 cleanup
shipped 2026-05-26 PM (commits 84c4a70 → 29e306b). Pick up at RR2 (spike).
Read first (REQUIRED, in order):
1. docs/research/2026-05-26-a8-wb-full-port-rr1-shipped-handoff.md
(this doc)
2. docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md
(the approved design)
3. docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md
(12-task plan; pick up at RR2)
4. docs/research/2026-05-26-a8-rr0-falsification-findings.md
(evidence triggering the scope expansion)
5. references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330
(the proven reference)
6. CLAUDE.md — "Currently working toward" line + A8 paragraph
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A8 — full WB port
HEAD: 29e306b (RR1 footer-marks)
Test baseline: build green; ~14-23 flaky test window (pre-existing)
Session flow:
### RR2 — spike (this session)
Per plan §RR2, 6 steps:
S1: inspect DatReaderWriter.BuildingInfo via grep/nuget
S2: read WB PortalRenderManager:518-551
S3: live-inspect a Holtburg cottage's BuildingInfo (temp diagnostic in
LandblockLoader.cs, launch, user walks to cottage, capture log,
revert diagnostic)
S4: write docs/research/2026-05-26-a8-buildings-data-shape.md
S5: commit findings
S6: gate — if data shape compatible, proceed to RR3; else re-brainstorm
Expected ~30-60 min. Single commit.
### RR3-RR12 — subsequent sessions
Subagent-driven (one fresh Sonnet subagent per code task with two-stage
review). Direct orchestration for RR8/RR10/RR12 visual gates.
## Constraints
- Per CLAUDE.md "no workarounds" rule — fix root causes. The new design
is the proper fix; don't iterate on R3.5-style symptom patches.
- WB code under references/WorldBuilder/ is MIT-licensed and the same
stack as acdream (Silk.NET, .NET); port verbatim where possible with
WB line refs in comments.
- Visual verification is the acceptance test. RR8 gate must close #78
+ R4 Issues A + C BEFORE proceeding to RR9 (Step 5).
- DO NOT re-revert R1 (ed72704) + R2 (55f26f2) — they're orthogonal
infrastructure consumed by the upcoming work.
## What success looks like
After all 12 tasks ship (~1.5-2 weeks calendar):
- Standing inside Holtburg cottage / cellar / inn / dungeon: all walls
solid. Sky visible through windows.
- Exit/entry transitions clean (Issues A + C closed).
- Cross-building visibility (Step 5) working — inn → cottage interior.
- Cottage interiors visible from outside through windows
(RenderOutsideIn).
- #78 closed; #102 closed; no regression on #100.
- M1.5 indoor scope fully shipped.
```
---
## Why pause now
The session has covered ~8 hours of brainstorm + design + plan + cleanup. Context budget is substantial. A fresh session for RR2 (the spike) lets the upcoming long stretch of RR3-RR12 implementation start with maximum context room for the subagents to consume. This is exactly the milestone-discipline rhythm CLAUDE.md describes.
Tomorrow's session opens with the pickup prompt above. The work is well-shaped: RR2 is small (≤1 hour); each of RR3-RR11 is one or two sessions of subagent-dispatched work with two-stage review; RR8 + RR10 + RR12 are visual gates that need ~30 min each of you driving the test client.
Good night. M1.5 is closer than it was this morning.

View file

@ -1,485 +0,0 @@
# Phase A8 RR7 reverted — full WB port handoff (2026-05-27)
## TL;DR for next session
RR7 (render-frame integration) shipped 4 times in one session; all 4 broke
the visual differently. **All four are reverted.** Branch is back to the
pre-A8 visual ("looks good"). RR3-RR6 infrastructure (`Building`,
`BuildingRegistry`, `BuildingLoader`, `WbDrawDispatcher.Draw(cellIds:)`
overload, `IndoorCellStencilPipeline` 3-bit + occlusion-query) remains
shipped + tested in isolation.
**The fundamental mistake:** RR3-RR7 ported WB's RenderInsideOut Steps 1-4
**conceptually** but routed cell-mesh rendering through our
`ObjectMeshManager` / `WbDrawDispatcher.Draw(IndoorPass)` pipeline. WB
doesn't do that — WB has a separate `EnvCellRenderManager` (862 LOC) that
renders cells via a different path. Without extracting that, the indoor
branch fires (gate works post-RR7.2) but cell interiors never render →
flat fog-color floors.
**Next session's mission:** port WB **verbatim**, including extracting
`EnvCellRenderManager.cs` + dependencies into our tree. No conceptual
adaptations. No "modern equivalent" decisions. Follow WB byte-for-byte
where the algorithm runs, just as Phase O extracted WB's mesh path.
User direction (verbatim, 2026-05-27):
> "Either we port exact behavior from retail or we port exact behavior
> from WB. ... Make a detailed plan to port WB verbatim behaviour to fix
> this. No quickfixes or fixes that might cause issues down the line ...
> use superpowers but DONT stop me for questions, be perfect, no
> band-aids. When you have a visual test ready with all rendering fix
> for this you launch the client for me to verify."
User decision: **WB**. (See decision rationale in
"Why WB and not retail" below.)
## Session log — what was tried and why it failed
This session opened picking up RR2 (BuildingInfo data-shape spike,
shipped clean) and then drove RR3 → RR4 → RR5 → RR6 → RR7 as planned.
The four RR7-variant fix attempts came after the user reported broken
visuals at the first visual gate.
### Commits shipped this session, before revert
| SHA | Phase | Status now | What it did |
|---|---|---|---|
| `f44a9bf` | RR2 | **KEPT** | Findings doc — `BuildingInfo` data shape + WB walk algorithm |
| `f125fdb` | RR3 | **KEPT** | `Building` + `BuildingRegistry` + `BuildingLoader` + 10 unit tests |
| `f8d0499` | RR4 | **KEPT** | `LoadedCell.BuildingId` + landblock-load wiring + 1 test |
| `3361933` | RR5 | **KEPT** | `WbDrawDispatcher.Draw(cellIds:)` overload + 2 tests |
| `6a7894a` | RR6 | **KEPT** | `IndoorCellStencilPipeline` 3-bit + 9 occlusion-query/state methods |
| `3d28d70` | RR7 | **REVERTED** by `4fa3390` | GameWindow render-frame restructure |
| `a1a3e0e` | RR7.1 | **REVERTED** by `21dc72b` | `AllLoadedCells` + late-stamp on drain |
| `efe3520` | RR7.2 | **REVERTED** by `9aaae02` | `_buildingRegistries` key normalization |
| `56673e1` | RR7.3 | **REVERTED** by `07c5981` | Dat-driven BFS in BuildingLoader |
Net infrastructure shipped: 5 commits, ~1100 LOC of production + 13
unit tests. All correct in isolation. None of the integration code
remains on the branch.
### Visual-gate launches and what they revealed
**Launch v1 — RR7 alone (commit `3d28d70`)**
- User reported: "Yes looks good!"
- `[vis]` log: `branch=indoor` count = **0** (out of 47,266 outdoor
decisions). 17,748 frames had `inside=True really=True` (camera in an
indoor cell) — but the gate's `BuildingId is not null` check failed
every time.
- **Why "looks good" was misleading:** RR7's call site used
`drainedCells` (the per-frame `_pendingCells` drain). Cells streamed
in over many frames, but `BuildingLoader.Build` ran once per landblock
load with whatever was in drainedCells THAT frame. Most building cells
were stamped on a frame when they weren't yet drained, so
`BuildingId` stayed null. Then `cameraInsideBuilding=false`, the
outdoor branch ran with full sky + initial terrain. Visually
indistinguishable from pre-A8.
- **My process failure:** declared visual gate passed without reading
the `[vis]` data first. "Looks good" without diagnostic correlation
is not verification.
**Launch v2 — RR7 + RR7.1 (`a1a3e0e`)**
- User reported: "All textures are missing, ground, sky only buildings
and objects are visible. Looks much worse."
- `[vis]` log: `branch=indoor` STILL 0 of 163,670 (with 125,476
`inside=True`).
- **Why it got worse:** RR7.1 made `BuildingLoader.Build` use
`_cellVisibility.AllLoadedCells` (every loaded cell, not just the
drain) which stamped MORE cells with `BuildingId`. That made
`cameraInsideBuilding=true` for more frames. But the registry-key
lookup at the gate STILL missed (storage at `0xA9B4FFFF`, lookup at
`0xA9B40000` — see RR7.2 below). So `cameraInsideBuilding=true`
sky + initial terrain GATED OFF → indoor branch's inner gate
(`camBuildings.Count > 0`) FAILED → outdoor branch ran WITHOUT sky
and terrain → black through windows.
**Launch v3 — RR7 + RR7.1 + RR7.2 (`efe3520`)**
- User reported: missing texture indoors (screenshot shows light-grey
fog-color areas where cell interior surfaces should be).
- `[vis]` log: `branch=indoor` = **119,471** vs outdoor 2,910. Indoor
branch finally fires.
- **Why it still broke:** RR7.2 fixed the registry key. Indoor branch
fires, `MarkAndPunch` runs, `Draw(IndoorPass, cellIds: camCellIds)`
runs. Building shells (cottage walls / inn walls — the
`IsBuildingShell` entities) render. But cell-mesh entities
(registered with `MeshRef(envCellId, ...)`) don't produce a textured
floor. The `[vis]` data confirms the gate works; the visual confirms
the cell-mesh path doesn't.
**Launch v4 — RR7 + RR7.1 + RR7.2 + RR7.3 (`56673e1`)**
- User reported: still flat grey areas.
- **Why it still broke:** RR7.3 made BFS dat-driven so building
EnvCellIds is complete regardless of cell load timing. Confirmed
BFS short-circuiting was NOT the cause — `camCellIds` contains the
user's current cell, the cell-mesh entity is walked, but the floor
doesn't appear.
### Root cause (only fully understood at session end)
WB's `VisibilityManager.RenderInsideOut`
(`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239`)
renders the inside-building cells via:
```csharp
envCellManager!.Render(pass1RenderPass, _currentEnvCellIds);
```
This calls into a **separate manager class**
`EnvCellRenderManager.cs`, 862 LOC, also in WB — that handles cell
rendering with its own GL pipeline, separate from
`ObjectMeshManager.cs`. The two managers exist because cell rendering
has different requirements (per-cell texture batching, different
transparency handling, cell-portal-aware geometry) from per-GfxObj
rendering.
Our RR7 collapsed Steps 3 (cell rendering) and Step 4 (stencil-gated
outdoor) into:
```csharp
_wbDrawDispatcher!.Draw(camera, ..., cellIds: camCellIds,
set: EntitySet.IndoorPass);
```
The dispatcher's `IndoorPass` walks entities including cell-mesh
entities (created in `GameWindow.BuildInteriorEntitiesForStreaming` at
line ~5441 with `MeshRefs = new[] { cellMeshRef }` where
`cellMeshRef.GfxObjId = envCellId`). But `ObjectMeshManager`'s draw
path is fundamentally per-GfxObj batched + MDI; it has a dat-side
`PrepareEnvCellMeshData` path (line ~1184 of WB's ObjectMeshManager,
also in our extracted copy) but that path's output isn't wired into
the dispatcher's instance-buffer layout the same way GfxObj meshes
are. Building shells render (they ARE GfxObj entities with proper
mesh refs after hydration at line ~5160). Cell meshes don't render
correctly.
In short: **the cell-mesh entity scheme we use is an architectural
mismatch with WB's render algorithm.** WB renders cells through
`EnvCellRenderManager.Render(cellIdSet)` — a per-cell rendering call.
We render cells through `Dispatcher.Draw(set: IndoorPass)` — a
per-entity batched call. The two are not interchangeable.
## Why WB and not retail
User asked decisively: "Either we port exact behavior from retail or we
port exact behavior from WB. What do you want?"
I chose WB. Reasons:
1. **Retail's algorithm doesn't fit modern GL.** Retail's
`PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709` uses
software polygon-clip rects (set per portal during recursive cell
traversal). Porting verbatim requires either (a) inventing a
modern-equivalent — which is what WB already did — or (b)
implementing per-fragment shader-discard against portal polygons,
which is expensive and non-trivial.
2. **WB is already our rendering base.** Phase N.4 (2026-05-08) adopted
WB as our rendering oracle. Phase N.5 made WB's bindless +
`glMultiDrawElementsIndirect` mandatory. Phase O (2026-05-21)
extracted WB's mesh + dat-handling code into our tree
(`references/WorldBuilder/` remains as read-reference, but the
actual pipeline files live at `src/AcDream.App/Rendering/Wb/`).
Adopting WB's `EnvCellRenderManager` + `VisibilityManager` is the
natural continuation.
3. **Modern code, retail behavior** — WB is the existing "modern code,
retail-equivalent behavior" port. WB's stencil-based RenderInsideOut
is the modern-GL realization of retail's polygon-clip algorithm.
The observable behavior matches.
4. **Same exact stack.** WB is MIT-licensed Silk.NET + .NET 10 +
DatReaderWriter — verbatim our stack. No translation cost.
5. **Tested by WB's developers.** WB's RenderInsideOut works in their
tool. Faithful porting means we inherit their validation.
## What WB's render frame actually does (the spec for the redo)
The render frame algorithm lives at
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239`.
The `RenderInsideOut` method takes managers as parameters
(`portalManager`, `envCellManager`, `terrainManager`, `sceneryManager`,
`staticObjectManager`, `sceneryShader`). Each step:
### Step 1: Stencil bit 1 at our building's portals (lines 78-97)
- `Enable(StencilTest)`, `ClearStencil(0)`, `Clear(StencilBufferBit)`.
- `Disable(CullFace)`, `StencilFunc.Always(1, 0xFF)`,
`StencilOp(Keep, Keep, Replace)`, `StencilMask(0x01)`,
`ColorMask(false×4)`, `DepthMask(false)`, `Enable(DepthTest)`,
`DepthFunc.Always`.
- For each building containing the current cell: `portalManager?.RenderBuildingStencilMask(building, snapshotVP, false)`.
### Step 2: Punch depth at portals (lines 99-105)
- `DepthMask(true)`, `DepthFunc.Always`.
- For each building containing the current cell: `RenderBuildingStencilMask(building, snapshotVP, true)`.
### Step 3: Render OUR cells (stencil OFF) (lines 107-127)
- `ColorMask(true, true, true, false)` (note: alpha bit OFF — WB intentional choice).
- `DepthMask(true)`, `Disable(StencilTest)`, `DepthFunc.Less`.
- `sceneryShader?.Bind()`.
- Collect `_currentEnvCellIds` from `_buildingsWithCurrentCell.SelectMany(b => b.EnvCellIds)`.
- `envCellManager!.Render(pass1RenderPass, _currentEnvCellIds)`.
- If transparency enabled: `DepthMask(false)`, render transparent pass, `DepthMask(true)`.
### Step 4: Stencil-gated outdoor — terrain + scenery + static objects (lines 129-154)
- If `didInsideStencil` (we had buildings): `Enable(StencilTest)`,
`StencilFunc.Equal(1, 0x01)`, `StencilOp(Keep, Keep, Keep)`,
`StencilMask(0x00)`, `ColorMask(true, true, true, false)`,
`DepthMask(true)`, `Enable(CullFace)`, `DepthFunc.Less`.
- `terrainManager.Render(snapshotView, snapshotProj, snapshotVP, snapshotPos, snapshotFov)`.
- `sceneryShader?.Bind()`.
- If scenery enabled: `sceneryManager?.Render(pass1RenderPass)`.
- If static-objects/buildings shown: `staticObjectManager?.Render(pass1RenderPass)`.
### Step 5: Other-buildings' cells through portals (lines 156-232)
- Collect `_otherBuildings` from `_visibleBuildingPortals` filtering OUT
buildings that contain `currentEnvCellId`.
- For each other-building (per `_otherBuildings`):
- Read back previous frame's occlusion query
(`GetQueryObject(building.QueryId, ResultAvailable)`,
`GetQueryObject(... Result)`). Update `building.WasVisible`.
- Start new query: `BeginQuery(SamplesPassed, building.QueryId)`,
`building.QueryStarted = true`.
- **a. Mark Bit 2 (Ref=3, Mask=0x02) where Bit 1 set**
(`StencilFunc.Equal(3, 0x01)`, `StencilOp Replace`,
`StencilMask 0x02`, `ColorMask off`, `DepthMask off`,
`Disable(CullFace)`).
`portalManager?.RenderBuildingStencilMask(building, snapshotVP, false)`.
- `EndQuery(SamplesPassed)`.
- **b. Clear depth where Stencil == 3** (`StencilFunc.Equal(3, 0x03)`,
`StencilMask 0x00`, `DepthMask true`, `DepthFunc.Always`).
`RenderBuildingStencilMask(building, snapshotVP, true)`.
- **c. Render other-building's EnvCells gated by Stencil == 3**
(`ColorMask(true, true, true, false)`, `DepthFunc.Less`,
`Enable(CullFace)`). `sceneryShader.Bind()`.
`envCellManager.Render(pass1RenderPass, building.EnvCellIds)` (+ transparent).
- **d. Reset Bit 2 back to 0** for next iteration
(`StencilMask 0x02`, `StencilFunc.Always(1, 0x02)`,
`StencilOp Replace`, `ColorMask off`, `DepthMask off`).
`RenderBuildingStencilMask(building, snapshotVP, false)`.
### Cleanup (lines 234-238)
- `Disable(StencilTest)`, `StencilMask(0xFF)`, `ColorMask(true×3, false)`.
## Why our RR7 didn't match this
1. **No `envCellManager.Render(...)` call.** We routed cells through
`Dispatcher.Draw(IndoorPass)`, which is per-GfxObj-batched, not
per-cell.
2. **No separate transparency pass for cells.** Step 3's
`DepthMask(false) + Render(Transparent)` was missing.
3. **No `sceneryShader.Bind()` between passes.** WB's algorithm
assumes a specific shader is bound at each step; we never did.
4. **Step 5 missing entirely.** Cross-building visibility (cottage
cellar visible from cottage above, inn rooms visible through doors)
not implemented. Would have shipped in RR9 but RR7 should have at
least scaffolded the order.
5. **ColorMask alpha-bit pattern not preserved.** WB uses
`ColorMask(true, true, true, false)` deliberately — alpha-bit OFF.
Our outdoor branch's `Draw(All)` doesn't toggle alpha bit, but
WB's path does. Could affect alpha-to-coverage downstream.
## The plan for the next session
### Phase 1: Extract `EnvCellRenderManager` into our tree (~862 LOC)
Mirror Phase O's pattern:
1. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs`
in full.
2. Identify its dependencies — likely `GlobalMeshBuffer`,
`ObjectMeshManager` (already extracted), `TextureAtlasManager`,
`IRenderManager`, `RenderPass`, `SceneData`. Extract any missing
dependencies.
3. Copy `EnvCellRenderManager.cs` to
`src/AcDream.App/Rendering/Wb/EnvCellRenderManager.cs`.
4. Adapt namespaces (`Chorizite.OpenGLSDLBackend.Lib`
`AcDream.App.Rendering.Wb`).
5. Resolve any references to types we don't have. Stub or extract as
needed.
6. Build green. No tests yet at this step.
### Phase 2: Wire `EnvCellRenderManager` into the existing landblock load
`EnvCellRenderManager.Register(envCell, cellStruct, worldTransform, ...)` is
how cells join its registry. Currently we call `CellMesh.Build` at
`GameWindow.BuildInteriorEntitiesForStreaming` (line ~5423). Replace
that with the `EnvCellRenderManager` registration path — cell meshes
flow through ITS pipeline, not through ObjectMeshManager via fake-
GfxObj-id MeshRefs.
The `WorldEntity` we create with `MeshRefs = [cellMeshRef]` (line 5441)
becomes irrelevant for cell rendering — the EnvCellRenderManager owns
the cells, the dispatcher renders only entities that have real GfxObj
mesh refs.
### Phase 3: Replicate `VisibilityManager.RenderInsideOut` byte-for-byte
In `GameWindow.cs` render frame (after the per-frame `glClear` +
visibility computation), replace the `if (cameraInsideBuilding)
{ ... } else { ... }` block we shipped + reverted with a call to a
new method `RenderInsideOutAcdream` that follows WB's Steps 1-5 line by
line.
`PortalRenderManager.RenderBuildingStencilMask(building, vp, punch)` is
the other dependency. Extract from
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`
(702 LOC) — at minimum the stencil-mask method + its mesh upload path.
The plumbing may be reusable for our existing `IndoorCellStencilPipeline`.
Our `IndoorCellStencilPipeline` already implements WB's Steps 1+2 +
Step 5 a/b/c/d. The mismatch is **what calls them** — our code calls
them with `_indoorStencilPipeline.MarkAndPunch(...)` etc. WB calls them
via `portalManager.RenderBuildingStencilMask(building, vp, punch)`.
The pipelines are equivalent in spirit but the entry point differs.
Map our pipeline methods onto WB's interface signature so the
RenderInsideOut algorithm can call them by name.
### Phase 4: Probes BEFORE visual launches
Mandatory before any visual gate. Add (gated on
`ACDREAM_PROBE_VIS=1` or a new `ACDREAM_PROBE_ENVCELL=1` flag):
- **`[envcells]` per frame**: count of cells walked by
`EnvCellRenderManager.Render`, count of triangles drawn, the
cellId set being rendered.
- **`[stencil]` per frame**: vertex count uploaded for MarkAndPunch
(the existing pipeline emits this internally — surface it).
- **`[draworder]` per frame**: assertion that the algorithm ran each
step in the right order with the right GL state on entry.
When a visual gate fires:
- ALWAYS read the probe data FIRST. Confirm indoor branch fired,
envcells were rendered, stencil mask was non-empty.
- Compare probe data to expected (the design doc has the algorithm
spelled out).
- ONLY THEN ask the user for visual confirmation.
### Phase 5: Visual gate (single)
Once Phases 1-4 done + probe data confirms correct behavior:
launch the client for the user to verify. ONE gate. Not four.
## Open questions for the next session to investigate
These DON'T require user input — investigate during execution:
1. **`PortalRenderManager.RenderBuildingStencilMask` mesh upload.**
Does WB upload exit portal polygons differently than we do? Our
`UploadBuildingPortalMesh` (Phase A8 RR6) might map cleanly to
WB's expectation, or might need adjustment.
2. **`EnvCellRenderManager.Register` API.** What does it accept?
Compare to our `_pendingCellMeshes[envCellId] = cellSubMeshes`.
Identify the seam.
3. **Transparency pass.** WB's Step 3 has an `if
(state.EnableTransparencyPass)` second `Render(Transparent)` call.
We don't have a state object yet; need to either add one or pick
the default (likely enabled, since indoor transparency matters for
stained glass, ornate furniture).
4. **Occlusion queries (RR9 scope).** RR7's job was Steps 1-4 only;
RR9 was supposed to add Step 5. But WB's RenderInsideOut has Step 5
inline — we shouldn't split it. Land Steps 1-5 together in the
next attempt. RR9 becomes a no-op or absorbed.
5. **`OutdoorScenery` EntitySet.** WB's Step 4 calls
`sceneryManager.Render(pass1RenderPass)` and
`staticObjectManager.Render(pass1RenderPass)` separately. We've
collapsed both into `Draw(EntitySet.OutdoorScenery)`. Need to
verify our `OutdoorScenery` partition matches what WB's two
managers cover, OR split them into two dispatch calls.
## Process rules for the next session (carved from this session's mistakes)
1. **No visual-gate launch without probe data first.** If the probe
says branch=indoor count = 0, the user's "looks good" doesn't
confirm A8 is working. Read the probe BEFORE asking the user.
2. **No partial WB ports.** Extract the manager. Wire it. Implement
the algorithm in full. No "Steps 1-4 now, Step 5 later." The steps
are interdependent; partial implementations have wrong cumulative
state.
3. **No conceptual adaptations of WB.** If WB does X, do X. If our
stack has a different way of doing it, either extract the WB way
into our stack OR use the existing analog 1:1 without "improvement."
No new abstractions invented mid-port.
4. **Trust-but-verify after every subagent dispatch.** Subagents
compile + pass tests in their isolation but don't verify visual
correctness. The harness pattern from #98 saga applies: build the
apparatus first, then trust evidence over plausible-looking code.
5. **Acknowledge the cost-of-failure asymmetry.** Each "fix" that
doesn't work costs the user a launch cycle, screenshot review,
bug-report write-up. Three wrong fixes in a row > one fully-thought
fix. Slow down at the brainstorming step, not at the implementation
step.
## Files that remain shipped (RR3-RR6 infrastructure)
These work in isolation and stay on the branch:
| File | LOC | Tested |
|---|---|---|
| `src/AcDream.App/Rendering/Wb/Building.cs` | 57 | 2 tests |
| `src/AcDream.App/Rendering/Wb/BuildingRegistry.cs` | 73 | 4 tests |
| `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` | 144 | 5 tests |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (additions: `Draw(cellIds:)` overload + `WalkEntitiesForTestByCellIds`) | +153 | 2 tests |
| `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` (additions: 4 stencil-3-bit methods + 4 occlusion-query methods + UploadBuildingPortalMesh) | +243 | 0 tests (GL required) |
The `LoadedCell.BuildingId` field also persists (from RR4) — that's a
1-property addition to `CellVisibility.cs`. RR4's wire-in in
`GameWindow.cs` (the `_buildingRegistries` dict + the
`BuildingLoader.Build(...)` call at line ~5876 + the RemoveLandblock
callbacks) is **also reverted** by the RR7 revert chain — the dict and
all references to it are gone now. Confirm via:
```
grep -n _buildingRegistries src/AcDream.App/Rendering/GameWindow.cs
```
If zero matches, the revert is complete. If matches remain, RR4 needs
manual cleanup (likely a stray field declaration the revert didn't
catch).
## Pickup prompt for next session
> Read this entire handoff doc, then read these in order:
>
> 1. `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` (the RenderInsideOut algorithm we're porting verbatim)
> 2. `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs` (the manager to extract — 862 LOC)
> 3. `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs` (the other dependency — extract the stencil-mask method + any infrastructure)
> 4. `docs/architecture/worldbuilder-inventory.md` (what we've already extracted from WB and where it lives)
> 5. `docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md` (the original A8 plan — IGNORE its RR7 design, follow this handoff doc's plan instead)
>
> Then brainstorm + write a fresh detailed plan covering:
> - The exact extraction list (every WB file to copy into our tree)
> - The exact wire-in points in GameWindow.cs
> - The probe trail with format specifications
> - The expected visual outcomes per step
> - The order of execution (extraction → wiring → probes → visual gate)
>
> Use the superpowers:writing-plans skill. The plan goes to
> `docs/superpowers/plans/2026-05-28-phase-a8-wb-render-inside-out-port.md`.
>
> Once the plan is written, execute it without stopping. No questions
> to the user mid-flight. When the visual test is ready, launch the
> client for visual confirmation. Read probe data BEFORE accepting any
> "looks good" report.
>
> User authorization (verbatim 2026-05-27): "use superpowers but DONT
> stop me for questions, be perfect, no bandaids."
## Key references
- Plan we deviated from: `docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md`
- Design doc: `docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md`
- WB extraction precedent (Phase O): commit `6a7894a`'s parent chain
- WB code root: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/`
- This session's RR1 handoff (still relevant for project context):
`docs/research/2026-05-26-a8-wb-full-port-rr1-shipped-handoff.md`
- RR2 findings (BuildingInfo data shape — still accurate, useful for
understanding the building model):
`docs/research/2026-05-26-a8-buildings-data-shape.md`

Some files were not shown because too many files have changed in this diff Show more