Compare commits
81 commits
e2bc3a9e99
...
c6b3fd6ebf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b3fd6ebf | ||
|
|
f845b2241a | ||
|
|
7c516edd7b | ||
|
|
91b29d1a89 | ||
|
|
86ecdf9ee1 | ||
|
|
7f55e14cd7 | ||
|
|
ff548b962c | ||
|
|
e62d076f33 | ||
|
|
165f67add4 | ||
|
|
d942ff73c0 | ||
|
|
a5d6bb3536 | ||
|
|
1af49b710e | ||
|
|
a9c74d153a | ||
|
|
eb0f772f0f | ||
|
|
3ffe1e44f6 | ||
|
|
702b30a63e | ||
|
|
069534a372 | ||
|
|
aad697602e | ||
|
|
1969c55823 | ||
|
|
b282c69f28 | ||
|
|
48f0b26f62 | ||
|
|
f0900ebe12 | ||
|
|
1f11ba9b38 | ||
|
|
fda6af7ad0 | ||
|
|
c19d6fb321 | ||
|
|
4e308d567a | ||
|
|
3764867566 | ||
|
|
eeb45e16e3 | ||
|
|
27d7de11d8 | ||
|
|
18a2e28875 | ||
|
|
59a4e3f6cf | ||
|
|
b04ad448fa | ||
|
|
3bf30d2c2b | ||
|
|
2e422418ec | ||
|
|
98977b8f66 | ||
|
|
73288657fd | ||
|
|
b838eccb38 | ||
|
|
914638819d | ||
|
|
011a5e43f4 | ||
|
|
e9cc9cb228 | ||
|
|
251763b2c4 | ||
|
|
9f152d9754 | ||
|
|
25f009140a | ||
|
|
9b948b6ad5 | ||
|
|
36a29ceff5 | ||
|
|
1dd20ddd40 | ||
|
|
51a7619286 | ||
|
|
fda8d65158 | ||
|
|
b57cb42fd7 | ||
|
|
6b0230be43 | ||
|
|
1fc6c0fd69 | ||
|
|
e798cb7898 | ||
|
|
f6e9c58932 | ||
|
|
1024ba34e0 | ||
|
|
a54cd7bef6 | ||
|
|
67e64c79cf | ||
|
|
b7e954e50b | ||
|
|
8f30e13317 | ||
|
|
ff8f434711 | ||
|
|
91086adbac | ||
|
|
e5a5916679 | ||
|
|
0c1403f2e6 | ||
|
|
8ebd33dc8f | ||
|
|
5945f1d915 | ||
|
|
73dee43d14 | ||
|
|
fc819a4814 | ||
|
|
d9c8b5762b | ||
|
|
9f069e14c9 | ||
|
|
6c4f6be1b4 | ||
|
|
3be700020b | ||
|
|
677266d477 | ||
|
|
f8829b39be | ||
|
|
0b25df53df | ||
|
|
4f3b8a6824 | ||
|
|
32423c2ba2 | ||
|
|
eda936dc4d | ||
|
|
2950cd5740 | ||
|
|
5d79dd3b88 | ||
|
|
fb92122731 | ||
|
|
d640ed74e1 | ||
|
|
b5da17db76 |
88 changed files with 20560 additions and 992 deletions
|
|
@ -13,6 +13,7 @@
|
|||
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||
|
|
|
|||
258
CLAUDE.md
258
CLAUDE.md
|
|
@ -126,12 +126,18 @@ ourselves".
|
|||
both radii and MSAA/anisotropic/A2C/completions-per-frame as a unit.
|
||||
Spec: `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`.
|
||||
|
||||
**Execution phases:** R1→R8 in the architecture doc. Each phase has clear
|
||||
goals, test criteria, and builds on the previous. Don't skip phases.
|
||||
**Execution model:** the active source of truth is the **milestones doc**
|
||||
(`docs/plans/2026-05-12-milestones.md`) for "what are we building right
|
||||
now" and the **strategic roadmap** (`docs/plans/2026-04-11-roadmap.md`)
|
||||
for the per-phase ledger of what's shipped, what's in flight, and what
|
||||
comes next. **Ignore the old "R1→R8" sequence** — it was an early refactor
|
||||
sketch that no longer matches reality (see the "Roadmap Model" section in
|
||||
`docs/architecture/acdream-architecture.md`). Per-phase detailed specs
|
||||
live under `docs/superpowers/specs/`.
|
||||
|
||||
The codebase is organized by layer (see architecture doc). Current phase
|
||||
state lives in memory (`memory/project_*.md`), plans in `docs/plans/`,
|
||||
research in `docs/research/`.
|
||||
The codebase is organized by layer (see architecture doc + the **Code
|
||||
Structure Rules** section below). Plans live in `docs/plans/`,
|
||||
research in `docs/research/`, persistent project memory in `memory/`.
|
||||
|
||||
**UI strategy:** three-layer split — swappable backend (ImGui.NET +
|
||||
`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a, custom retail-look
|
||||
|
|
@ -164,12 +170,74 @@ click-to-rebind. As of Phase K (2026-04-26), ALL keyboard / mouse
|
|||
input flows through the dispatcher — no IsKeyPressed polling outside
|
||||
the per-frame movement queries.
|
||||
|
||||
## Code Structure Rules
|
||||
|
||||
These are the structural rules the project commits to. They are
|
||||
**process rules** (where code goes, what depends on what), not style
|
||||
rules (formatting / naming). They exist to keep the layer split honest
|
||||
and to stop `GameWindow.cs` from continuing to grow into a 10k-line
|
||||
god object. The full rationale + the extraction sequence we're
|
||||
pursuing live in [`docs/architecture/code-structure.md`](docs/architecture/code-structure.md).
|
||||
|
||||
1. **No new substantial feature bodies in `GameWindow.cs`.** It is
|
||||
already over 10,000 lines and owns runtime wiring. New runtime
|
||||
work goes into a dedicated controller / sink / orchestrator class
|
||||
in `src/AcDream.App/` (or deeper in `AcDream.Core` when it's pure
|
||||
logic). Adding a handful of fields and a one-paragraph method to
|
||||
wire an extracted class in is fine; adding a new ~200-line feature
|
||||
directly is not. When in doubt, file a small follow-up extraction
|
||||
as part of the change.
|
||||
|
||||
2. **`AcDream.Core` must not depend on the window / GL / backend
|
||||
projects, except via documented interop seams.** The only
|
||||
currently-allowed seams are `WorldBuilder.Shared` (stateless helpers:
|
||||
`TerrainUtils`, `TerrainEntry`, `RegionInfo`) and
|
||||
`Chorizite.OpenGLSDLBackend.Lib` (stateless helpers only:
|
||||
`SceneryHelpers`, `TextureHelpers`). New Core code that needs a GL
|
||||
surface must define an interface in Core and let `AcDream.App`
|
||||
implement it — never the reverse. If you need to add a project
|
||||
reference to Core, the change must come with an inventory-doc
|
||||
update explaining why.
|
||||
|
||||
3. **UI panels target `AcDream.UI.Abstractions` only.** No panel may
|
||||
import `AcDream.UI.ImGui` or any backend namespace. ViewModels,
|
||||
commands, and the `IPanel` / `IPanelRenderer` contract are the
|
||||
surface; everything else is backend. This is what lets us swap
|
||||
D.2a (ImGui) for D.2b (retail-look) later without rewriting
|
||||
panels.
|
||||
|
||||
4. **Startup environment variables enter through a typed options
|
||||
object.** `AcDream.App.RuntimeOptions` is the single source of
|
||||
truth for startup configuration. `Program.cs` reads the
|
||||
environment once into `RuntimeOptions` and passes it into
|
||||
`GameWindow`. Don't sprinkle `Environment.GetEnvironmentVariable`
|
||||
reads through new code paths; add a field to `RuntimeOptions` and
|
||||
pipe it through.
|
||||
|
||||
5. **Runtime probes (diagnostic toggles) belong in diagnostic owner
|
||||
classes.** Today `AcDream.Core.Physics.PhysicsDiagnostics` owns the
|
||||
`ACDREAM_PROBE_*` family. The pattern: one static class per
|
||||
subsystem, exposing typed bool/int properties read from env vars
|
||||
once at startup (with optional runtime-toggleable counterparts for
|
||||
the DebugPanel). Per-call-site `Environment.GetEnvironmentVariable`
|
||||
reads in new code are a process smell — if a flag survives one
|
||||
phase, promote it to a diagnostic owner. The dozens of existing
|
||||
`ACDREAM_DUMP_*` reads scattered through `GameWindow` are tech
|
||||
debt; do not add more.
|
||||
|
||||
6. **Tests live in the project matching the layer under test.** Core
|
||||
tests in `tests/AcDream.Core.Tests/`, UI tests in
|
||||
`tests/AcDream.UI.Abstractions.Tests/`, network tests in
|
||||
`tests/AcDream.Core.Net.Tests/`. App-layer tests (RuntimeOptions
|
||||
parsing, etc.) belong in `tests/AcDream.App.Tests/`. When adding a
|
||||
new test project, register it in `AcDream.slnx`.
|
||||
|
||||
## How to operate
|
||||
|
||||
**You are the lead engineer AND architect on this project at all times.**
|
||||
You own the architecture (`docs/architecture/acdream-architecture.md`),
|
||||
the execution plan (phases R1–R8), the development workflow, and all
|
||||
technical decisions. Stop as little as possible. Drive work autonomously and continuously through full phases and
|
||||
the execution plan (milestones doc + strategic roadmap), the development
|
||||
workflow, and all technical decisions. Stop as little as possible. Drive work autonomously and continuously through full phases and
|
||||
across commit boundaries. Do not stop mid-phase for routine progress check-ins,
|
||||
permission asks on low-stakes design calls, or "should I continue?" confirmations.
|
||||
The user has repeatedly authorized direct-to-main commits, multi-commit sessions,
|
||||
|
|
@ -179,6 +247,26 @@ The only thing that genuinely requires stopping is **visual confirmation** — t
|
|||
user needs to look at the running client and tell you whether it matches
|
||||
retail. Everything else is your call.
|
||||
|
||||
**No workarounds without explicit approval.** When you spot a bug or
|
||||
encounter a behavioral mismatch, fix the underlying cause — do not ship a
|
||||
band-aid, suppression flag, grace period, retry loop, or any other "make
|
||||
the symptom go away" shortcut, unless the user has explicitly approved
|
||||
that shape OR you are building a NEW feature with a different design.
|
||||
This rule exists because every workaround creates architectural debt that
|
||||
masks the real issue, makes future refactors harder, and erodes the
|
||||
codebase's retail-faithfulness. Examples of disallowed shortcuts: an
|
||||
`if (problematicState) return early` guard at the symptom site instead of
|
||||
investigating why the state happened; a timer-based "settle period" to
|
||||
hide a race; a flag like `_suppressXDuringY` to mask a wire-level mistake;
|
||||
a `try/catch` swallowing an exception that signals a real problem. If you
|
||||
notice a fix is starting to look like a workaround mid-implementation,
|
||||
stop, file the proper investigation as an issue with full reproduction
|
||||
notes, and either (a) ask the user before shipping the workaround, or
|
||||
(b) invest the time to fix the root cause. The user has explicitly
|
||||
authorized "spend more time, get it right" over "ship a shortcut and
|
||||
file the cleanup." Quote them: "we should have no workarounds unless I
|
||||
say so or we want a different feature."
|
||||
|
||||
**Only stop and wait for the user when:**
|
||||
|
||||
- Visual verification is the acceptance test ("does the drudge look right now?")
|
||||
|
|
@ -603,10 +691,21 @@ acdream operates at **two altitudes** above the daily commit:
|
|||
section). Phase-level index. This is where you orient when you know
|
||||
the milestone and need the next concrete sub-phase.
|
||||
|
||||
**Currently working toward: M1 — Walkable + clickable world.** L.2
|
||||
collision + B.4 interaction. Demo target: walk through Holtburg without
|
||||
getting stuck, open the inn door, click an NPC, pick up an item.
|
||||
Estimated 4–6 weeks from 2026-05-12.
|
||||
**M1 landed 2026-05-16** via Phase B.6 (`d640ed7`). L.2 collision +
|
||||
B.4 interaction + B.5 pickup + B.6 server-driven auto-walk all
|
||||
shipped. The four demo targets work end-to-end: walk Holtburg, open
|
||||
inn door, click NPC, pick up item. Freeze list active — M1's phases
|
||||
are off-limits until M7 polish. Writeup at top of M1 block in
|
||||
`docs/plans/2026-05-12-milestones.md`.
|
||||
|
||||
**Currently working toward: M2 — "Kill a drudge."** Equip a sword,
|
||||
walk to a drudge, swing, see damage in chat, watch the swing
|
||||
animation, drudge dies and drops loot, pick up the loot, open
|
||||
inventory and see it. Phases to ship: F.2 (Inventory panel), F.3
|
||||
(Combat math + damage flow), F.5a (visible-at-login dev panels —
|
||||
Attributes / Skills / Equipped / Inventory list, minimal ImGui),
|
||||
L.1c (combat animation wiring), L.1b (command router prereq).
|
||||
~6–10 weeks from 2026-05-16.
|
||||
|
||||
**Work-order autonomy — the meta-rule.** You decide what to work on
|
||||
next, always. **The user does NOT pick between phases, milestones, or
|
||||
|
|
@ -640,14 +739,14 @@ four below actually work.
|
|||
explicitly post-M7. The freeze list per milestone lives in the
|
||||
milestones doc.
|
||||
|
||||
3. **Each milestone hit gets a recorded demo video.** When M1 lands,
|
||||
record ~30 seconds of the demo scenario, drop it at
|
||||
`docs/milestones/M1-walkable-clickable.mp4`, and pin a still + a
|
||||
one-paragraph writeup at the top of `2026-05-12-milestones.md`. The
|
||||
freeze list updates. The "currently working toward" line in this
|
||||
CLAUDE.md updates to M2. **Crossing a milestone is a real event with
|
||||
an artifact** — that's the morale instrument. Phases ship; milestones
|
||||
land.
|
||||
3. **Crossing a milestone is a textual event, not a video event.**
|
||||
When a milestone's demo scenario is functionally complete, update
|
||||
`2026-05-12-milestones.md` with a one-paragraph writeup describing
|
||||
what works end-to-end, flip the freeze list, and update the
|
||||
"currently working toward" line in this CLAUDE.md to the next
|
||||
milestone. Do NOT ask the user to record a demo video — they find
|
||||
it pointless. The milestones doc + the CLAUDE.md flip ARE the
|
||||
milestone artifact. Phases ship; milestones land.
|
||||
|
||||
4. **State both altitudes at session start.** First action of any
|
||||
session: "Currently working toward M1 — Walkable + clickable world.
|
||||
|
|
@ -677,7 +776,36 @@ acdream's plan lives in two files committed to the repo:
|
|||
acceptance criteria. Do not drift from the spec without explicit user
|
||||
approval.
|
||||
|
||||
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
|
||||
**Indoor walking Phase 2 — Portal-based cell tracking shipped
|
||||
2026-05-19.** Six commits:
|
||||
- `1969c55` — CellBSP + Portals wired into CellPhysics (`PortalInfo` struct, `VisibleCellIds`)
|
||||
- `aad6976` — `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`; `ResolveCellId` rename
|
||||
- `069534a` — `BuildingPhysics` + `CheckBuildingTransit` for outdoor→indoor entry via `BldPortalInfo`
|
||||
- `702b30a` — code-review polish (DRY cell-id derivation, `PortalFlags.ExactMatch` enum, docs)
|
||||
- `3ffe1e4` — critical fix: pass foot-sphere center (`GlobalSphere[0].Origin`) not `CheckPos` to `ResolveCellId`
|
||||
- `eb0f772` — `TryFindIndoorWalkablePlane` synthesizes indoor walkable plane from cell floor poly
|
||||
|
||||
**#86** (click selection penetrates walls) — **CLOSED** (Phase 1 Cluster A).
|
||||
**#84** (blocked by air indoors) — **FULLY CLOSED.** Spawn-in-building variant
|
||||
closed by Phase 1 (Phase D AABB containment). Wall-block-from-inside variant
|
||||
closed by Phase 2 (portal-graph traversal).
|
||||
**#85** (pass through walls outside→in) — **CLOSED** by Phase 2.
|
||||
`CheckBuildingTransit` promotes CellId via the building-shell portal graph
|
||||
on outdoor→indoor entry; indoor-BSP collision fires from both sides.
|
||||
**#87** (indoor portal-based cell tracking) — **CLOSED** by Phase 2.
|
||||
**#88** (indoor static objects vibrate) — **FILED** (pre-existing, Medium).
|
||||
**#89** (port `BSPQuery.SphereIntersectsCellBsp`) — **FILED** (Low, documented
|
||||
approximation in `CheckBuildingTransit`).
|
||||
Diagnostic infrastructure: `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
|
||||
`[check-bldg]` probes all stay in place.
|
||||
Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md).
|
||||
Phase 1 handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](docs/research/2026-05-19-cluster-a-shipped-handoff.md).
|
||||
|
||||
**Next phase is Claude's choice** per work-order autonomy. Candidates:
|
||||
M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b — kill-a-drudge demo);
|
||||
or the pre-existing "next phase candidates" list below.
|
||||
|
||||
**Previously in Phase L.2 (Movement & Collision Conformance).** L.2a slices
|
||||
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
|
||||
**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13;
|
||||
**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
|
||||
|
|
@ -926,20 +1054,50 @@ for the window to close.
|
|||
|
||||
### Logout-before-reconnect
|
||||
|
||||
**ACE keeps your last session alive briefly after a disconnect.** If you
|
||||
relaunch the client within a few seconds of the last close, the handshake
|
||||
fails with `live: session failed: CharacterList not received` and the
|
||||
process exits with code 29. Wait ~3–5 seconds between launches, or explicitly
|
||||
kill stale processes:
|
||||
**ACE keeps your last session alive after a disconnect, and the duration
|
||||
depends on HOW the client exited.** Two cases:
|
||||
|
||||
1. **Graceful close (client sent logout packet to ACE):** session clears
|
||||
in ~3–5 seconds. Wait briefly between launches.
|
||||
2. **Hard kill (Stop-Process, crash, force-close):** no logout packet
|
||||
reached ACE. ACE keeps the session marked logged-in until its own
|
||||
timeout — observed in practice at ~3+ minutes. Subsequent relaunches
|
||||
fail with `live: session failed: CharacterList not received` (exit 29)
|
||||
the entire time. **There is no admin command available to us to kick
|
||||
the stale session.** Either wait it out, or use the graceful path
|
||||
below.
|
||||
|
||||
**Prefer the graceful close path when ending a launch.** PowerShell's
|
||||
`Stop-Process` is a hard kill — it bypasses the client's shutdown hook
|
||||
which is where the logout packet would have been sent. The graceful
|
||||
alternative sends WM_CLOSE so the window's close handler runs:
|
||||
|
||||
```powershell
|
||||
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$proc.CloseMainWindow() | Out-Null
|
||||
if (-not $proc.WaitForExit(5000)) {
|
||||
# Fell through to hard-kill — session WILL be stuck on ACE.
|
||||
$proc | Stop-Process -Force
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
# ... then launch ...
|
||||
```
|
||||
|
||||
The user has repeatedly confirmed this — don't treat exit-29-after-rapid-relaunch
|
||||
as a code bug. It's a server-side session-cleanup delay.
|
||||
If `WaitForExit(5000)` returns false (the client didn't exit in 5 seconds
|
||||
after WM_CLOSE), the client is unresponsive and a hard kill is the only
|
||||
option — accept that ACE will be unhappy for a few minutes.
|
||||
|
||||
**When recovering from a hard-killed session that ACE still considers
|
||||
active:** the only honest answer is to wait. Don't bother retrying every
|
||||
30 seconds — make a single retry attempt ~3 minutes after the kill, and
|
||||
if it still fails wait another 2 minutes before trying again. The user
|
||||
will likely volunteer when ACE has cleared the session if you ask.
|
||||
|
||||
The user has repeatedly confirmed this — don't treat exit-29-after-relaunch
|
||||
as a code bug. It's a server-side session-cleanup delay whose duration is
|
||||
governed by whether the previous shutdown was graceful or forced.
|
||||
|
||||
### Test character
|
||||
|
||||
|
|
@ -1034,13 +1192,19 @@ already-running ACE session via the handshake race.
|
|||
stop transitions, and keep their visual position tracked smoothly
|
||||
between the 5–10 Hz UpdatePosition bursts (dead-reckoning).
|
||||
|
||||
## Reference repos: check ALL FOUR, not just one
|
||||
## Reference repos: cross-check the relevant ones
|
||||
|
||||
When researching a protocol detail, dat format, rendering algorithm, or
|
||||
any "how does AC do X" question, **check all four of the vendored
|
||||
references in `references/`** before committing to an approach. Do not
|
||||
settle on the first hit and move on — cross-reference at least two of
|
||||
these, ideally all four:
|
||||
The `references/` tree holds **six** vendored projects (ACE, ACViewer,
|
||||
WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in
|
||||
some areas and disagree in others. Before committing to an approach,
|
||||
**cross-reference at least two of them** for the domain you're working
|
||||
in — the per-domain hierarchy in the next section tells you which to
|
||||
read first. A single reference can be misleading; the intersection of
|
||||
the relevant references is almost always the truth. The user has
|
||||
repeatedly had to remind me about this when I narrowly searched one ref
|
||||
and missed obvious answers in another.
|
||||
|
||||
The six references:
|
||||
|
||||
- **`references/ACE/`** — ACEmulator server. Authority on the wire
|
||||
protocol (packet framing, ISAAC, game message opcodes, serialization
|
||||
|
|
@ -1052,17 +1216,21 @@ these, ideally all four:
|
|||
`ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical
|
||||
subpalette overlay algorithm.
|
||||
- **`references/WorldBuilder/`** — **acdream's rendering + dat-handling
|
||||
BASE (not just a reference).** As of 2026-05-08 acdream is moving to
|
||||
fork WorldBuilder upstream and depend on the fork for terrain,
|
||||
scenery, static objects, EnvCells, portals, sky, particles, texture
|
||||
decoding, mesh extraction, visibility/culling. WorldBuilder is
|
||||
MIT-licensed, exact-stack match (Silk.NET + .NET), and verified to
|
||||
render the world correctly. **Before re-porting any rendering or
|
||||
dat-handling algorithm from retail decomp, check
|
||||
`docs/architecture/worldbuilder-inventory.md` first.** If WB has it,
|
||||
use WB's port. If WB doesn't have it (network, physics, animation,
|
||||
movement, UI, plugin, audio, chat), port from retail decomp as
|
||||
before.
|
||||
base.** WorldBuilder is not just a reference: as of Phase N.4 (shipped
|
||||
2026-05-08), `ObjectMeshManager` is the production mesh pipeline,
|
||||
`WbMeshAdapter` is the seam, and `WbDrawDispatcher` is the production
|
||||
draw path. The modern path (`N.5`) is **mandatory** — missing bindless
|
||||
throws at startup, there is no legacy fallback. **Before re-porting
|
||||
any rendering or dat-handling algorithm from retail decomp, read
|
||||
`docs/architecture/worldbuilder-inventory.md` first.** The inventory
|
||||
tells you what WB covers (terrain, scenery, static objects, EnvCells,
|
||||
portals, sky, particles, texture decoding, mesh extraction,
|
||||
visibility/culling) and what we still write ourselves (the 🔴 list:
|
||||
network, physics, animation, movement, UI, plugin, audio, chat).
|
||||
WorldBuilder is MIT-licensed and exact-stack with us (Silk.NET +
|
||||
.NET); the divergences we've documented (e.g. WB's terrain split
|
||||
formula vs retail's `FSplitNESW`) are called out in the inventory
|
||||
doc.
|
||||
- **`references/Chorizite.ACProtocol/`** — clean-room C# protocol
|
||||
library generated from a protocol XML description. Useful sanity check
|
||||
on field order, packed-dword conventions, type-prefix handling. The
|
||||
|
|
|
|||
894
docs/ISSUES.md
894
docs/ISSUES.md
|
|
@ -46,13 +46,801 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Active issues
|
||||
|
||||
## #69 — Local player rotation isn't animated (no leg/arm cycle while pivoting)
|
||||
---
|
||||
|
||||
## #87 — Drop WB fork patch by switching to PrepareEnvCellGeomMeshDataAsync
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (band-aid removal; not user-visible)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** rendering, WB integration
|
||||
|
||||
**Description:** Phase 2 (2026-05-19) shipped a one-line patch in our
|
||||
WB fork at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`
|
||||
(branch `acdream` on the fork, SHA `34460c4`) to guard a blind
|
||||
`TryGet<Setup>(stab.Id, ...)` call against GfxObj-prefixed ids. That
|
||||
patch fixes the symptom (missing floors) but is structurally a
|
||||
band-aid — per CLAUDE.md's no-workarounds rule we should retire it.
|
||||
|
||||
The proper fix: switch our EnvCell rendering from
|
||||
`PrepareMeshDataAsync(envCellId, ...)` (general-purpose entry that
|
||||
also iterates static-object parts + emitters we don't need) to WB's
|
||||
narrower `PrepareEnvCellGeomMeshDataAsync(geomId, environmentId, cellStructure, surfaces)`
|
||||
at [`ObjectMeshManager.cs:386`](../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:386).
|
||||
That function only builds the cell room mesh (floor / walls / ceiling),
|
||||
which is the only piece we actually use from WB for cells — we already
|
||||
hydrate static objects as separate `WorldEntity` instances in
|
||||
`BuildInteriorEntitiesForStreaming`, and we run particle scripts via
|
||||
our own `EntityScriptActivator` (Phase C.1.5b).
|
||||
|
||||
**Root cause / status:** Misuse of WB's general-purpose API for a
|
||||
geometry-only need. The general-purpose path triggers static-object
|
||||
iteration that has a bug (TryGet<Setup> without type check) AND that
|
||||
does work we throw away. Both problems disappear if we use the
|
||||
geometry-only entry point WB already exposes for exactly this purpose
|
||||
(it's what WB's own `EnvCellRenderManager` uses internally).
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
| | Current (patched WB) | Switch to geom-only API |
|
||||
|---|---|---|
|
||||
| WB fork divergence | One-line patch | Zero |
|
||||
| Future WB upstream merges | Conflicts | Clean |
|
||||
| Performance | Slightly worse (wasted iteration) | Slightly better |
|
||||
| Risk to other functionality | None (working today) | Needs re-verification |
|
||||
|
||||
**Files (the change):**
|
||||
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` around line 5367-5378
|
||||
(cell-entity hydration — change `MeshRefs[0].GfxObjId` from `envCellId`
|
||||
to `envCellId | 0x100000000UL`, the synthetic geom id with bit 32 set).
|
||||
- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` — add a new method
|
||||
`PrepareEnvCellGeomMesh(ulong geomId, uint environmentId, ushort cellStructure, List<ushort> surfaces)`
|
||||
that forwards to `_meshManager.PrepareEnvCellGeomMeshDataAsync(...)`,
|
||||
and call it from the streaming path instead of the bare
|
||||
`IncrementRefCount(envCellId)`.
|
||||
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`
|
||||
— revert the type-check guard we added. The function returns to
|
||||
pristine WB state.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- Floors still render in Holtburg Inn (regression check vs Phase 2).
|
||||
- `references/WorldBuilder` submodule pointer returns to upstream-clean
|
||||
(no acdream-specific commits in the fork's `acdream` branch — or
|
||||
rather, the `acdream` branch fast-forwards back to match upstream's
|
||||
state for this file).
|
||||
- Probe re-capture at Holtburg confirms `[indoor-upload] completed` for
|
||||
all cells previously failing.
|
||||
- No `[wb-error]` lines.
|
||||
|
||||
**Research:** [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](research/2026-05-19-indoor-cell-rendering-cause.md)
|
||||
documents the underlying WB bug.
|
||||
|
||||
---
|
||||
|
||||
## Indoor walking issue cluster (2026-05-19)
|
||||
|
||||
The Phase 2 indoor cell rendering fix (floor now renders inside buildings)
|
||||
surfaced nine pre-existing indoor bugs the user observed at Holtburg Inn
|
||||
the moment they could walk indoors. None caused by the floor fix — all
|
||||
existed before but were unobservable because there was no floor to stand
|
||||
on. Filed individually below; #78 + #84 + #85 + #86 likely share a root
|
||||
cause (cell BSP / portal-cull plumbing), and #79 + #80 + #81 + #82 share
|
||||
the indoor-lighting plumbing.
|
||||
|
||||
---
|
||||
|
||||
## #78 — Outdoor stabs/buildings visible through the rendered floor
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** HIGH (immediate visual jank now that floors render)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** rendering, visibility
|
||||
|
||||
**Description:** Standing inside Holtburg Inn looking at the floor or
|
||||
walls, the user sees other buildings in the distance at their correct
|
||||
world position + scale — but visible THROUGH the floor and walls. As if
|
||||
the cell mesh is rendered but doesn't occlude or stencil-cull what's
|
||||
behind it.
|
||||
|
||||
**Root cause / status:** Two plausible causes:
|
||||
1. The `+0.02f` Z bump applied to cell origin at `GameWindow.cs:5362`
|
||||
pushes the floor mesh 2 cm above terrain, so depth test correctly
|
||||
occludes terrain. But OUTDOOR STABS (landblock-baked building geometry)
|
||||
at the same X,Y may have Z values comparable to or higher than the
|
||||
cell-mesh floor, producing z-fighting / see-through.
|
||||
2. Outdoor stabs aren't being culled when the player is inside an
|
||||
EnvCell — this is the Phase 1 Task 3 deferred work
|
||||
("Cull outdoor stabs when indoors via VisibleCellIds"). WB has a
|
||||
`RenderInsideOut` stencil pipeline (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`)
|
||||
that acdream never invokes.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (per-entity walk —
|
||||
consider gating outdoor stab entities on visible-cell membership).
|
||||
- `src/AcDream.App/Rendering/CellVisibility.cs:222+` (`ComputeVisibility`
|
||||
returns `VisibleCellIds`; the dispatcher already filters by
|
||||
`entity.ParentCellId ∈ visibleCellIds` but outdoor stabs have
|
||||
`ParentCellId == null` so they always pass).
|
||||
|
||||
**Acceptance:** Standing inside a sealed-interior cell, no outdoor
|
||||
geometry is visible through floor/walls. Standing where a cell has a
|
||||
real outdoor portal (door open, window) outdoor geometry is correctly
|
||||
visible through the portal.
|
||||
|
||||
---
|
||||
|
||||
## #79 — Indoor lighting: spurious spot lights on walls
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** lighting
|
||||
|
||||
**Description:** Walking around inside Holtburg Inn, the user sometimes
|
||||
sees spot-light-like patches on the interior walls that don't correspond
|
||||
to retail's lighting.
|
||||
|
||||
**Root cause / status:** Point lights from cell static objects (torch
|
||||
entities) are being registered via `LightInfoLoader.Load` + `LightingHookSink`
|
||||
(Phase 1 verified). Their per-light parameters (position, range, intensity,
|
||||
cone) may be wrong — wrong falloff treatment, wrong world-space transform,
|
||||
or wrong direction for spot lights. Spec at
|
||||
`docs/research/deepdives/r13-dynamic-lighting.md` documents the retail
|
||||
LightInfo→LightSource mapping but the live behavior hasn't been verified
|
||||
against retail.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.Core/Lighting/LightInfoLoader.cs`
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — `accumulateLights`
|
||||
spot-cone logic.
|
||||
|
||||
**Acceptance:** Side-by-side comparison with retail at the inn shows
|
||||
matching torch-light pools.
|
||||
|
||||
---
|
||||
|
||||
## #80 — Camera on 2nd floor goes very dark
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** lighting
|
||||
|
||||
**Description:** Walking up to the second floor of a building, the
|
||||
lighting suddenly goes much darker than retail.
|
||||
|
||||
**Root cause / status:** Possible causes:
|
||||
1. The `playerInsideCell` lighting trigger (Phase 1 / commit `1024ba3`)
|
||||
uses `CellVisibility.IsInsideAnyCell(playerPos)` which is a brute-force
|
||||
PointInCell scan. The 2nd floor cell may not be in the loaded set OR
|
||||
may have wrong bounds.
|
||||
2. The per-cell ambient is currently a flat `(0.20, 0.20, 0.20)` for
|
||||
any indoor cell. Retail has per-cell ambient overrides; ours doesn't
|
||||
read them. A 2nd-floor cell with stairwell shadowing may need a
|
||||
different value.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs:8330+` (`UpdateSunFromSky`,
|
||||
indoor branch).
|
||||
|
||||
**Acceptance:** 2nd-floor cells render with similar brightness to
|
||||
ground floor; transition is not abrupt.
|
||||
|
||||
---
|
||||
|
||||
## #81 — Static building stabs don't react to atmospheric lighting changes
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** lighting, rendering
|
||||
|
||||
**Description:** Outside, time-of-day changes (sunrise/sunset/lightning)
|
||||
don't visibly affect static building stabs (the inn / cottages). The
|
||||
buildings stay statically lit while terrain and scenery shift colors.
|
||||
|
||||
**Root cause / status:** Stabs are rendered through `WbDrawDispatcher`
|
||||
with `mesh_modern.frag` which DOES consume the `SceneLightingUbo`
|
||||
(sun + ambient + fog). Verify the shader is being used for stabs and
|
||||
that the UBO is bound at the right binding slot per draw call.
|
||||
Possibly a shader-path divergence — terrain uses `terrain_modern.frag`,
|
||||
entities use `mesh_modern.frag`, but stabs/scenery may be on a
|
||||
different path.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
|
||||
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||||
|
||||
**Acceptance:** Stabs darken/brighten in sync with terrain + scenery
|
||||
across the day/night cycle.
|
||||
|
||||
---
|
||||
|
||||
## #82 — Some slope terrain lit incorrectly
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (cosmetic)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** rendering, terrain
|
||||
|
||||
**Description:** Specific terrain slopes appear lit "wrong" compared to
|
||||
retail.
|
||||
|
||||
**Root cause / status:** Likely terrain normal calculation or the
|
||||
landblock-edge normal-blending divergence between WB and retail (per
|
||||
`feedback_wb_migration_formulas.md` — WB's terrain split formula
|
||||
differs from retail's `FSplitNESW`).
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
|
||||
- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
|
||||
|
||||
**Acceptance:** Side-by-side comparison with retail at the same Holtburg
|
||||
slopes shows matching shading.
|
||||
|
||||
---
|
||||
|
||||
## #83 — Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck)
|
||||
|
||||
**Status:** OPEN — foundation work landed 2026-05-19, root-cause fix deferred to a follow-up investigation phase
|
||||
**Severity:** HIGH (blocks vertical indoor traversal + degrades single-floor cases)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** physics, movement, resolver
|
||||
|
||||
**Description:** Walking UP stairs in single-floor houses works
|
||||
(grounded step-up routes through retail-faithful `BSPQuery.FindWalkableInternal`
|
||||
via `StepSphereDown`). Walking DOWN into cellars fails ("ground blocking" —
|
||||
can't descend). Walking on 2nd floors works partially but intermittently
|
||||
gets stuck in the falling animation. "Phantom collisions" / invisible
|
||||
obstacles in rooms persist. The original title "Walking up stairs broken"
|
||||
was misleading per user's clarification 2026-05-19.
|
||||
|
||||
**Partial fix landed 2026-05-19 (6 commits `ff548b9` → `f845b22`).**
|
||||
Foundation work: extended `BSPQuery.FindWalkableInternal` to expose the
|
||||
hit polygon's dictionary key id; added thin public wrapper
|
||||
`BSPQuery.FindWalkableSphere` over the existing retail-faithful BSP
|
||||
walkable-finder (acclient_2013_pseudo_c.txt:326211 / :326793); refactored
|
||||
`Transition.TryFindIndoorWalkablePlane` to route through that wrapper
|
||||
instead of its Phase-2 linear first-match XY scan; added `[indoor-walkable]`
|
||||
runtime-toggleable probe line for diagnostic visibility. 5 new unit tests
|
||||
+ 1 integration test, 9 pre-existing IndoorWalkablePlane tests updated
|
||||
to the new signature.
|
||||
|
||||
**Foundation work did NOT fix the user-reported bugs.** Visual verification
|
||||
2026-05-19: cellar descent FAIL, 2nd-floor walking FAIL (intermittent
|
||||
falling-stuck), single-floor cottage REGRESSED to intermittent falling-stuck
|
||||
(was stable before), phantom collisions PERSIST. The probe captured 1443
|
||||
MISS / 2 HIT over 1445 indoor-walkable calls — the BSP walker correctly
|
||||
rejects the foot-sphere-tangent-to-floor case (sphere center is exactly
|
||||
at `floorZ + radius` when grounded, so `PolygonHitsSpherePrecise` fails
|
||||
the `|dist| > radius - epsilon` check by ~0.0002).
|
||||
|
||||
**Root cause (deeper than originally diagnosed):** `Transition.TryFindIndoorWalkablePlane`
|
||||
fundamentally exists as a Phase 2 commit `eb0f772` stop-gap to synthesize
|
||||
a ContactPlane every frame when the indoor BSP returns OK. Retail doesn't
|
||||
do this — retail RETAINS the previous frame's `ContactPlane` when the
|
||||
collision dispatcher says "no collision." There is no retail analog of
|
||||
`find_walkable` being called as a standing-still query — retail's
|
||||
`find_walkable` only runs inside a downward sphere sweep
|
||||
(`step_sphere_down`), where the sphere is moving and the overlap test
|
||||
is meaningful. In our `TryFindIndoorWalkablePlane` flow, the sphere is
|
||||
tangent (grounded), not moving — the algorithm correctly returns "no
|
||||
overlap." The single-floor cottage worked previously because the OLD
|
||||
linear scan ignored Z and falsely returned HIT for any XY-overlapping
|
||||
walkable; the new BSP-walker correctly identifies "no overlap" and
|
||||
falls through to the outdoor terrain backstop, which only happens to
|
||||
produce sensible Z for single-floor outdoor-adjacent cases.
|
||||
|
||||
**Files in the foundation work:**
|
||||
- `src/AcDream.Core/Physics/BSPQuery.cs` — `FindWalkableInternal` signature extension, new `FindWalkableSphere` public wrapper
|
||||
- `src/AcDream.Core/Physics/TransitionTypes.cs` — `TryFindIndoorWalkablePlane` refactor, `PointInPolygonXY` deletion, `[indoor-walkable]` probe
|
||||
- `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` — 4 new unit tests
|
||||
- `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` — new integration test
|
||||
- `tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs` — 9 tests updated to new signature
|
||||
|
||||
**Next investigation phase (deferred):** Port retail's `ContactPlane` retention
|
||||
mechanism so the resolver retains the previous frame's contact plane when
|
||||
the BSP says "no collision," instead of re-synthesizing it per frame. The
|
||||
proper fix likely eliminates `TryFindIndoorWalkablePlane` entirely. Needs
|
||||
deep investigation of retail's `CTransition::transitional_insert` /
|
||||
`CPhysicsObj::transition` / `LastKnownContactPlane` interactions. Foundation
|
||||
work (BSP walker + probe + tests) remains useful regardless of approach.
|
||||
|
||||
**Acceptance:** Walk down stairs into a cellar without getting stuck.
|
||||
Walk on a 2nd floor without intermittent falling-stuck. Single-floor
|
||||
cottage walking remains stable (no regression).
|
||||
|
||||
**Handoff:** [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md).
|
||||
|
||||
---
|
||||
|
||||
## #84 — [DONE 2026-05-19] Blocked by air indoors
|
||||
|
||||
**Status:** DONE
|
||||
**Closed:** 2026-05-19
|
||||
**Severity:** HIGH (blocks indoor navigation)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** physics, collision
|
||||
|
||||
**Description:** While walking inside buildings, the player sometimes
|
||||
collides with invisible obstacles in mid-floor where there's nothing
|
||||
visible.
|
||||
|
||||
**Root cause / status:** Cell BSP geometry doesn't align with the
|
||||
visible cell mesh. Possibilities:
|
||||
1. The `cellTransform` applied to physics in
|
||||
`_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)`
|
||||
at `GameWindow.cs:5384` includes the `+0.02f` Z bump, but the BSP
|
||||
geometry may not be lifted with it — physics geometry sits 2cm BELOW
|
||||
render geometry, so invisible "ceilings" at floor-level cause
|
||||
blockage.
|
||||
2. CellStruct BSP contains polygons that the cell mesh doesn't include
|
||||
(or vice versa) — the two are derived from different fields.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs:5362-5384` (cellOrigin Z bump
|
||||
+ physics cache call).
|
||||
|
||||
**Acceptance:** Walking through interior cell space hits collisions
|
||||
only where visible walls/furniture exist.
|
||||
|
||||
**Resolution (2026-05-19 partial · `c19d6fb`):** Phase D of Cluster A
|
||||
extended `ResolveOutdoorCellId` in `PhysicsEngine.cs` with an indoor
|
||||
cell-containment scan: when the player's world position falls inside any
|
||||
cached EnvCell's AABB, `CellId` is promoted to that indoor cell, which
|
||||
enables the `FindEnvCollisions` indoor-BSP branch. This resolved the
|
||||
"spawn in building and be stuck above the floor" variant of #84 —
|
||||
player's CellId now promotes to the interior cell on spawn-in, the floor
|
||||
is walkable, and the player can move freely. The "invisible air obstacle"
|
||||
symptom for rooms the player walks INTO from outside was tracked under #87
|
||||
and required portal-based cell tracking.
|
||||
|
||||
**Resolution (2026-05-19 full · `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`):**
|
||||
Indoor walking Phase 2 replaced AABB containment with portal-graph cell traversal
|
||||
(`CellTransit.FindCellList` + `CheckBuildingTransit`). CellId now promotes to indoor
|
||||
cells via portals and remains promoted during normal walking through doorways. Indoor
|
||||
cell-BSP collision fires consistently. Indoor walkable plane synthesized from floor
|
||||
poly (`TryFindIndoorWalkablePlane`) so the resolver tracks walkability correctly when
|
||||
the player is standing on an indoor floor. User visually verified at Holtburg cottage:
|
||||
walls block from inside, multi-room navigation works, walking outdoors through a door
|
||||
works. Issue fully closed.
|
||||
|
||||
---
|
||||
|
||||
## #85 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Pass through walls from outside→in
|
||||
|
||||
**Status:** DONE
|
||||
**Closed:** 2026-05-19
|
||||
**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** physics, collision
|
||||
|
||||
**Resolution (2026-05-19 · Indoor walking Phase 2):** The root cause (CellId never promoted
|
||||
to the indoor cell during outdoor→indoor walking) was resolved by portal-graph cell
|
||||
traversal in `CellTransit.CheckBuildingTransit`. Once `CellId` promotes to the indoor
|
||||
cell, the indoor-BSP collision branch in `FindEnvCollisions` fires for approaches from
|
||||
both inside and outside. User visually verified walls block from outside (player must
|
||||
use the door portal to enter). See #87 and handoff:
|
||||
[`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md).
|
||||
|
||||
**Original description:** Approaching a building from the outside, the player
|
||||
can walk THROUGH walls into the interior — one-directional wall
|
||||
collision. From the inside trying to exit, the wall does block.
|
||||
|
||||
The root cause was pinned (Cluster A 2026-05-19) as the same failure as
|
||||
#84's remaining symptom — `CellId` wasn't promoted to the indoor cell
|
||||
during normal outdoor→indoor walking because AABB containment was too
|
||||
tight for threshold/doorway cells. Without CellId in the indoor cell,
|
||||
the indoor-BSP collision branch in `FindEnvCollisions` never fired
|
||||
regardless of approach direction.
|
||||
|
||||
---
|
||||
|
||||
## #87 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Indoor cell tracking uses AABB containment instead of portal traversal
|
||||
|
||||
**Status:** DONE
|
||||
**Closed:** 2026-05-19
|
||||
**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** physics
|
||||
|
||||
**Resolution (2026-05-19 · Indoor walking Phase 2):** Portal-graph cell traversal
|
||||
(`CellTransit.FindCellList` + `CheckBuildingTransit`) replaced the AABB containment
|
||||
shortcut. Player CellId now correctly promotes to indoor cells via portals;
|
||||
indoor cell-BSP collision branch fires consistently; walls block from inside.
|
||||
Outdoor→indoor entry via `BuildingPhysics` + `BldPortalInfo` (`CheckBuildingTransit`)
|
||||
wires the building-shell portal graph. Indoor walkable plane synthesized from the
|
||||
cell's floor poly so the resolver tracks walkability during indoor movement (`TryFindIndoorWalkablePlane`).
|
||||
See handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md).
|
||||
|
||||
**Original description:** `PhysicsDataCache.TryFindContainingCell` promotes the
|
||||
player's `CellId` to an indoor EnvCell when their world position falls
|
||||
inside any cached cell's local AABB. This is too tight to keep `CellId`
|
||||
promoted to an indoor cell during normal walking. Threshold/doorway cells
|
||||
(the polys that sit at a room boundary) have AABB Z ranges of only ~0.2 m;
|
||||
a standing player at local Z=0.46 m is OUTSIDE the AABB and containment
|
||||
fails. Because `CellId` drifts back to the outdoor cell, the indoor-BSP
|
||||
collision branch in `TransitionTypes.FindEnvCollisions` is gated out for
|
||||
most movement, so walls don't block from inside the house and the floor
|
||||
physics is unreliable. The retail fix is portal-based cell traversal —
|
||||
when the player crosses a cell portal boundary, the cell ownership
|
||||
propagates through portal connectivity data in `CEnvCell`.
|
||||
|
||||
---
|
||||
|
||||
## #88 — Indoor static objects vibrate (bookshelves, open furnaces)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (visual jitter; doesn't block gameplay)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** rendering, animation
|
||||
|
||||
**Description:** Static objects inside cells (bookshelves, open furnaces, possibly other interior props) show per-frame transform jitter / vibration. Pre-existing (user noticed before Phase 2 shipped). Likely candidates:
|
||||
|
||||
1. `EntityScriptActivator.OnCreate/OnRemove` firing repeatedly as the player's CellId promotes/demotes near cell boundaries (less likely after Phase 2's portal-based tracking — but worth investigating).
|
||||
2. Per-part transforms for cell-static `WorldEntity` instances getting recomputed each frame with floating-point drift.
|
||||
3. Particle-emitter offsets accumulating instead of resetting.
|
||||
|
||||
**Files to investigate:**
|
||||
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — OnCreate/OnRemove call patterns
|
||||
- `src/AcDream.App/Rendering/GpuWorldState.cs` — entity transform updates per frame
|
||||
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — per-batch transform composition
|
||||
|
||||
**Acceptance:** Indoor static objects render stable (no per-frame jitter).
|
||||
|
||||
---
|
||||
|
||||
## #89 — Port BSPQuery.SphereIntersectsCellBsp for retail-faithful CheckBuildingTransit
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (Phase 2 ships with a documented approximation)
|
||||
**Filed:** 2026-05-19
|
||||
**Component:** physics
|
||||
|
||||
**Description:** Retail's `CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell` — a radius-aware sphere-vs-BSP test that returns Inside/Crossing/Outside. Phase 2's `CellTransit.CheckBuildingTransit` uses `BSPQuery.PointInsideCellBsp` (radius-less, tests only the sphere CENTER). Practical effect: outdoor→indoor entry fires ~sphereRadius (~0.48m) deeper into the doorway than retail. The sphereRadius parameter is plumbed through but currently unused.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit` (line ~162)
|
||||
- `src/AcDream.Core/Physics/BSPQuery.cs::PointInsideCellBsp` (line ~940) — existing point test to model the new sphere variant after
|
||||
|
||||
**Acceptance:** `CellTransit.CheckBuildingTransit` calls a new `BSPQuery.SphereIntersectsCellBsp(node, sphereCenter, sphereRadius)` that returns `Inside`/`Crossing`/`Outside`. Entry timing matches retail visually at the Holtburg cottage door.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
**Status:** DONE
|
||||
**Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged)
|
||||
**Filed:** 2026-05-16
|
||||
|
||||
**Resolution (2026-05-16 · `0b25df5`):** Step 2 re-attempted with
|
||||
`[step2-diag]` traces at every hypothesized fault point. The traces
|
||||
showed all four hypotheses were wrong — `session.hashcode` was identical
|
||||
through `_liveSession`, `_liveSessionController.Session`, and the
|
||||
captured `liveSession` local in the chat-bus lambda, ruling out
|
||||
identity mismatches and closure-capture bugs. Doors verified via
|
||||
inbound `OnLiveMotionUpdated` round-trip (cmd=0x000B open, cmd=0x000C
|
||||
close). Pickup verified via 4 successful `[B.5] pickup` calls. The
|
||||
previous broken run was almost certainly a stale ACE session (no other
|
||||
code-level explanation survives the diag trace). One small material
|
||||
diff: the chat-bus lambda's `var liveSession = _liveSession;` capture
|
||||
became `var liveSession = session;` (the non-null parameter) so the
|
||||
compiler can statically prove non-null inside the lambda — both pointed
|
||||
to the same `WorldSession` instance, only the static analysis changed.
|
||||
|
||||
Traces stripped before commit. Walking-range auto-walk bug observed
|
||||
during the second verification run is pre-existing (filed as #77, not
|
||||
caused by this refactor).
|
||||
|
||||
**Description:** A first attempt at Step 2 — extracting `LiveSessionController`
|
||||
|
||||
**Description:** A first attempt at Step 2 — extracting `LiveSessionController`
|
||||
out of `GameWindow.cs` — was implemented and reverted in the same session
|
||||
on the `claude/hungry-tharp-b4a27b` worktree. Visual verification at
|
||||
Holtburg revealed:
|
||||
|
||||
- Chat input field accepts text + Enter but nothing is sent (no echo, no
|
||||
ACE response).
|
||||
- Double-click on doors / NPCs fires `[B.4b] use guid=... seq=N` outbound
|
||||
(verified in `launch.log`) but no visible client-side effect (door doesn't
|
||||
swing, NPC doesn't dialogue).
|
||||
- R + click-target produces `[B.4b] use-deferred guid=... seq=N`, the
|
||||
player auto-walks to the target, but the deferred Use does NOT fire on
|
||||
arrival (regresses the Phase B.6 / issue #63 / #75 work).
|
||||
|
||||
The Step 1 (`eda936d` RuntimeOptions) and Rule 5 follow-up
|
||||
(`32423c2` DumpSteepRoof → PhysicsDiagnostics) commits are NOT affected
|
||||
and stay clean.
|
||||
|
||||
**Root cause / status:** Unknown. The refactor preserved every event
|
||||
subscription line-for-line (verified by `git diff` — only one `_liveSession.X +=`
|
||||
line moved, all others present). The new shape:
|
||||
|
||||
```
|
||||
TryStartLiveSession()
|
||||
→ _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents)
|
||||
→ new WorldSession(endpoint)
|
||||
→ wireEvents(session) // i.e. WireLiveSessionEvents(session)
|
||||
→ Chat.OnSystemMessage("connecting...")
|
||||
→ _liveSession.Connect(user, pass)
|
||||
→ ...character validation + EnterWorld + post-setup...
|
||||
```
|
||||
|
||||
Looks identical to the original control flow. Hypotheses to test on a
|
||||
clean re-attempt:
|
||||
|
||||
1. **Timing of `_liveSession` field assignment.** The new code assigns
|
||||
`_liveSession` inside `WireLiveSessionEvents` before subscriptions
|
||||
run, and again after CreateAndWire returns. The original code set
|
||||
`_liveSession` once at the inline `new WorldSession(...)` site. A
|
||||
subtle ordering bug between subscriptions and `_liveSession`'s
|
||||
externally visible state may matter.
|
||||
2. **LiveCommandBus closure capture.** The `var liveSession = _liveSession;`
|
||||
capture inside the chat handler block may have been getting a
|
||||
different value than before — though the field IS set by the time
|
||||
the capture happens (line 1 of `WireLiveSessionEvents`).
|
||||
3. **Inbound packet ordering.** ACE may be sending the first
|
||||
StateUpdate / spawn stream BEFORE the EnterWorld dance completes in
|
||||
the new flow; if subscriptions are wired but `_liveSession` field
|
||||
is briefly inconsistent, an early handler call could see a partial
|
||||
state. The `_liveSession?.Tick()` route now goes through
|
||||
`_liveSessionController?.Tick()`; verify that's not the difference.
|
||||
4. **Some non-subscription side effect** in `WireLiveSessionEvents`
|
||||
that wasn't carried over correctly — over-indentation suggests a
|
||||
diff-friendly intermediate state; full re-indentation may surface
|
||||
the bug.
|
||||
|
||||
**Files (in the reverted state — recover from worktree git reflog or
|
||||
re-write):**
|
||||
- `src/AcDream.App/Net/LiveSessionController.cs` (new, ~115 LOC)
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — `TryStartLiveSession` split
|
||||
+ new `WireLiveSessionEvents` method
|
||||
|
||||
**Research:** No memory entry yet. If the re-attempt succeeds, add a
|
||||
`feedback_step2_extraction_pitfalls.md` capturing whichever hypothesis
|
||||
turned out to be the bug.
|
||||
|
||||
**Acceptance:** Step 2 lands when the full M1 demo loop (walk Holtburg,
|
||||
double-click inn door + door swings, double-click NPC + NPC dialogues,
|
||||
F-key pickup on a ground item) works identically to the pre-refactor
|
||||
behavior, AND chat input echoes back through the panel.
|
||||
|
||||
---
|
||||
|
||||
## #75 — [DONE 2026-05-16 · `f035ea3`] Auto-walk should drive body directly, not synthesize player-input
|
||||
|
||||
**Status:** DONE
|
||||
**Severity:** LOW (functionally correct via grace-period band-aid; architectural cleanup only)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** physics / auto-walk
|
||||
|
||||
**Resolution (2026-05-16 · `f035ea3`):** Refactored `ApplyAutoWalkOverlay` → `DriveServerAutoWalk`. Auto-walk now steps Yaw, sets `_body.set_local_velocity` from runRate, and calls `_motion.DoMotion(WalkForward, speed)` directly — NO `MovementInput` synthesis. `Update` gates the user-input motion + velocity section on `!autoWalkConsumedMotion` to prevent overwrite. The 500ms arrival grace period (band-aid) deleted. The wire-layer `!IsServerAutoWalking` guard at `GameWindow.cs:6419` retained as a semantic statement (user-MoveToState is for user-driven intent only), not as a band-aid for the synthesis leak that no longer exists. Animation cycle plumbed through via `localAnimCmd` / `localAnimSpeed` for both moving-forward and turn-first phases (issue #69 folded in). Walk/run threshold corrected to 1.0m (overrides ACE's wire-supplied 15.0f; matches user-observed retail behaviour + ACE's own physics layer default). `IsPickupableTarget` now checks `BF_STUCK` (`acclient.h:6435`) to correctly block signs/banners that share Misc ItemType with real pickup items.
|
||||
|
||||
**Description:** `ApplyAutoWalkOverlay` in `PlayerMovementController`
|
||||
synthesizes `Forward+Run` `MovementInput` during inbound `MoveToObject`
|
||||
so the existing motion-interpreter pipeline drives the body. The
|
||||
synthesis leaks: motion-interpreter sets `MotionStateChanged=true`,
|
||||
which would fire an outbound `MoveToState` "user is running"
|
||||
packet to ACE — interpreted as user-took-manual-control and cancels
|
||||
ACE's `MoveToChain`. We mitigate with a guard
|
||||
(`!_playerController.IsServerAutoWalking` at `GameWindow.cs:6410`)
|
||||
plus a 500 ms post-arrival grace period to cover ACE's poll race.
|
||||
|
||||
Retail's `MoveToManager::HandleMoveToPosition` (decomp 0x0052xxxx)
|
||||
steps the body POSITION directly when server `MoveToObject` arrives —
|
||||
NO player-input synthesis, NO motion-interpreter involvement, NO
|
||||
outbound MoveToState. Holtburger
|
||||
([simulation.rs:178-206](references/holtburger/crates/holtburger-core/src/client/simulation.rs))
|
||||
follows the same pattern (sets `ServerControlledProjection`, advances
|
||||
the body, returns empty).
|
||||
|
||||
**Acceptance:** Refactor auto-walk to step `_body.Position` (or
|
||||
equivalent) directly from the wire-supplied path data + run rate, NOT
|
||||
via synthesized input. Motion state during auto-walk becomes a
|
||||
SERVER-DRIVEN state (similar to how remote players' motion is driven
|
||||
by inbound MoveToState packets), not a USER-DRIVEN one. The 500 ms
|
||||
grace period in `EndServerAutoWalk` becomes unnecessary and can be
|
||||
deleted; same for the `IsServerAutoWalking` guard at the wire layer
|
||||
(no MoveToState would have been built in the first place).
|
||||
|
||||
Animation cycle currently driven by motion-interpreter's
|
||||
`MotionStateChanged → SetCycle(RunForward)` would need a separate
|
||||
path: probably mirror how remote-player animation is driven by
|
||||
inbound motion packets (the sequencer accepts a `SetCycle` directly).
|
||||
|
||||
**Files:** `src/AcDream.App/Input/PlayerMovementController.cs`
|
||||
(`ApplyAutoWalkOverlay` returns synthesized input today; refactor to
|
||||
step body directly + drive animation via `_animationSequencer.SetCycle`
|
||||
directly). `src/AcDream.App/Rendering/GameWindow.cs` (delete the
|
||||
`!IsServerAutoWalking` guard once the leak is gone).
|
||||
|
||||
**Estimated scope:** Medium (~50-100 LOC + careful testing of
|
||||
animation cycle continuity). Not blocking M1 — the grace-period
|
||||
band-aid produces retail-faithful behaviour empirically.
|
||||
|
||||
---
|
||||
|
||||
## #74 — [DONE 2026-05-16 · `de44358`] AP cadence is per-frame-while-moving, more chatty than retail
|
||||
|
||||
**Status:** DONE
|
||||
**Severity:** LOW (works; just sends ~60× the packets retail would during smooth motion)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** physics / net cadence
|
||||
|
||||
**Resolution (2026-05-16 · `de44358`):** With #75 (MoveToState suppression refactor) closing the MoveToChain-cancellation race, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`): cell/plane change during the sub-interval; cell-or-frame change after the 1s heartbeat. Added `_lastSentContactPlane` field + extended `NotePositionSent(Vector3, uint, Plane, float)` + added `ApproxPlaneEqual` helper + `PlayerMovementController.ContactPlane` public accessor. Effective rates now match retail: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne.
|
||||
|
||||
**Description:** The diff-driven AP cadence shipped in Commit B fires
|
||||
`HeartbeatDue` on **any** position change each frame while grounded
|
||||
on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
|
||||
heartbeat when idle. Retail's `ShouldSendPositionEvent`
|
||||
(`acclient_2013_pseudo_c.txt:700233`) only sends during the
|
||||
sub-interval when cell or contact-plane changes, and only sends the
|
||||
1 Hz heartbeat if `(cellId, frame)` changed since `last_sent` —
|
||||
truly idle = 0 Hz. So retail during continuous smooth movement is
|
||||
effectively 1 Hz (cell crosses + plane changes don't happen every
|
||||
frame); we are ~60 Hz.
|
||||
|
||||
**Root cause / status:** Deliberate ACE-targeted choice. The
|
||||
per-frame cadence is load-bearing for ACE's `WithinUseRadius` poll
|
||||
to see the player arrive at a target during local speculative
|
||||
auto-walk (issue #63's workaround chain). Going to 1 Hz would
|
||||
re-introduce the arrival-lag bug for far-range Use/PickUp.
|
||||
|
||||
**Files:** [PlayerMovementController.cs:1240-1275](src/AcDream.App/Input/PlayerMovementController.cs)
|
||||
— the `HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed)`
|
||||
gate.
|
||||
|
||||
**Acceptance:** Either (a) fix issue #63 so we honor ACE's
|
||||
`MoveToObject` server-side, removing the need for the per-frame
|
||||
cadence, then revert to retail's `cell-or-plane-change || (interval && frame-change)`
|
||||
shape (~5 LOC change); or (b) document this as a permanent
|
||||
divergence and update commit messages / code comments to match.
|
||||
|
||||
**Estimated scope:** Small (~5 LOC + commit-message rewrite) once
|
||||
#63 is fixed. Currently blocked by #63.
|
||||
|
||||
---
|
||||
|
||||
## #73 — Retail-message centralization plan — per-feature string sweeps
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (per-feature work, not infrastructure)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** ui / retail messages
|
||||
|
||||
**Description:** Commit A added `AcDream.Core.Ui.RetailMessages` as
|
||||
the home for retail-decomp-sourced UI strings (`CannotBeUsed`,
|
||||
`CantBePickedUp`, `CannotPickUpCreatures`). The retail decomp has
|
||||
~750 more user-facing strings we'll need over time — combat misses,
|
||||
spell fizzles, vendor dialogs, "you do not have enough" etc. Rather
|
||||
than bulk-port them once, port per-feature as the feature lands:
|
||||
when wiring vendor purchase, sweep vendor strings into
|
||||
`RetailMessages.Vendor.*`; when wiring spell-cast feedback, sweep
|
||||
`RetailMessages.Spell.*`.
|
||||
|
||||
**Status:** No infrastructure work pending. Pattern is established;
|
||||
new strings get added to `RetailMessages.cs` with retail anchor
|
||||
comments at the call site that triggered the need.
|
||||
|
||||
**Files:** [RetailMessages.cs](src/AcDream.Core/Ui/RetailMessages.cs)
|
||||
— class-level doc comment already describes the per-feature sweep
|
||||
pattern.
|
||||
|
||||
**Acceptance:** Each phase / feature that adds new user-facing
|
||||
strings sweeps its retail-anchor strings into `RetailMessages` and
|
||||
calls them by name rather than literal-in-place. Closing condition:
|
||||
"all M1 demo strings are in RetailMessages" or similar per-milestone
|
||||
gate, decided when M1 ships.
|
||||
|
||||
---
|
||||
|
||||
## #72 — Confirm Humanoid TurnRight/TurnLeft `omega.z` base rate via cdb
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (current ±π/2 fallback matches all corroborating
|
||||
evidence; cdb probe would settle the open question for good)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** physics / rotation / research
|
||||
|
||||
**Description:** Commit A's rotation rate uses
|
||||
`BaseTurnRateRadPerSec = π/2` based on the documented
|
||||
`AnimationSequencer.cs:734-741` claim that the Humanoid motion table
|
||||
ships TurnRight/TurnLeft with `HasOmega` cleared (forcing the
|
||||
convention fallback). The constant has 3 corroborating sources but
|
||||
the actual dat content was never dumped — and the run-multiplier
|
||||
`run_turn_factor = 1.5` at retail `0x007c8914` from
|
||||
`apply_run_to_command` (decomp 0x00527be0) likewise hasn't been
|
||||
verified live.
|
||||
|
||||
**Acceptance:** Set a cdb breakpoint on `CSequence::set_omega`
|
||||
(`acclient_2013_pseudo_c.txt` — find exact symbol address) while
|
||||
holding A or D in a retail client. Capture the `omega.z` argument
|
||||
value walking, then running. If `±π/2` walking and `±π/2 × 1.5 ≈ 2.356`
|
||||
running, close as confirmed. If different, file as a regression and
|
||||
fix the constants in
|
||||
[RemoteMoveToDriver.cs](src/AcDream.Core/Physics/RemoteMoveToDriver.cs).
|
||||
|
||||
**Estimated scope:** ~30 min cdb session + 1 commit if confirmed,
|
||||
or +small fix if different. Not blocking M1.
|
||||
|
||||
---
|
||||
|
||||
## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (Stage A — screen-rect picker — is sufficient for M1)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** selection / picker
|
||||
|
||||
**Description:** Retail's mouse picker does two-tier sphere-then-polygon
|
||||
selection (`acclient_2013_pseudo_c.txt:0x0054c740`
|
||||
`Render::GfxObjUnderSelectionRay`):
|
||||
1. Per-part sphere reject via `CGfxObj::drawing_sphere`.
|
||||
2. Polygon-accurate refine via `CPolygon::polygon_hits_ray` on every
|
||||
visual polygon; closest-t polygon hit wins over any sphere hit.
|
||||
|
||||
Commit B's Stage A
|
||||
([WorldPicker.cs](src/AcDream.Core/Selection/WorldPicker.cs)) does
|
||||
screen-space rect hit-test against the projected
|
||||
`Setup.SelectionSphere` (matching the indicator rect, deliberately
|
||||
broader than the visible mesh polygons). Stage B would tighten clicks
|
||||
to the visible mesh — under-pick what looks like empty space inside
|
||||
the rect, catch visible mesh that pokes past the sphere boundary
|
||||
(creature outstretched arm, sign edge).
|
||||
|
||||
**Acceptance:** Pipe per-part GfxObj visual polygons through a
|
||||
`PickPolygonProvider` interface (don't duplicate mesh decoding —
|
||||
hook the existing `ObjectMeshManager` cached data). Two-tier in
|
||||
`WorldPicker.Pick`: sphere reject → polygon scan → polygon hit
|
||||
dominates sphere hit. Acceptance test: visible-mesh accuracy on
|
||||
Holtburg sign, Royal Guard outstretched bow arm, inn-door wood
|
||||
frame edges.
|
||||
|
||||
**Estimated scope:** Medium (~4-6 hours). Defer until visual
|
||||
verification surfaces a Stage A miss in real play. The user
|
||||
confirmed 2026-05-16 that "I can click on longer ranges now so
|
||||
good" — Stage A is enough for M1's "click an NPC" demo.
|
||||
|
||||
---
|
||||
|
||||
## #70 — Triangle apex/size — final retail-feel UX pass
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (cosmetic — indicator already retail-anchored, this is final-feel polish)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** ui / target indicator
|
||||
|
||||
**Description:** Per 2026-05-16 user feedback during the
|
||||
`SelectionSphere` indicator ship, the triangle apex direction
|
||||
(flipped to point inward at the target) and sprite size (currently
|
||||
8 px legs) are heuristic visual choices. Retail uses an actual DAT
|
||||
sprite from `UIRegion::GetChild(0x1000003a/3b/3c)` — the bitmap
|
||||
shape and size come from the dat, not constants.
|
||||
|
||||
**Acceptance:** Extract the retail triangle sprite from the dat
|
||||
(probably via `tools/UiLayoutMockup` or a new `DatSpriteProbe`) and
|
||||
either (a) blit the exact bitmap, or (b) pick a procedural size +
|
||||
shape that matches it pixel-for-pixel at standard zoom.
|
||||
|
||||
**Files:** [TargetIndicatorPanel.cs](src/AcDream.App/UI/TargetIndicatorPanel.cs)
|
||||
— `TriangleSize` constant + the four `AddTriangleFilled` calls.
|
||||
|
||||
**Estimated scope:** Small (~1-2 hours, mostly dat exploration).
|
||||
Not blocking M1.
|
||||
|
||||
---
|
||||
|
||||
## #69 — [DONE 2026-05-16 · `f035ea3`] Local player rotation isn't animated (no leg/arm cycle while pivoting)
|
||||
|
||||
**Status:** DONE
|
||||
**Severity:** LOW (visual polish — rotation works, just looks stiff)
|
||||
**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
|
||||
**Component:** motion / animation cycle
|
||||
|
||||
**Resolution (2026-05-16 · `f035ea3`):** Fixed as part of the auto-walk architectural refactor (issue #75). `DriveServerAutoWalk` now records the per-frame rotation direction in `_autoWalkTurnDirectionThisFrame` (+1 / -1 / 0); the animation override at the bottom of `Update` reads that flag and sets `localAnimCmd` to `TurnLeft` / `TurnRight` during the turn-first phase. User confirmed 2026-05-16 that the auto-walk turn-first case (click target, body rotates before walking) now plays the leg-shuffle animation. User-driven A/D rotation was always working — the original issue description was specific to the auto-walk turn-first case.
|
||||
|
||||
**Description:** When the auto-walk overlay rotates the local player
|
||||
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
|
||||
the body's Yaw rotates smoothly but no leg / arm animation plays —
|
||||
|
|
@ -254,14 +1042,20 @@ locally on send (mirroring retail's client behavior).
|
|||
|
||||
---
|
||||
|
||||
## #63 — Server-initiated auto-walk (MoveToObject) not honored
|
||||
## #63 — [DONE 2026-05-16 · `f035ea3`] Server-initiated auto-walk (MoveToObject) not honored
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** DONE
|
||||
**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
|
||||
works fine)
|
||||
**Filed:** 2026-05-14 (B.5 visual verification)
|
||||
**Component:** motion / inbound MoveToObject handling
|
||||
|
||||
**Resolution (2026-05-16):** Closed in two parts:
|
||||
1. **B.6 slice 2 (2026-05-14):** inbound MoveToObject parsing + `BeginServerAutoWalk` wiring at `GameWindow.cs:3389` — body auto-walks toward the server-supplied destination.
|
||||
2. **B.6 #75 refactor (`f035ea3`, 2026-05-16):** `ApplyAutoWalkOverlay → DriveServerAutoWalk` drives the body directly from path data, no input synthesis. The `MoveToState` leak that previously cancelled ACE's `MoveToChain` callback is gone; the chain runs uninterrupted and `TryUseItem` / `TryPickUp` fires server-side on arrival. No client-side retry needed. Walk/run threshold corrected to 1.0m (matches retail-observed; overrides ACE's wire-default 15m).
|
||||
|
||||
Visual-verified end-to-end: far-range Use on NPCs / doors / spell components / corpses all complete via ACE's server-side callback. The far-range retry workaround from Task 1's first iteration (`c61d049`'s `_pendingPostArrivalAction` arming) was deleted as part of #75 (`f035ea3`).
|
||||
|
||||
**Description:** When the player triggers a Use or PutItemInContainer
|
||||
on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
|
||||
runs server-side auto-walk via `CreateMoveToChain` →
|
||||
|
|
@ -334,9 +1128,19 @@ int animFrame0Parts = ae.Animation?.PartFrames.Count > 0
|
|||
|
||||
---
|
||||
|
||||
## #61 — AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
|
||||
## #61 — [DONE 2026-05-18 · `9f069e1`] AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
|
||||
|
||||
**Status:** DONE — fixed by `9f069e1` (also widened scope: same bug
|
||||
manifested as the local-player run-stop twitch — user-observed during
|
||||
the M2 anim-pass session). Root cause was `BuildBlendedFrame` wrapping
|
||||
`nextIdx` to `rangeLo` unconditionally at the high-frame boundary —
|
||||
correct for looping cycles (idle/run/walk loops), wrong for one-shot
|
||||
links. During the ~30 ms fractional tail of any link, the renderer
|
||||
blended `frame[end]` with `frame[0]`, producing the flash through the
|
||||
anim's starting pose. Fix: gate the wrap on `curr.IsLooping`. Pinned by
|
||||
the new `Advance_LinkTailDoesNotBlendIntoLinkFrame0` regression test.
|
||||
Visual-verified by the user end-to-end on 2026-05-18.
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (visual polish — animation works, brief one-frame flash through prior pose at end of swing)
|
||||
**Filed:** 2026-05-13 (visual test of B.4c)
|
||||
**Component:** animation / `AcDream.Core.Physics.AnimationSequencer` link+cycle transition
|
||||
|
|
@ -2232,6 +3036,86 @@ Unverified. The likely culprits, ranked by suspected probability:
|
|||
|
||||
# Recently closed
|
||||
|
||||
## #86 — [DONE 2026-05-19 · 3764867 + 4e308d5] Click selection penetrates walls
|
||||
|
||||
**Closed:** 2026-05-19
|
||||
**Commits:** `3764867` — fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker; `4e308d5` — test(picker): Cluster A #86 — screen-rect cell-occlusion tests
|
||||
**Component:** input, interaction
|
||||
|
||||
**Resolution:** `WorldPicker.Pick` now accepts a `cellOccluder` callback
|
||||
(`CellBspRayOccluder`). Before returning a hit, both `Pick` overloads
|
||||
consult the occluder's `NearestWallT` value; any candidate entity whose
|
||||
ray parameter exceeds the nearest-wall intersection is filtered out.
|
||||
The occluder is wired from `GameWindow` using the loaded `PhysicsDataCache`
|
||||
cell structs. Entities behind walls from the camera's perspective are no
|
||||
longer selectable. Screen-rect occlusion tests verify the filter across
|
||||
several hit/miss scenarios.
|
||||
|
||||
---
|
||||
|
||||
## #77 — [DONE 2026-05-18 · 3be7000] Auto-walk doesn't engage at walking range; pickup at walking range overshoots and snaps back
|
||||
|
||||
**Closed:** 2026-05-18
|
||||
**Commit:** `3be7000` — fix(physics): close #77 — auto-walk honors ACE CanCharge bit; zero velocity in turn-in-place
|
||||
**Component:** physics / `PlayerMovementController` / `GameWindow.OnLiveMotionUpdated` / `CreateObject.ServerMotionState`
|
||||
|
||||
**Resolution.** Two coupled bugs sharing a root in
|
||||
`PlayerMovementController.DriveServerAutoWalk` + `BeginServerAutoWalk`.
|
||||
|
||||
1. **Walk-vs-run misclassification (the user-visible "always runs at walk range" half).**
|
||||
`BeginServerAutoWalk` decided `_autoWalkInitiallyRunning = (initialDist −
|
||||
distanceToObject) >= 1.0f`, forcing run at any chase past ~1.6 m.
|
||||
ACE's wire-level walk-vs-run answer is the MovementParameters
|
||||
**CanCharge** bit (0x10), which `Creature.SetWalkRunThreshold`
|
||||
sets when server-side player→target distance ≥ `WalkRunThreshold/2`
|
||||
(= 7.5 m default). Retail's `MovementParameters::get_command`
|
||||
(decomp `0x0052aa00`, `acclient_2013_pseudo_c.txt:307946+`) gates
|
||||
the run path on CanCharge first; the inner walk_run_threshold
|
||||
check practically always walks given ACE's 15 m default. The
|
||||
hardcoded 1.0 m threshold pushed run into the 3-5 m walk-range the
|
||||
user reported should walk.
|
||||
|
||||
2. **Velocity leak in turn-in-place phase (the user-visible "overshoots
|
||||
and snaps back" half).** When the auto-walked body crossed the
|
||||
destination, `desiredYaw` flipped ~180°, `walkAligned` dropped to
|
||||
false, and the `if (!moveForward) return true;` branch returned
|
||||
without zeroing body velocity. The body kept the prior frame's
|
||||
running velocity (`RunAnimSpeed × runRate ≈ 11 m/s`) and slid 4-5 m
|
||||
past the target before the turn-around rotation completed.
|
||||
|
||||
**Changes:**
|
||||
- `CreateObject.ServerMotionState.CanCharge`: new bool prop reading
|
||||
bit 0x10 of `MoveToParameters`. Cross-ref ACE
|
||||
`MovementParams.CanCharge = 0x10`.
|
||||
- `PlayerMovementController.BeginServerAutoWalk`: replaces the unused
|
||||
`walkRunThreshold` parameter with `bool canCharge`; sets
|
||||
`_autoWalkInitiallyRunning = canCharge`.
|
||||
- `PlayerMovementController.DriveServerAutoWalk` turn-in-place branch:
|
||||
calls `_motion.DoMotion(Ready, 1.0)` and zeros body horizontal
|
||||
velocity (preserving Z for gravity). No-op for initial-turn with a
|
||||
stationary body; fixes overshoot-recovery and settling cases.
|
||||
- `GameWindow.OnLiveMotionUpdated`: passes
|
||||
`update.MotionState.CanCharge` through; `[autowalk-begin]` trace
|
||||
now shows `canCharge=` instead of `walkRunThresh=`.
|
||||
- `GameWindow.InstallSpeculativeTurnToTarget`: predicts ACE's
|
||||
CanCharge from local distance using ACE's exact 7.5 m rule, so the
|
||||
speculative install agrees with the wire-triggered overwrite that
|
||||
arrives moments later.
|
||||
|
||||
**Verification.** Build green; all targeted test projects pass cleanly
|
||||
(Core.Net 294/294, UI.Abstractions 419/419, App 10/10; Core 1073 passed
|
||||
/ 8 pre-existing failures unchanged). Visual-verified at Holtburg
|
||||
2026-05-18: walk-range NPC click walks + Use fires + dialogue appears,
|
||||
walk-range F-key pickup walks + no overshoot + item enters inventory,
|
||||
far-range pickup (8-10 m+) still runs.
|
||||
|
||||
**Lesson archived:** `memory/feedback_autowalk_cancharge_bit.md`. When
|
||||
ACE already encodes a decision on the wire (CanCharge IS the walk-vs-run
|
||||
answer), relay it — don't reinvent the bucket with a locally-computed
|
||||
threshold.
|
||||
|
||||
---
|
||||
|
||||
## #56 — [DONE 2026-05-12 · 8735c39] `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root
|
||||
|
||||
**Closed:** 2026-05-12
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ designed 2026-04-24. Full design: `docs/plans/2026-04-24-ui-framework.md`.
|
|||
The backend is pluggable; ViewModels / Commands / `IPanelRenderer` are
|
||||
stable across the swap. ImGui persists forever as the
|
||||
`ACDREAM_DEVTOOLS=1` devtools overlay regardless of which backend owns
|
||||
the game UI. See `memory/project_ui_architecture.md` for the session
|
||||
crib-sheet version.
|
||||
the game UI. The full UI design lives in
|
||||
`docs/plans/2026-04-24-ui-framework.md`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
376
docs/architecture/code-structure.md
Normal file
376
docs/architecture/code-structure.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# acdream — code structure & extraction sequence
|
||||
|
||||
**Status:** Living document. Created 2026-05-16 as the companion to the
|
||||
"Code Structure Rules" section in `CLAUDE.md`.
|
||||
**Purpose:** Describe the desired structural state of the App layer,
|
||||
explain the rules we've adopted, and lay out the safe extraction
|
||||
sequence from today's reality (one 10,304-line `GameWindow.cs`) to the
|
||||
target (thin `GameWindow`, small focused collaborators).
|
||||
**Companion to:** [`acdream-architecture.md`](acdream-architecture.md)
|
||||
(the layered architecture) and
|
||||
[`worldbuilder-inventory.md`](worldbuilder-inventory.md) (what we take
|
||||
from WB vs port ourselves).
|
||||
|
||||
---
|
||||
|
||||
## 1. The structural problem we're solving
|
||||
|
||||
The layered architecture works: `AcDream.Core` is GL-free, the network
|
||||
layer is wire-compatible, the UI has a stable contract, plugins load.
|
||||
The structural debt is concentrated in **one file**:
|
||||
|
||||
```
|
||||
src/AcDream.App/Rendering/GameWindow.cs 10,304 lines
|
||||
```
|
||||
|
||||
`GameWindow` is the single object that:
|
||||
|
||||
- Owns the GL context, the window, input, and shaders.
|
||||
- Reads ~40 different environment variables across its lifetime.
|
||||
- Hosts the live network session (`WorldSession`) and the offline
|
||||
pre-login state.
|
||||
- Owns parallel dictionaries for entity lookup (`_entitiesByServerGuid`,
|
||||
the per-landblock entity lists in `GpuWorldState`, plus the player
|
||||
controller's own state).
|
||||
- Drives selection / interaction (`WorldPicker`, `SendUse`,
|
||||
`SendPickUp`).
|
||||
- Drives per-frame render orchestration (sky → terrain → opaque mesh →
|
||||
transparent mesh → particles → debug lines → UI).
|
||||
- Wires up every plugin hook sink, every diagnostic, every panel.
|
||||
|
||||
Almost every M1 / M2 bug touches this file. Every new feature adds a
|
||||
field plus a method plus a wiring call. It is not getting better on its
|
||||
own.
|
||||
|
||||
The fix is **not** "rewrite `GameWindow` in one pass" — that's a
|
||||
high-risk change that would block M2. The fix is to **extract one
|
||||
collaborator at a time**, verify behavior is unchanged, ship, and
|
||||
move on. This document defines that sequence.
|
||||
|
||||
---
|
||||
|
||||
## 2. Code Structure Rules — the discipline
|
||||
|
||||
Recap of the rules from `CLAUDE.md` with the rationale:
|
||||
|
||||
### Rule 1: No new substantial feature bodies in `GameWindow.cs`
|
||||
|
||||
**Why:** Every line we add to `GameWindow` makes the eventual decomposition
|
||||
harder. New features that "live in" `GameWindow` instead of being
|
||||
extracted are the reason the file is 10k lines.
|
||||
|
||||
**How to apply:** A new feature gets its own class under
|
||||
`src/AcDream.App/<Subsystem>/` (or deeper in `AcDream.Core` if it's pure
|
||||
logic). `GameWindow` owns a field and a wiring call, nothing more. If
|
||||
you find yourself adding a 200-line method to `GameWindow`, stop and
|
||||
extract.
|
||||
|
||||
**Exemption:** Trivial wiring that *must* stay in `GameWindow` because
|
||||
it touches GL state during `OnLoad` is acceptable, but should still
|
||||
delegate to a collaborator for the substance.
|
||||
|
||||
### Rule 2: `AcDream.Core` must not depend on window / GL / backend projects
|
||||
|
||||
**Why:** Core is the GL-free, testable layer. The moment Core imports
|
||||
a GL or windowing namespace, we've lost the ability to test it without
|
||||
a graphics context, and the layer split becomes fiction.
|
||||
|
||||
**How to apply:** The only currently-allowed seams from Core into the
|
||||
WB / GL world are:
|
||||
|
||||
- `WorldBuilder.Shared` — stateless helpers (`TerrainUtils`,
|
||||
`TerrainEntry`, `RegionInfo`).
|
||||
- `Chorizite.OpenGLSDLBackend.Lib` — stateless helpers
|
||||
(`SceneryHelpers`, `TextureHelpers`).
|
||||
|
||||
Both are leaf namespaces with no GL state. If you need a new seam (e.g.
|
||||
WB's `ObjectMeshManager` needs to be visible from Core), the change
|
||||
**must** come with an inventory-doc update justifying it and ideally a
|
||||
slim interface in Core that the App layer implements.
|
||||
|
||||
**Current reality:** `src/AcDream.Core/AcDream.Core.csproj` references
|
||||
`Chorizite.OpenGLSDLBackend` (not just `OpenGLSDLBackend.Lib`). This is
|
||||
historical. Two Core files import from it:
|
||||
- `World/SceneryGenerator.cs` — `Chorizite.OpenGLSDLBackend.Lib`
|
||||
- `Textures/SurfaceDecoder.cs` — `Chorizite.OpenGLSDLBackend.Lib`
|
||||
|
||||
Both use the stateless `Lib` namespace only. The full project reference
|
||||
is wider than it needs to be; tightening it to just `WorldBuilder.Shared`
|
||||
+ a stateless-helpers shim is a candidate future cut, but not urgent.
|
||||
|
||||
### Rule 3: UI panels target `AcDream.UI.Abstractions` only
|
||||
|
||||
**Why:** This is the one rule that keeps D.2b (the future retail-look
|
||||
backend) viable. Every panel that imports `ImGuiNET` directly is a panel
|
||||
we'd have to rewrite when the backend swaps.
|
||||
|
||||
**How to apply:** A panel's `using` block must mention
|
||||
`AcDream.UI.Abstractions.*` and nothing from `AcDream.UI.ImGui`. The
|
||||
panel writes against `IPanelRenderer`. The `ImGuiPanelRenderer`
|
||||
translates those calls to ImGui at runtime. Plugin-facing UI follows the
|
||||
same rule.
|
||||
|
||||
### Rule 4: Startup env vars enter through `RuntimeOptions`
|
||||
|
||||
**Why:** Environment variables are global mutable state. Reading them
|
||||
at random call sites means (a) duplicated `Environment.GetEnvironmentVariable`
|
||||
boilerplate, (b) no single place to see "what flags does the client
|
||||
respond to?", (c) impossible to unit-test parsing.
|
||||
|
||||
**How to apply:** `src/AcDream.App/RuntimeOptions.cs` is the typed
|
||||
options object. `Program.cs` builds it once from args + env and passes
|
||||
it to `GameWindow`. New startup flags add a field to `RuntimeOptions`
|
||||
and a parser in `RuntimeOptions.FromEnvironment`. They don't add
|
||||
`Environment.GetEnvironmentVariable` reads.
|
||||
|
||||
**Scope:** `RuntimeOptions` is for **startup-time** configuration —
|
||||
things that don't change once the window is up. Runtime diagnostic
|
||||
toggles are Rule 5's domain.
|
||||
|
||||
### Rule 5: Runtime diagnostic toggles live in diagnostic owner classes
|
||||
|
||||
**Why:** Diagnostic flags (`ACDREAM_DUMP_MOTION`, `ACDREAM_PROBE_*`,
|
||||
etc.) need to be both env-readable at startup *and* runtime-toggleable
|
||||
from the DebugPanel. Per-call-site env reads can't be runtime-toggled.
|
||||
|
||||
**How to apply:** Today's template is
|
||||
`src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — one static class with
|
||||
typed `Probe*` properties read from env vars once at startup, plus
|
||||
runtime setters that the DebugPanel binds. New diagnostic flags follow
|
||||
this shape, not the per-call-site pattern that dominates `GameWindow.cs`.
|
||||
|
||||
**Cleanup direction:** The dozens of existing `ACDREAM_DUMP_*` reads
|
||||
inside `GameWindow.cs` are tech debt. We do NOT bulk-migrate them as
|
||||
part of this refactor — they're working, they're scattered, and
|
||||
moving them carries risk without a current acceptor. We migrate them
|
||||
opportunistically: when a `GameWindow` extraction lands and a diagnostic
|
||||
moves with it, route it through the new owner's diagnostic class.
|
||||
|
||||
### Rule 6: Tests live in the project matching the layer
|
||||
|
||||
**Why:** Test discoverability + dependency hygiene. A test for a Core
|
||||
class belongs next to other Core tests; a test for an App class belongs
|
||||
in an App test project. Co-locating tests across layers makes the
|
||||
dependency graph dishonest.
|
||||
|
||||
**How to apply:** One test project per source project that has tests.
|
||||
Today:
|
||||
|
||||
- `tests/AcDream.Core.Tests/` ← `src/AcDream.Core/`
|
||||
- `tests/AcDream.Core.Net.Tests/` ← `src/AcDream.Core.Net/`
|
||||
- `tests/AcDream.UI.Abstractions.Tests/` ← `src/AcDream.UI.Abstractions/`
|
||||
|
||||
`AcDream.App` does **not** yet have a test project. The RuntimeOptions
|
||||
extraction is the trigger to create `tests/AcDream.App.Tests/`. Future
|
||||
App-layer tests (LiveSessionController, SelectionInteractionController,
|
||||
etc.) go there.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target structure of the App layer
|
||||
|
||||
The end state — not what we're shipping in one pass, but the shape
|
||||
we're aiming at.
|
||||
|
||||
```
|
||||
src/AcDream.App/
|
||||
├── Program.cs # parse args + env → RuntimeOptions, build GameWindow
|
||||
├── RuntimeOptions.cs # typed startup options (Rule 4)
|
||||
├── Rendering/
|
||||
│ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
|
||||
│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI)
|
||||
│ ├── TerrainModernRenderer.cs # (already exists)
|
||||
│ ├── TextureCache.cs # (already exists)
|
||||
│ ├── ParticleRenderer.cs # (already exists)
|
||||
│ ├── Sky/ # (already exists)
|
||||
│ ├── Wb/ # (already exists — WB seam)
|
||||
│ └── Vfx/ # (already exists)
|
||||
├── Net/
|
||||
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
|
||||
├── World/
|
||||
│ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation
|
||||
├── Interaction/
|
||||
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
|
||||
├── Streaming/ # (already exists)
|
||||
├── Input/ # (already exists)
|
||||
├── Audio/ # (already exists)
|
||||
└── Plugins/ # (already exists)
|
||||
```
|
||||
|
||||
What `GameWindow` keeps:
|
||||
|
||||
- `IWindow` / `GL` / `IInputContext` lifecycle (constructor + `OnLoad` +
|
||||
`Run` + `OnClosing`).
|
||||
- `RuntimeOptions` reference (the typed startup config).
|
||||
- One field per collaborator (`_liveSessionController`,
|
||||
`_liveEntityRuntime`, `_selectionInteraction`,
|
||||
`_renderFrameOrchestrator`).
|
||||
- The Silk.NET event-handler stubs that delegate to collaborators.
|
||||
|
||||
What `GameWindow` loses:
|
||||
|
||||
- The 7 startup-time env var fields → moved into `RuntimeOptions`.
|
||||
- `TryStartLiveSession` + the post-login network drain → moved into
|
||||
`LiveSessionController`.
|
||||
- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id
|
||||
translation → moved into `LiveEntityRuntime`.
|
||||
- `WorldPicker` + `_selectedGuid` + `SendUse` / `SendPickUp` → moved
|
||||
into `SelectionInteractionController`.
|
||||
- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`.
|
||||
|
||||
The eventual `GameEntity` aggregation (target state described in
|
||||
`acdream-architecture.md` §"GameEntity: The Unified Entity") happens
|
||||
**after** `LiveEntityRuntime` is the single owner of entity state.
|
||||
Until then, the parallel-dicts problem is bounded inside one class
|
||||
instead of spread across `GameWindow`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Extraction sequence — safest first
|
||||
|
||||
Each step is **one PR-sized refactor**. Each must build clean, all
|
||||
tests pass, and visual verification at Holtburg looks identical to
|
||||
the previous step. Don't bundle two steps.
|
||||
|
||||
### Step 1 — `RuntimeOptions` (this PR)
|
||||
|
||||
**Scope:** Replace startup-time env var reads with a typed options
|
||||
object built once in `Program.cs`.
|
||||
|
||||
**Behavior change:** None. Same env vars, same defaults, same effects.
|
||||
|
||||
**Risk:** Low. Mechanical substitution at ~10-15 call sites in
|
||||
`GameWindow.cs` + one constructor signature change.
|
||||
|
||||
**Test:** Unit tests for `RuntimeOptions.FromEnvironment` parsing (the
|
||||
new `tests/AcDream.App.Tests/` project).
|
||||
|
||||
**Verification:** `dotnet build` + `dotnet test` green. Visual launch
|
||||
verifies live mode + dat dir resolution still work.
|
||||
|
||||
### Step 2 — `LiveSessionController`
|
||||
|
||||
**Scope:** Extract `TryStartLiveSession` + the WorldSession ownership +
|
||||
the post-EnterWorld drain (`OnLiveStateUpdated`, `OnLiveEntityDeleted`,
|
||||
etc.) into a controller class.
|
||||
|
||||
**Behavior change:** None. Same wire behavior, same handshake.
|
||||
|
||||
**Risk:** Medium. WorldSession lifecycle is load-bearing — every
|
||||
session-state crash would surface here. The change is a class
|
||||
extraction with the same event subscriptions, not a rewrite.
|
||||
|
||||
**Test:** Existing `AcDream.Core.Net.Tests` already cover the wire
|
||||
layer. The controller itself gets a smoke test that verifies it can be
|
||||
constructed without a live socket (offline mode).
|
||||
|
||||
**Verification:** Visual login + Holtburg traversal + door interaction
|
||||
identical to pre-extraction.
|
||||
|
||||
### Step 3 — `LiveEntityRuntime` (or `EntityRuntimeRegistry`)
|
||||
|
||||
**Scope:** Centralize the parallel dictionaries — `_entitiesByServerGuid`,
|
||||
the streaming entity lists, the player controller's player entity —
|
||||
into one owner. Surface the ServerGuid↔entity.Id translation as a
|
||||
single API (eliminating the trap from L.2g slice 1c).
|
||||
|
||||
**Behavior change:** None.
|
||||
|
||||
**Risk:** Medium-high. Entity lookup is in every hot path. The change
|
||||
is structural (one owner instead of three) but the lookup semantics
|
||||
must be byte-identical.
|
||||
|
||||
**Test:** Entity-spawn / despawn / lookup tests in
|
||||
`tests/AcDream.App.Tests/`. Existing visual verification at Holtburg
|
||||
catches any drift in interaction.
|
||||
|
||||
**Verification:** Walk Holtburg, click NPC, open door, pick up item.
|
||||
All four M1 demo targets must still work.
|
||||
|
||||
### Step 4 — `SelectionInteractionController`
|
||||
|
||||
**Scope:** Extract `WorldPicker`, `_selectedGuid`, `SendUse`,
|
||||
`SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
|
||||
`SelectionPickUp` switch cases into one controller. Depends on Step 3
|
||||
(uses `LiveEntityRuntime`).
|
||||
|
||||
**Behavior change:** None.
|
||||
|
||||
**Risk:** Low-medium. Selection state is local to interactions; the
|
||||
network outbound side is well-defined (`InteractRequests.BuildUse` /
|
||||
`BuildPickUp`).
|
||||
|
||||
**Test:** Selection state machine tests in `tests/AcDream.App.Tests/`.
|
||||
|
||||
**Verification:** Click-to-select, double-click-to-Use, F-key pickup
|
||||
all still work.
|
||||
|
||||
### Step 5 — `RenderFrameOrchestrator`
|
||||
|
||||
**Scope:** Extract the per-frame draw sequence (sky → terrain →
|
||||
opaque mesh → translucent mesh → particles → debug → UI) into a
|
||||
dedicated orchestrator that `GameWindow.OnRender` delegates to.
|
||||
|
||||
**Behavior change:** None. Same draw order, same GL state.
|
||||
|
||||
**Risk:** Medium. GL state management is touchy; the orchestrator
|
||||
must hand the GL context to the same renderers in the same order with
|
||||
the same per-pass state setup.
|
||||
|
||||
**Test:** Visual verification only. Render orchestration is hard to
|
||||
unit-test without a GL context.
|
||||
|
||||
**Verification:** Holtburg at radius 4, radius 8, radius 12 looks
|
||||
identical across all four quality presets.
|
||||
|
||||
### Step 6 — `GameEntity` aggregation (the big one)
|
||||
|
||||
**Scope:** Consolidate `WorldEntity` + `AnimatedEntity` + the per-entity
|
||||
state in `LiveEntityRuntime` into one `GameEntity` class (the target
|
||||
described in `acdream-architecture.md`). Every entity in the world —
|
||||
player, NPC, monster, door, item — becomes a single `GameEntity`.
|
||||
|
||||
**Behavior change:** None at the wire / visual level; substantial at
|
||||
the call-site level (everyone moves to the new entity API).
|
||||
|
||||
**Risk:** High. Touches every system that reads entity state.
|
||||
|
||||
**Test:** All existing tests + the new `AcDream.App.Tests` suite. Visual
|
||||
verification at every M1 / M2 scenario.
|
||||
|
||||
**Verification:** Full M2 demo loop (equip sword, kill drudge, pick up
|
||||
loot, open inventory) works identically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rules of the road during the extraction
|
||||
|
||||
1. **One step at a time.** A PR that ships Step 1 ships only Step 1.
|
||||
Bundling steps makes failures hard to isolate.
|
||||
2. **Behavior preservation is the acceptance criterion.** Every step
|
||||
must build clean, all tests pass, and visual verification at the
|
||||
appropriate M1 / M2 scenarios must succeed. We're moving code, not
|
||||
changing it.
|
||||
3. **No new features during an extraction step.** If you spot a real
|
||||
bug while extracting, file it in `docs/ISSUES.md` and address it in
|
||||
a separate commit (before or after the extraction, not folded into
|
||||
it).
|
||||
4. **Diagnostic toggle migrations are opportunistic.** When a method
|
||||
moves to a new owner, the diagnostic flag inside it can move to a
|
||||
diagnostic class as part of the same commit. We do not do a bulk
|
||||
diagnostic-cleanup pass.
|
||||
5. **Update this document when the plan changes.** If Step 3 turns out
|
||||
to need a different shape than described above, update §4 in the
|
||||
same session you discover the divergence.
|
||||
|
||||
---
|
||||
|
||||
## 6. What this document is **not**
|
||||
|
||||
- **Not a full rewrite plan.** The point is the *opposite* — small
|
||||
steps, verified at each boundary.
|
||||
- **Not blocking M2.** Step 1 is small enough to ship without
|
||||
disrupting M2 work. Later steps interleave with M2 / M3 phases as
|
||||
the corresponding code paths come into focus.
|
||||
- **Not a substitute for the milestones / roadmap.** Those drive the
|
||||
feature work. This drives the structural work that runs underneath.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-05-12. **Between phases.** **Since the last header update:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). **Earlier this week:** post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b).
|
||||
**Status:** Living document. Updated 2026-05-19. **Between phases.** **Since the last header update:** Indoor walkable-plane BSP port FOUNDATION shipped (6 commits, `ff548b9` → `f845b22`) but visual verification failed — cellar descent, 2nd-floor walking, single-floor cottage regressions all confirm the shipped fix doesn't address the user-reported indoor bugs. Root cause now diagnosed as deeper than originally thought: `TryFindIndoorWalkablePlane` exists as a Phase 2 stop-gap that retail doesn't have an analog for. Retail retains ContactPlane state across frames; we re-synthesize per frame. Foundation work (BSP walker + probe + tests) remains useful; next phase needs to port retail's ContactPlane retention mechanism and likely eliminate `TryFindIndoorWalkablePlane` entirely. Handoff: [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](../research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md). ISSUES #83 remains OPEN with deeper diagnosis. **Earlier:** Indoor cell rendering Phase 1 (diagnostics) + Phase 2 (fix) shipped — root cause was a one-line WB bug at `ObjectMeshManager.cs:1223` (blind `TryGet<Setup>` on GfxObj-prefixed stab ids threw `ArgumentOutOfRangeException` which WB's outer catch silently swallowed, causing 26/123 Holtburg cells to fail upload). Identified via diagnostic chain (5 `[indoor-*]` probes + a `ContinueWith` exception surfacer + a `ConsoleErrorLogger` injected into WB), fixed with a Setup-prefix guard. User visually confirmed floors render. Surfaced 9 pre-existing indoor bugs filed in `docs/ISSUES.md`. **Earlier:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b).
|
||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
||||
|
||||
---
|
||||
|
|
@ -68,7 +68,11 @@
|
|||
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
|
||||
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
|
||||
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
|
||||
| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
|
||||
| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
|
||||
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` → `[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
|
||||
| Indoor walking Phase 1 — BSP cluster (partial) | 2026-05-19. Probe + WorldPicker cell-BSP occlusion (#86 closed) + CellId promotion via AABB containment (partial #84 fix). Seven commits across 5 phases: `18a2e28` plan, `27d7de1` Phase A `[indoor-bsp]` probe + toggle, `3764867` Phase B CellBspRayOccluder in WorldPicker, `4e308d5` Phase B screen-rect tests, `c19d6fb` Phase D AABB containment + L.2e bare-low-byte fix, `fda6af7` Phase E `[cell-cache]` diagnostic, `1f11ba9` Phase E extended AABB/bsphere/poly-count fields. **#86 closed** (picker occlusion). **#84 partially closed** (spawn-in-building stuck-above-floor resolved; threshold/doorway walls remain open under #87). **#85 open** (wall pass-through root cause confirmed as same as #84 remaining symptom — CellId doesn't stay promoted during outdoor→indoor walking). **#87 filed** (portal-based indoor cell tracking — retail-faithful follow-up). `[indoor-bsp]` + `[cell-cache]` probes stay in place as scaffolding for the follow-up phase. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](../research/2026-05-19-cluster-a-shipped-handoff.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md). | Tests ✓ |
|
||||
| Indoor walking Phase 2 — Portal-based cell tracking | 2026-05-19. Portal-graph traversal replaces Phase D's AABB containment. Six commits: `1969c55` CellBSP+Portals wired into CellPhysics; `aad6976` CellTransit.FindCellList + FindTransitCellsSphere + AddAllOutsideCells + ResolveCellId rename; `069534a` BuildingPhysics + CheckBuildingTransit for outdoor→indoor entry; `702b30a` code-review polish; `3ffe1e4` pass foot-sphere center to ResolveCellId (critical fix — was passing CheckPos instead of GlobalSphere[0].Origin, causing PointInsideCellBsp to return false at floor level); `eb0f772` TryFindIndoorWalkablePlane synthesizes walkable plane from cell floor poly so the resolver doesn't fall through to outdoor SampleTerrainWalkable. **Closes #87, #85, and the wall-pass-through portion of #84 (fully closes #84).** Files #88 (indoor static object vibration — pre-existing) and #89 (BSPQuery.SphereIntersectsCellBsp — approximation in CheckBuildingTransit). `[cell-transit]`, `[indoor-bsp]`, `[check-bldg]`, `[cell-cache]` probes stay in place. Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md). | Live ✓ |
|
||||
|
||||
Plus polish that doesn't get its own phase number:
|
||||
- FlyCamera default speed lowered + Shift-to-boost
|
||||
|
|
@ -222,7 +226,8 @@ Research: R9 + R12 + R13.
|
|||
|
||||
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
|
||||
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
|
||||
- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on L.2e** for trustworthy `cell_bsp`, indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
|
||||
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
|
||||
- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on indoor portal-based cell tracking above** (and previously on L.2e) for trustworthy indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
|
||||
|
||||
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ feeling because no single phase ship feels like a real milestone.
|
|||
This document sits **one altitude above** the roadmap. Each milestone is:
|
||||
|
||||
- **~6–10 weeks of focused work** (not a single phase, not a whole year).
|
||||
- Defined by a **concrete playable scenario** that gets recorded as a demo
|
||||
video when the milestone hits.
|
||||
- Defined by a **concrete playable scenario** — when the scenario works
|
||||
end-to-end, the milestone lands.
|
||||
- A **scope-freeze event**: when a milestone lands, the phases it covers go
|
||||
off-limits until v1.0's final polish pass (M7).
|
||||
|
||||
Crossing a milestone is a real event with an artifact. Phases ship; milestones
|
||||
**land**.
|
||||
Crossing a milestone is a textual event — milestones doc gets the writeup,
|
||||
the freeze list flips, CLAUDE.md's "currently working toward" line advances.
|
||||
Phases ship; milestones **land**.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -42,12 +43,12 @@ Crossing a milestone is a real event with an artifact. Phases ship; milestones
|
|||
shipped phases keep silently consuming attention.
|
||||
|
||||
3. **The milestone log is the morale instrument.** When a milestone hits:
|
||||
- Record a ~30-second demo video showing the scenario end-to-end.
|
||||
- Drop it in `docs/milestones/MN-<slug>.mp4` (create the directory on
|
||||
first hit).
|
||||
- Pin a still frame + one-paragraph writeup at the top of this doc.
|
||||
- Pin a one-paragraph writeup at the top of this doc describing what
|
||||
works end-to-end (any caveats or known regressions are explicit).
|
||||
- Update the freeze list. Update CLAUDE.md's "currently working toward"
|
||||
line to the next milestone.
|
||||
- NO demo videos. User explicitly removed that requirement 2026-05-16
|
||||
("pointless of recording videos, for what purpose?").
|
||||
|
||||
4. **State both altitudes at session start.** "Currently working toward M1.
|
||||
Current phase: L.2 collision. Next concrete step: L.2d slice 1 spec." This
|
||||
|
|
@ -94,7 +95,28 @@ missing is the gameplay loop on top.
|
|||
|
||||
---
|
||||
|
||||
### M1 — "Walkable + clickable world" — 🟡 CURRENT, all 4 demo targets met (pending recorded video)
|
||||
### M1 — "Walkable + clickable world" — ✅ LANDED 2026-05-16
|
||||
|
||||
**Landing writeup (2026-05-16, after Phase B.6 ship `d640ed7`):**
|
||||
All four M1 demo targets work end-to-end. You can log into `+Acdream`
|
||||
at Holtburg, walk freely without getting stuck (L.2 collision is
|
||||
retail-faithful through the doorway). Click any inn door at any
|
||||
range, press R, the character runs (or walks if close) toward it
|
||||
with the correct animation cycle including the leg-shuffle turn-first
|
||||
phase, opens the door via ACE's server-side `MoveToChain` callback.
|
||||
Same for clicking an NPC — runs over, body rotates to face, dialogue
|
||||
fires from ACE without any client-side retry. Pickup items at any
|
||||
range with F or R; spell components, food, money, weapons all work;
|
||||
signs and other `BF_STUCK` scenery correctly block at the gate.
|
||||
AP cadence matches retail (0 Hz idle, ~1 Hz smooth motion, per-event
|
||||
on cell/plane changes). Run/walk threshold matches retail observation
|
||||
(1m of distance left to walk). The earlier ladder of workarounds —
|
||||
client-side retry, per-frame chatty AP, MoveToState suppression
|
||||
grace period — all deleted via the Phase B.6 architectural refactor
|
||||
(`d640ed7`). M1's demo path is now bit-for-bit retail-faithful end
|
||||
to end.
|
||||
|
||||
|
||||
|
||||
**Demo scenario:** Walk through Holtburg without getting stuck on the inn
|
||||
doorway. Open the inn door. Click an NPC and see selection feedback. Pick
|
||||
|
|
@ -109,21 +131,22 @@ up an item from the ground.
|
|||
| 3 | Click an NPC and see selection feedback | ✅ met | B.4b chain + chat handlers; verified 2026-05-14 (Tirenia + Royal Guard double-click → NPC dialogue in chat panel) |
|
||||
| 4 | Pick up an item from the ground | ✅ met (close-range path) | B.5 + post-B.5 `PickupEvent (0xF74A)` fix shipped 2026-05-14; visual-verified at Holtburg; creature-pickup guard added in `a01ebd5` |
|
||||
|
||||
**What's left to formally land M1:**
|
||||
- Record ~30s demo video of the four-target scenario end-to-end.
|
||||
- Drop at `docs/milestones/M1-walkable-clickable.mp4`.
|
||||
- Pin still + one-paragraph writeup at the top of this doc.
|
||||
- Flip the freeze list. Update `CLAUDE.md`'s "currently working toward"
|
||||
line to M2.
|
||||
**Landing artifacts done 2026-05-16:**
|
||||
- ✅ Landing writeup pinned at top of this milestone block (above the table).
|
||||
- ✅ Freeze list applied (see below).
|
||||
- ✅ `CLAUDE.md`'s "currently working toward" advanced to M2.
|
||||
|
||||
**Known polish items deferred (do not block M1 recording, addressable post-M1):**
|
||||
**Known polish items deferred to post-M7 (do not gate M1 landing):**
|
||||
- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing. LOW.
|
||||
- **#62** — PARTSDIAG null-guard. Latent, not reachable today.
|
||||
- **#63** — Server-initiated `MoveToObject` auto-walk not honored (blocks
|
||||
double-click pickup + out-of-range F-pickup; close-range still works).
|
||||
MEDIUM. Candidate Phase B.6 — holtburger has the reference port.
|
||||
- **#63** — ✅ CLOSED by Phase B.6 (`d640ed7`). Server-initiated
|
||||
`MoveToObject` is now honored end-to-end; ACE's `MoveToChain`
|
||||
callback fires server-side on arrival.
|
||||
- **#64** — Local-player pickup animation does not render (retail
|
||||
observers see it correctly). LOW.
|
||||
- **#69, #74, #75** — all closed by Phase B.6 (`d640ed7`). Turn-first
|
||||
animation, retail-narrow AP cadence, body-direct auto-walk
|
||||
architecture.
|
||||
|
||||
**Phases that shipped to clear M1:**
|
||||
- **L.2 (a + d + g sub-lanes)** — Movement & Collision Conformance.
|
||||
|
|
|
|||
284
docs/research/2026-05-16-issue77-autowalk-handoff.md
Normal file
284
docs/research/2026-05-16-issue77-autowalk-handoff.md
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
# Issue #77 — close-range auto-walk + pickup overshoot — investigation handoff
|
||||
|
||||
**Filed:** 2026-05-16 (in `docs/ISSUES.md` as the active issue at the top)
|
||||
**Severity:** MEDIUM (M1-deferred polish; visible during normal play, doesn't block any phase)
|
||||
**Component:** physics / auto-walk / `PlayerMovementController.DriveServerAutoWalk`
|
||||
**Branch state when handed off:** main at `f8829b3` (post-merge of `claude/hungry-tharp-b4a27b`)
|
||||
|
||||
---
|
||||
|
||||
## What you're chasing
|
||||
|
||||
Two related close-range bugs in the server-driven auto-walk path. Both are
|
||||
**pre-existing** — not caused by the LiveSessionController extraction
|
||||
(0b25df5) — they were surfaced during that refactor's visual verification.
|
||||
|
||||
### Bug A — NPC at walking range never auto-walks
|
||||
|
||||
- User clicks an NPC (e.g. Royal Guard at `0x7A9B46AE`) when the player
|
||||
is at "walking range" — far enough that retail would walk a short
|
||||
distance to reach the NPC's `useRadius`, not close enough to fire
|
||||
Use immediately.
|
||||
- The client's `WorldPicker` returns `WithinUseRadius=false`, so
|
||||
`OnInputAction.UseSelected` defers the Use and would expect ACE's
|
||||
inbound `MoveToObject` motion update to drive the player to the NPC.
|
||||
- **The local player does not visibly move.** Repeated clicks (the trace
|
||||
below shows seq 81 → 87 → 90 → 96 → 105 → 141 → 146 → 159 → 163 →
|
||||
169 → 173 → 177 against the same Royal Guard) produce the same
|
||||
response every time without any movement.
|
||||
|
||||
### Bug B — Pickup at walking range runs/overshoots/snaps back
|
||||
|
||||
- User presses F on a ground item while in "walking range" of it.
|
||||
- Player **runs** (not walks) toward the target.
|
||||
- Overshoots the item, then **blips back** to the correct position
|
||||
before the pickup actually fires.
|
||||
- The pickup completes (item ends up in inventory), but the visual is
|
||||
jarring.
|
||||
|
||||
The "blips back" almost certainly means ACE's server-side position
|
||||
correction snaps the player back after the client overshot. The
|
||||
client's run-not-walk choice is the proximal cause.
|
||||
|
||||
---
|
||||
|
||||
## What we know already (don't re-discover this)
|
||||
|
||||
### Trace evidence captured during merge
|
||||
|
||||
From `launch.log` of the Step 2 verification run (task `b01zkw68w`,
|
||||
2026-05-16, with `ACDREAM_DEVTOOLS=1` + my temporary
|
||||
`OnLiveMotionUpdated` diagnostic):
|
||||
|
||||
```
|
||||
[B.4b] use guid=0x7A9B46AE seq=159 ← outbound Use packet sent
|
||||
OnLiveMotionUpdated: guid=0x5000000A stance=61 cmd=0x speed= ← player → NonCombat
|
||||
OnLiveMotionUpdated: guid=0x7A9B46AE stance=61 cmd=0x0003 speed= ← NPC turns to face
|
||||
OnLiveMotionUpdated: guid=0x7A9B46AE stance=61 cmd=0x speed=
|
||||
OnLiveMotionUpdated: guid=0x5000000A stance=0 cmd=0x0005 speed=-1.84 ← ACE sends MoveToObject for player
|
||||
OnLiveMotionUpdated: guid=0x5000000A stance=0 cmd=0x speed=
|
||||
```
|
||||
|
||||
The pattern repeats identically for every retry — ACE *is* sending the
|
||||
auto-walk command, but the client isn't engaging it.
|
||||
|
||||
**The negative speed (-1.84) is suspicious.** Speed is parsed as a raw
|
||||
IEEE 754 float by `UpdateMotion.cs:193`. Either retail encodes a sign
|
||||
that we're misinterpreting, or this is a legitimate "move backward"
|
||||
instruction (ACE sometimes positions the move-to point behind the
|
||||
player). The auto-walk engagement condition may be filtering negative
|
||||
speeds out without our intent.
|
||||
|
||||
### What was already established BEFORE this issue
|
||||
|
||||
`PlayerMovementController` got significant retail-faithful refactor work
|
||||
in Phase B.6 (closed-issue #75 territory, commit `f035ea3`). That work
|
||||
established:
|
||||
|
||||
- **Walk/run threshold = 1.0m** of remaining-distance-to-useRadius
|
||||
(not ACE's wire-supplied 15m default — that's overridden).
|
||||
- **One-shot walk/run decision** at `BeginServerAutoWalk` time, held
|
||||
for the rest of the chain.
|
||||
- **Direct body-velocity drive** — auto-walk does NOT synthesize
|
||||
`MovementInput`. It steps `Yaw`, sets `_body.set_local_velocity`
|
||||
from `runRate`, and calls `_motion.DoMotion(WalkForward, speed)`
|
||||
directly.
|
||||
|
||||
The auto-walk diagnostic infrastructure already exists:
|
||||
|
||||
```
|
||||
PhysicsDiagnostics.ProbeAutoWalkEnabled ← runtime-toggleable
|
||||
ACDREAM_PROBE_AUTOWALK=1 ← env-var enable
|
||||
|
||||
[autowalk-out] on every SendUse / SendPickUp
|
||||
[autowalk-mt] on every inbound UpdateMotion for the local player
|
||||
[autowalk-up] on every inbound UpdatePosition for the local player
|
||||
[autowalk-begin] when BeginServerAutoWalk fires
|
||||
[autowalk-end] when EndServerAutoWalk fires
|
||||
```
|
||||
|
||||
**You should turn this on first.** The `[autowalk-begin]` line will tell
|
||||
you whether `BeginServerAutoWalk` is even being invoked for the
|
||||
walking-range case.
|
||||
|
||||
### Where to start reading code
|
||||
|
||||
| File | Why |
|
||||
|---|---|
|
||||
| `src/AcDream.App/Input/PlayerMovementController.cs` | The auto-walk driver lives here. Key functions: `BeginServerAutoWalk` (line ~428), `DriveServerAutoWalk` (line ~550), `EndServerAutoWalk` (line ~478). |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` line ~3360 | The `OnLiveMotionUpdated` site that detects MoveToObject pattern and calls `BeginServerAutoWalk`. The `[autowalk-mt]` and `[autowalk-begin]` traces fire here. |
|
||||
| `src/AcDream.Core.Net/Messages/UpdateMotion.cs` line ~193 | The inbound parser. `ForwardSpeed` is a raw float — investigate whether negative is legitimate or a sign-misinterpretation. |
|
||||
| `docs/superpowers/specs/2026-05-14-phase-b6-design.md` | The Phase B.6 design spec. Read this first to understand the existing auto-walk contract. |
|
||||
| `references/holtburger/crates/holtburger-core/src/client/simulation.rs` | The Rust client's equivalent — has `ServerControlledProjection` + `approximate_move_to_object_projection_target`. Holtburger handles this case correctly, so cross-checking is valuable. |
|
||||
| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | Grep for `MoveToManager::HandleMoveToPosition`, `MoveToManager::HandleAutonomyLevelChange`, `CMotionInterp::apply_interpreted_movement`. Retail's truth. |
|
||||
|
||||
### What was checked and ruled out during the Step 2 session
|
||||
|
||||
- The bugs exist on the **pre-Step-2 branch** (eda936d / 32423c2 / main).
|
||||
This was confirmed by diff scope: `PlayerMovementController.cs`,
|
||||
`PhysicsEngine.cs`, `UpdateMotion.cs` were not touched by Step 2.
|
||||
- The Step 2 refactor (`0b25df5`) does not affect the auto-walk path.
|
||||
- Subscriptions are wired correctly — `OnLiveMotionUpdated` IS firing
|
||||
for every motion update (verified via `[step2-diag]` traces that have
|
||||
since been stripped).
|
||||
|
||||
---
|
||||
|
||||
## Hypotheses to test, in order
|
||||
|
||||
### H1 (most likely) — `BeginServerAutoWalk` never fires for the walking-range MoveToObject
|
||||
|
||||
The walking-range MoveToObject from ACE may not match the pattern that
|
||||
`OnLiveMotionUpdated` checks before calling `BeginServerAutoWalk`. The
|
||||
condition probably checks for one of: `IsServerControlledMoveTo`,
|
||||
non-zero ForwardSpeed magnitude, specific `MovementType`, or specific
|
||||
`ForwardCommand` values. Walking-range UpdateMotion may differ from
|
||||
running-range in one of those fields.
|
||||
|
||||
**Test:** Enable `ACDREAM_PROBE_AUTOWALK=1`, click the NPC at walking
|
||||
range. Look for `[autowalk-mt]` (inbound parse) WITHOUT a following
|
||||
`[autowalk-begin]`. That confirms H1 and points to GameWindow.cs:3360.
|
||||
|
||||
### H2 — `BeginServerAutoWalk` fires but `_autoWalkInitiallyRunning` decision misclassifies
|
||||
|
||||
The walk/run decision uses:
|
||||
```csharp
|
||||
remainingAtStart = initialDist - distanceToObject
|
||||
_autoWalkInitiallyRunning = remainingAtStart >= 1.0m
|
||||
```
|
||||
|
||||
If ACE sends a `distanceToObject` (useRadius) much smaller than the
|
||||
NPC's actual useRadius — or if `initialDist` is computed against the
|
||||
wrong target position — `remainingAtStart` could land just above 1m
|
||||
even at user-perceived walking range, causing run-not-walk. That
|
||||
matches **Bug B**'s "runs and overshoots" pattern.
|
||||
|
||||
**Test:** Compare `[autowalk-begin] dest=(...) minDist=... objDist=... walkRunThresh=...`
|
||||
values between a walking-range click and a running-range click. The
|
||||
`objDist` should be the wire-supplied useRadius. If it's wrong (too
|
||||
small), retail's value disagrees and we have a parser bug elsewhere.
|
||||
|
||||
### H3 — Negative `ForwardSpeed` is filtered or misinterpreted
|
||||
|
||||
`speed=-1.84` is the literal IEEE 754 float on the wire. Retail's
|
||||
`CMotionInterp::handle_action_walkforward` (or whichever code consumes
|
||||
ForwardSpeed) may use it for direction relative to the auto-walk
|
||||
heading; a sign-extension bug in our parse would matter.
|
||||
|
||||
**Test:** Grep `references/holtburger` and named-retail decomp for how
|
||||
`ForwardSpeed` is consumed. If retail/holtburger interpret the sign
|
||||
specially and we don't, that's the gap.
|
||||
|
||||
### H4 — Arrival predicate fires too early
|
||||
|
||||
`DriveServerAutoWalk` line ~601:
|
||||
```csharp
|
||||
withinArrival = dist <= arrivalThreshold
|
||||
```
|
||||
where `arrivalThreshold = _autoWalkDistanceToObject` (use-radius).
|
||||
|
||||
If `distanceToObject` is 0 or near-zero (a parser bug, see H2), the
|
||||
arrival predicate fires on the first frame and `EndServerAutoWalk("arrived")`
|
||||
is called immediately, so the player never visibly moves. That matches
|
||||
**Bug A** exactly.
|
||||
|
||||
**Test:** Look for `[autowalk-end] reason=arrived` immediately after
|
||||
`[autowalk-begin]` with zero or one frame between. That confirms H4.
|
||||
|
||||
---
|
||||
|
||||
## Reproduction recipe (~3 minutes)
|
||||
|
||||
1. **Launch with autowalk probe enabled:**
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
$env:ACDREAM_PROBE_AUTOWALK = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug | Tee-Object -FilePath launch.log
|
||||
```
|
||||
|
||||
2. **Reproduce Bug A:**
|
||||
- Walk toward the inn area in Holtburg until you're ~3-5 meters from
|
||||
an NPC (e.g. Royal Guard near the inn). Estimate by eye — the goal
|
||||
is "you can see the NPC clearly but you'd need to take a few steps
|
||||
to reach them."
|
||||
- Double-click the NPC.
|
||||
- Observe: player doesn't move. Click again — same result.
|
||||
|
||||
3. **Reproduce Bug B:**
|
||||
- Find a ground item (Holtburg has scattered spell components — the
|
||||
coloured Tapers are obvious). Stand ~3-5 meters away.
|
||||
- Press F (or whatever your `SelectionPickUp` key is bound to).
|
||||
- Observe: player runs, overshoots, snaps back, item picked up.
|
||||
|
||||
4. **Stop the client gracefully** (window close, not Stop-Process — see
|
||||
CLAUDE.md "Logout-before-reconnect"). ACE clears stale sessions in
|
||||
3–5 seconds on graceful close.
|
||||
|
||||
5. **Grep the log:**
|
||||
```bash
|
||||
tr -d '\000' < launch.log | grep -E "\[autowalk-(out|mt|begin|up|end)\]"
|
||||
```
|
||||
|
||||
This should give you a complete frame-by-frame trace of every
|
||||
auto-walk decision the client made.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
When the fix lands:
|
||||
|
||||
- ✅ Click NPC at walking range → player **walks** (not runs) directly to NPC,
|
||||
Use fires on arrival, NPC dialogue appears.
|
||||
- ✅ Press F on ground item at walking range → player **walks** the short
|
||||
distance, no overshoot, no blip-back, item enters inventory.
|
||||
- ✅ Far-range click still **runs** to target (don't regress the working case).
|
||||
- ✅ Out-of-walking-but-very-close-range case (right at the edge of useRadius)
|
||||
still arrives without infinite spin or stuttering.
|
||||
- ✅ All existing tests pass (8 pre-existing Core failures are baseline,
|
||||
do NOT count against the fix).
|
||||
- ✅ Visual verification at Holtburg, all three M1 demo targets still work
|
||||
(door, NPC, pickup).
|
||||
|
||||
---
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't add a workaround**, per CLAUDE.md's "no workarounds" rule.
|
||||
No grace-period band-aid, no "if speed is negative, force walk" hack,
|
||||
no "always walk when within 5m" override. Fix the root cause.
|
||||
- **Don't rewrite the auto-walk path** — Phase B.6 was a heavy retail
|
||||
decomp port. The fix is almost certainly a one-condition or one-formula
|
||||
adjustment, not a new design.
|
||||
- **Don't change `Step 2`'s extracted code**. `LiveSessionController` and
|
||||
the wireup are clean — `OnLiveMotionUpdated` is wired and firing per
|
||||
the previous session's verification traces.
|
||||
|
||||
---
|
||||
|
||||
## Time estimate
|
||||
|
||||
- 30 min: read the spec + reproduce + capture trace
|
||||
- 30 min: identify root-cause hypothesis from the trace
|
||||
- 30 min – 2 hr: implement the fix (depends on which hypothesis lands)
|
||||
- 30 min: visual verification + write commit message
|
||||
- **Total:** 2–3 hours focused work
|
||||
|
||||
---
|
||||
|
||||
## When done
|
||||
|
||||
1. Commit message format: `fix(physics): close #77 — <root cause summary>`
|
||||
2. Move `#77` to the "Recently closed" section of `docs/ISSUES.md`
|
||||
with closed-date + commit SHA (matches the project convention).
|
||||
3. If the fix uncovered a durable lesson (e.g. "ACE sends negative
|
||||
ForwardSpeed for MoveToObject; we were filtering"), add a
|
||||
`feedback_*.md` memory entry per `memory/MEMORY.md` conventions.
|
||||
4. The next pre-M2 cleanup items in queue: root `.editorconfig` + Step 3
|
||||
(`LiveEntityRuntime`). See `docs/architecture/code-structure.md` §4.
|
||||
90
docs/research/2026-05-16-session-handoff.md
Normal file
90
docs/research/2026-05-16-session-handoff.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Session handoff — 2026-05-16
|
||||
|
||||
## What landed this session
|
||||
|
||||
**M1 — "Walkable + clickable world" — ✅ LANDED on main at `fb92122`.**
|
||||
|
||||
All four M1 demo targets work end-to-end retail-faithfully:
|
||||
1. Walk through Holtburg without getting stuck (L.2 collision) — outdoor only verified
|
||||
2. Open the inn door (B.4c)
|
||||
3. Click an NPC and see dialogue (B.4b + Phase B.6)
|
||||
4. Pick up an item from the ground (B.5 + Phase B.6)
|
||||
|
||||
**Important caveat:** the M1 demo specifically did NOT test:
|
||||
- Walking around inside the inn (just the doorway)
|
||||
- Going up stairs
|
||||
- Indoor-to-indoor transitions
|
||||
- Indoor lighting correctness
|
||||
|
||||
These were assumed to "probably work" because outdoor walking does. They likely don't.
|
||||
|
||||
## Main branch history (newest first)
|
||||
|
||||
| SHA | Title |
|
||||
|---|---|
|
||||
| `5d79dd3` | docs: session handoff 2026-05-16 (this doc; will be overwritten when you read this) |
|
||||
| `fb92122` | milestone: M1 landed; flip "currently working toward" to M2 |
|
||||
| `d640ed7` | feat(retail): Phase B.6 — server-driven auto-walk done right |
|
||||
| `b5da17d` | feat(retail): Commit B — retail-faithful AP cadence + screen-rect picker |
|
||||
| `e2bc3a9` | (base — docs(CLAUDE.md): document Ghidra MCP + WireMCP availability) |
|
||||
|
||||
## Phase B.6 (today's big landing) — what it actually did
|
||||
|
||||
Replaced the chain of Commit-B workarounds that compensated for ACE's `MoveToChain` getting cancelled by a leaked user-MoveToState packet during inbound auto-walk. The fix was architectural:
|
||||
|
||||
- **`ApplyAutoWalkOverlay → DriveServerAutoWalk`** — auto-walk drives the body's velocity + motion state + animation cycle DIRECTLY from the wire-supplied path data. No player-input synthesis. Mirrors retail's `MovementManager::PerformMovement` case 6 (decomp `0x00524440`) which never touches the user-input pipeline during server-controlled auto-walk.
|
||||
- **Wire-layer guard retained** at `GameWindow.cs:6419` as a semantic statement (user-MoveToState packets are for user-driven motion intent), NOT as the band-aid the deleted 500ms grace period was.
|
||||
- **Walk/run threshold = 1.0m** (matches user-observed retail behaviour; ACE's wire-default 15.0f is ignored — overridden in `BeginServerAutoWalk` via const `RetailWalkRunThresholdMeters`). Formula from decomp `0x0052aa00 MovementParameters::get_command`: `running = (initialDist - distance_to_object) >= threshold`, one-shot at chain start.
|
||||
- **Animation cycle plumbed** for moving-forward (RunForward/WalkForward) AND turn-first phase (TurnLeft/TurnRight via `_autoWalkTurnDirectionThisFrame`).
|
||||
- **Pickup gate** corrected to check `BF_STUCK` (`acclient.h:6435`, bit `0x4`) — signs (`pwd=0x14`) blocked; spell components (`pwd=0x10`) allowed.
|
||||
- **R-key dispatches by target type** — creature → SendUse, pickupable → SendPickUp, useable → SendUse, else toast.
|
||||
- **AP cadence reverted** to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`). Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne. `ApproxPlaneEqual` helper added.
|
||||
|
||||
Issues closed: **#63, #69, #74, #75**.
|
||||
|
||||
## New rules / preferences captured this session
|
||||
|
||||
1. **No workarounds without explicit approval.** CLAUDE.md "How to operate" + `memory/feedback_no_workarounds.md`. Band-aids, grace periods, suppression flags, retry loops forbidden unless the user explicitly approves or it's a deliberate new-feature design.
|
||||
|
||||
2. **No milestone demo videos.** CLAUDE.md "milestone discipline" rule #3 + `memory/feedback_no_demo_videos.md`. Milestones land via text-only artifacts.
|
||||
|
||||
3. **Graceful client shutdown via `CloseMainWindow`.** CLAUDE.md "Logout-before-reconnect" section. `Stop-Process` is a hard kill — leaves ACE's session marked logged-in for ~3+ min before timeout.
|
||||
|
||||
## Direction redirect at session end
|
||||
|
||||
The originally-planned **M2 — "Kill a drudge"** is being **deferred** in favor of fundamentals-first work. User's stated reasoning: indoor walking, physics correctness, and lighting are all untested or broken, and building combat on top of an unverified-indoor foundation would compound problems.
|
||||
|
||||
**New M2 candidate — "Indoor walkability."** Demo scenario candidate: walk into the Holtburg inn, climb to the second floor, look around, walk back out — all with sensible camera + correct indoor lighting + collision-clean. Three sub-phases proposed:
|
||||
|
||||
| Phase | Scope | Estimate |
|
||||
|---|---|---|
|
||||
| Camera correctness | Sphere-cast from player to camera position; snap to first wall hit; lerp recovery when clear. Prevent camera from dipping below ground. Indoor-specific but applies everywhere. | ~1 day |
|
||||
| Indoor collision audit | Walk every floor of the Holtburg inn (and a nearby small dungeon if time). Document each off-feeling spot — stairs, narrow halls, EnvCell-to-EnvCell transitions, EnvCell-to-outdoor seams. Fix scoped per finding. | ~1 week |
|
||||
| Indoor lighting basics | Tightly scoped: torch-light pools + proper indoor ambient (dim). Defer fire/lamp/glow particles + dynamic-light count optimization to M5. Touches shader pipeline (modern bindless path) + per-object light selection. | ~1-2 weeks |
|
||||
|
||||
Order matters: camera fix first (you can't honestly audit collision while the camera fights you), then collision audit (find the bugs), then lighting (the visual unlock).
|
||||
|
||||
**This milestone has not been formally renamed in `docs/plans/2026-05-12-milestones.md` yet.** The doc still says "M2 — Kill a drudge." The next session should brainstorm the new milestone (using `superpowers:brainstorming`), agree on the demo scenario, scope the sub-phases, then update the milestones doc + CLAUDE.md to reflect the reorder. Combat slides to M3.
|
||||
|
||||
## Test baseline
|
||||
|
||||
- Core.Net: 294/294 ✅
|
||||
- Core: 1073/1081 (8 pre-existing Physics failures — BSPStepUp + MotionInterpreter; unchanged baseline)
|
||||
|
||||
## Environment reminders
|
||||
|
||||
- ACE running locally on `127.0.0.1:9000` (testaccount / testpassword / `+Acdream` at `0x5000000A`).
|
||||
- DAT directory: `%USERPROFILE%\Documents\Asheron's Call`.
|
||||
- Worktree branch `claude/vigilant-golick-9433e1` has the full 20-commit Phase B.6 history; main has one squashed commit (`d640ed7`).
|
||||
- WorldBuilder submodule needs `git submodule update --init references/WorldBuilder` in fresh worktrees.
|
||||
|
||||
## Open issues worth tracking
|
||||
|
||||
- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing. LOW.
|
||||
- **#64** — Local-player pickup animation does not render. LOW.
|
||||
- **#70** — Triangle apex/size DAT sprite. LOW.
|
||||
- **#71** — WorldPicker Stage B polygon refine. LOW.
|
||||
- **#72** — cdb probe to confirm `omega.z = π/2` base rate. LOW.
|
||||
- **#73** — Retail-message centralization. Per-feature, ongoing.
|
||||
|
||||
None block the new indoor milestone. All M7 polish.
|
||||
256
docs/research/2026-05-19-cluster-a-shipped-handoff.md
Normal file
256
docs/research/2026-05-19-cluster-a-shipped-handoff.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# Indoor walking Phase 1 — BSP cluster (Cluster A) — handoff (2026-05-19)
|
||||
|
||||
**Date:** 2026-05-19.
|
||||
**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller).
|
||||
**Predecessor:** Indoor lighting + rendering Phase 2 (fix) — floors now render in Holtburg Inn. Nine pre-existing indoor bugs surfaced the moment floors were visible; this cluster addresses the collision/interaction subset (#84, #85, #86) and adds diagnostic infrastructure for the follow-up portal-traversal phase.
|
||||
**Plan:** [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Cluster A shipped **partially**. Three of the five planned phases (A, B, D)
|
||||
produced real behavior changes; two (C — obstacle audit — and E — cell-cache
|
||||
diagnostics) are diagnostic/research phases. The cluster's investigation
|
||||
confirmed that the wall-collision failures (#84, #85) all root in one cause:
|
||||
the player's `CellId` is never promoted to an indoor cell during normal
|
||||
walking, so the indoor-BSP collision branch in `TransitionTypes.FindEnvCollisions`
|
||||
never fires. Phase D implemented an AABB-containment shortcut that resolves
|
||||
the specific "spawn inside a building and be stuck above the floor" case but
|
||||
proved too tight to keep `CellId` promoted through threshold/doorway cells
|
||||
during normal outdoor→indoor entry.
|
||||
|
||||
**#86** (click selection penetrates walls) is **fully closed** — a clean,
|
||||
self-contained fix in `WorldPicker`.
|
||||
|
||||
**#84** is **partially closed** — the spawn-in-building symptom is gone; the
|
||||
remaining wall-collision symptom during normal walking is tracked under the
|
||||
new **#87**.
|
||||
|
||||
**#85** remains **open**; its root cause is confirmed identical to #84's
|
||||
remaining symptom and is also tracked under #87.
|
||||
|
||||
**#87** (indoor portal-based cell tracking) is **filed** and ready for the
|
||||
follow-up phase.
|
||||
|
||||
---
|
||||
|
||||
## Commits
|
||||
|
||||
| # | SHA | Subject | Phase |
|
||||
|---|---|---|---|
|
||||
| 1 | `18a2e28` | `docs(plan): implementation plan written` | Plan doc |
|
||||
| 2 | `27d7de1` | `feat(physics): Cluster A — indoor BSP collision probe` | Phase A |
|
||||
| 3 | `3764867` | `fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker` | Phase B |
|
||||
| 4 | `4e308d5` | `test(picker): Cluster A #86 — screen-rect cell-occlusion tests` | Phase B follow-up |
|
||||
| 5 | `c19d6fb` | `fix(physics): Cluster A #84 + #85 — indoor cell tracking` | Phase D |
|
||||
| 6 | `fda6af7` | `feat(physics): Cluster A — cell-cache diagnostic` | Phase E (1st) |
|
||||
| 7 | `1f11ba9` | `feat(diag): Cluster A — extend [cell-cache] with AABB + bsphere + recursive poly count` | Phase E (2nd) |
|
||||
|
||||
**Build:** clean on all commits.
|
||||
**Tests:** `dotnet test` shows the same 8 pre-existing failures in
|
||||
`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged across
|
||||
the entire cluster). All targeted test projects green. Phase B follow-up
|
||||
adds screen-rect occlusion tests; Phase D adds `RegisterCellStructForTest`
|
||||
helper used by caller-side tests.
|
||||
|
||||
---
|
||||
|
||||
## What shipped
|
||||
|
||||
### Phase A — `[indoor-bsp]` probe
|
||||
|
||||
New `PhysicsDiagnostics.ProbeIndoorBspEnabled` toggle (env var
|
||||
`ACDREAM_PROBE_INDOOR_BSP` + DebugPanel checkbox under
|
||||
`ACDREAM_DEVTOOLS=1`). When enabled, logs one `[indoor-bsp]` line each time
|
||||
`TransitionTypes.FindEnvCollisions` takes the indoor-cell branch —
|
||||
i.e., when `CellId` is an EnvCell id and the BSP contains physics polys. The
|
||||
probe serves as a presence detector: if `[indoor-bsp]` never fires during
|
||||
indoor walking, the BSP is not being consulted at all.
|
||||
|
||||
### Phase B — WorldPicker cell-BSP ray occlusion (closes #86)
|
||||
|
||||
New `CellBspRayOccluder` class (in `src/AcDream.App/Rendering/`) computes
|
||||
`NearestWallT`: the smallest ray parameter at which the pick ray intersects
|
||||
any cached EnvCell BSP polygon. Both `WorldPicker.Pick` overloads now accept
|
||||
an optional `cellOccluder` callback and filter out any hit candidate whose
|
||||
ray T exceeds `NearestWallT`. The occluder is wired from `GameWindow` using
|
||||
the `PhysicsDataCache` cell structs that Phase D also extends.
|
||||
|
||||
Before Phase B: clicking through a wall from the outside selected NPCs/items
|
||||
inside the building — `WorldPicker.BuildRay + Pick` (Phase B.4b) tested only
|
||||
entity AABBs and scenery BSPs, not EnvCell BSP geometry.
|
||||
|
||||
After Phase B: entities behind the nearest wall from the camera's perspective
|
||||
are filtered out of the candidate set. Screen-rect unit tests verify the
|
||||
filter across hit/miss/occlusion scenarios.
|
||||
|
||||
### Phase D — AABB containment for indoor CellId (partial #84 fix)
|
||||
|
||||
`PhysicsEngine.ResolveOutdoorCellId` is extended with an indoor
|
||||
cell-containment scan. After resolving the outdoor cell, the method checks
|
||||
whether the player's world position falls inside any cached `CellPhysics`
|
||||
AABB; if so, `CellId` is promoted to that EnvCell. This enables the
|
||||
`FindEnvCollisions` indoor-BSP branch.
|
||||
|
||||
New `PhysicsDataCache.TryFindContainingCell(worldPos)` does the AABB scan.
|
||||
New `CellPhysics.WorldAabb` caches the cell-local AABB in world space on
|
||||
first call (transforms the BSP bounding sphere's local AABB by the cell
|
||||
origin). New `RegisterCellStructForTest` helper allows unit test callers to
|
||||
populate the cache directly.
|
||||
|
||||
Also fixes the L.2e bare-low-byte preservation bug: `ResolveOutdoorCellId`
|
||||
was silently truncating the player CellId to the low 16 bits; the fix
|
||||
preserves the full 32-bit value.
|
||||
|
||||
**What this solved:** player spawning inside a building (e.g., logging in
|
||||
from a position inside Holtburg cottage) no longer sees `walkable=False` for
|
||||
hundreds of resolves with world Z=94.000. Phase D promotes CellId to the
|
||||
indoor cell, the floor's BSP polys are found, the player can move.
|
||||
|
||||
**What this did NOT solve:** the `[indoor-bsp]` probe fires only 6 times
|
||||
during an entire indoor walking session (all mid-jump, when the body happens
|
||||
to be at a height that falls inside a room AABB). During normal walking on
|
||||
the floor, the player's world Z is at the AABB floor level or lower —
|
||||
outside the AABB for threshold/doorway cells that have only a 0.2 m Z range.
|
||||
See Phase E evidence below.
|
||||
|
||||
### Phase E — Cell-cache diagnostic infrastructure
|
||||
|
||||
Two commits add `[cell-cache]` log output (env var
|
||||
`ACDREAM_PROBE_CELL_CACHE`, also DebugPanel). For each EnvCell in the
|
||||
physics cache, the probe logs:
|
||||
|
||||
```
|
||||
[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14
|
||||
bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80)
|
||||
bspOrigin=(0.00,0.00,0.00) bspRadius=9.97
|
||||
```
|
||||
|
||||
The extended second commit adds `bspTotalLeafPolys`, `bspUnmatchedIds`,
|
||||
`bspOrigin`, and `bspRadius` fields to give a complete picture of cell
|
||||
geometry from the physics cache perspective. This infrastructure stays in
|
||||
place as scaffolding for the portal-traversal phase.
|
||||
|
||||
---
|
||||
|
||||
## Issue status after Cluster A
|
||||
|
||||
| Issue | Status | Notes |
|
||||
|---|---|---|
|
||||
| #84 Blocked by air indoors | OPEN (partial) | Spawn-in-building variant resolved by Phase D. Threshold/doorway wall-blocking remains open under #87. |
|
||||
| #85 Pass through walls outside→in | OPEN | Root cause confirmed as same as #84 remaining symptom. See #87. |
|
||||
| #86 Click selection penetrates walls | **CLOSED** | Phase B. `WorldPicker.Pick` + `CellBspRayOccluder`. |
|
||||
| #87 Indoor portal-based cell tracking | OPEN (new) | Filed 2026-05-19. Retail-faithful fix via `CObjMaint::HandleObjectEnterCell`. |
|
||||
|
||||
---
|
||||
|
||||
## Probe evidence — log file findings
|
||||
|
||||
### `launch-cluster-a-capture.log`
|
||||
|
||||
Initial probe run with `ACDREAM_PROBE_INDOOR_BSP=1`. Result: **zero
|
||||
`[indoor-bsp]` lines** during outdoor walking and during approach to the
|
||||
Holtburg cottage doorway. This was the first confirmation that the indoor-BSP
|
||||
branch was entirely gated out. The player's CellId remained an outdoor cell
|
||||
for all movement.
|
||||
|
||||
### `launch-cluster-a-verify.log`
|
||||
|
||||
Post-Phase-D run. Observed `[indoor-bsp]` lines **only during jump frames**
|
||||
(6 total). When the player jumped inside the cottage, the body briefly rose
|
||||
to a height inside the room AABB, CellId promoted to `0xA9B40143`, and the
|
||||
indoor-BSP branch fired. On landing, the body returned to floor level, fell
|
||||
outside the AABB, and CellId reverted to the outdoor cell. Confirmed that
|
||||
AABB containment works for the room cell when the player is mid-air, but
|
||||
fails at floor level.
|
||||
|
||||
### `launch-cluster-a-cache-diag2.log`
|
||||
|
||||
First `[cell-cache]` probe run (Phase E first commit). Showed all cached
|
||||
cells with their physics poly counts and local AABBs. Confirmed 14 physics
|
||||
polys in cell `0xA9B40143` (the room), indicating BSP geometry is present
|
||||
and complete. Identified cell `0xA9B40146` as a 4-poly threshold cell.
|
||||
|
||||
### `launch-cluster-a-cache-diag3.log`
|
||||
|
||||
Extended `[cell-cache]` probe run (Phase E second commit). Full data:
|
||||
|
||||
```
|
||||
[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14
|
||||
bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80)
|
||||
bspOrigin=(0.00,0.00,0.00) bspRadius=9.97
|
||||
```
|
||||
Room cell: 2.80 m AABB height — works for mid-air player.
|
||||
|
||||
```
|
||||
[cell-cache] id=0xA9B40146 physicsPolyCount=4
|
||||
aabbMin=(-11.60,2.80,-0.20) aabbMax=(-10.00,7.60,0.00)
|
||||
bspRadius=2.3
|
||||
```
|
||||
Threshold/doorway cell: 0.20 m AABB Z range (from -0.20 to 0.00). A standing
|
||||
player at local Z=0.46 m is outside this AABB. **This is why AABB containment
|
||||
fails for normal walking through doorways.**
|
||||
|
||||
Key conclusion: the geometry is correct and complete (14/14 polys match between
|
||||
physics cache and BSP leaf count). The problem is purely in the cell-ownership
|
||||
tracking mechanism, not the collision data itself.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic infrastructure remaining in place
|
||||
|
||||
Both probes stay committed and wired. They serve as scaffolding for the
|
||||
portal-traversal follow-up phase:
|
||||
|
||||
- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one
|
||||
`[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell
|
||||
branch. After portal traversal is implemented, this probe should fire
|
||||
consistently whenever the player is indoors.
|
||||
|
||||
- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all
|
||||
cached EnvCell physics data (poly counts, BSP bounding sphere, AABB,
|
||||
unmatched ID count). Useful for verifying that cell structs load correctly
|
||||
and that portal connectivity data is present.
|
||||
|
||||
Both are gated behind `PhysicsDiagnostics` static class (existing pattern
|
||||
from L.2a).
|
||||
|
||||
---
|
||||
|
||||
## Follow-up items for the portal-traversal phase
|
||||
|
||||
**1. Implement portal-based indoor cell tracking (issue #87).**
|
||||
Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's
|
||||
`CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses
|
||||
a cell portal boundary, `CellId` propagates through `CEnvCell` portal
|
||||
connectivity data. PDB symbols in `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||
and struct definitions in `docs/research/named-retail/acclient.h` lines
|
||||
31715-31726 (`CCellStructure` shape). The retail reference implementation
|
||||
is the right oracle — do not guess at the traversal algorithm.
|
||||
|
||||
**2. Audit-trail note: add retail PDB symbol citations to `TryFindContainingCell`.**
|
||||
The current implementation in `src/AcDream.Core/Physics/PhysicsDataCache.cs`
|
||||
~line 261 is documented as a shortcut. The follow-up phase should add
|
||||
the PDB symbol citation (e.g., `// retail: CObjMaint::HandleObjectEnterCell
|
||||
// docs/research/named-retail/acclient_2013_pseudo_c.txt:XXXXX`)
|
||||
per the Phase D code-review I1 note, so future readers know this is intentionally
|
||||
replacing an interim implementation.
|
||||
|
||||
**3. Consider renaming `ResolveOutdoorCellId` → `ResolveCellId`.**
|
||||
The method now handles both outdoor and indoor cell resolution. The rename
|
||||
is low-risk (one call site in `PhysicsEngine.cs`) and would reduce the
|
||||
cognitive overhead for the next phase's author. Noted as a Phase D code-review
|
||||
M2 suggestion — do it in the same commit as the portal-traversal implementation
|
||||
to keep the rename and the semantic change together.
|
||||
|
||||
---
|
||||
|
||||
## State at handoff
|
||||
|
||||
- **Branch:** `claude/competent-robinson-dec1f4`, 7 commits of implementation/test/diagnostic work.
|
||||
- **Build state:** `dotnet build -c Debug` clean.
|
||||
- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp baseline). All new tests green.
|
||||
- **Issues:** #86 CLOSED; #84 PARTIAL; #85 OPEN; #87 OPEN (new).
|
||||
- **Diagnostic probes:** `[indoor-bsp]` + `[cell-cache]` active and wired.
|
||||
- **Next:** portal-based indoor cell tracking (#87) or M2 critical path — Claude's choice per work-order autonomy.
|
||||
94
docs/research/2026-05-19-indoor-cell-rendering-cause.md
Normal file
94
docs/research/2026-05-19-indoor-cell-rendering-cause.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Indoor Cell Rendering — Phase 2 Cause Report
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
|
||||
**Capture method:** Phase 2's `ContinueWith` + `ConsoleErrorLogger` injected into WB's `ObjectMeshManager` surfaced the exception WB was silently catching.
|
||||
|
||||
## Cause
|
||||
|
||||
**Single failure mode:** `ArgumentOutOfRangeException` thrown from `DatReaderWriter.DBObjs.Setup.Unpack` at WB's [`ObjectMeshManager.cs:1223`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1223):
|
||||
|
||||
```csharp
|
||||
// For EnvCell static objects, we need to manually collect emitters if they are Setups
|
||||
if (_dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) { // ← throws
|
||||
```
|
||||
|
||||
WB iterates `envCell.StaticObjects` and **blindly calls `TryGet<Setup>` on every stab id**, regardless of whether the id is actually a Setup-prefix (`0x02xxxxxx`) or a GfxObj-prefix (`0x01xxxxxx`). When stab.Id is a GfxObj, `DatReaderWriter` finds the file (Portal dat has both GfxObjs and Setups under the same tree-lookup) and attempts to deserialize the GfxObj bytes as a Setup record. The Setup format is structurally different — early parse fails inside `QualifiedDataId.Unpack` → `DatBinReader.ReadBytesInternal` throws `ArgumentOutOfRangeException`.
|
||||
|
||||
The exception bubbles up to `PrepareMeshData`'s outer try/catch at line 589:
|
||||
|
||||
```csharp
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
|
||||
return null; // ← swallows exception, returns null
|
||||
}
|
||||
```
|
||||
|
||||
The entire EnvCell upload fails silently. The cell's room geometry (floor / walls / ceiling) never reaches `_renderData`, so the dispatcher skips drawing it. Static objects inside the cell (which acdream hydrates separately) still render — they have their own GfxObj uploads.
|
||||
|
||||
**This also explains the user's "objects below ground" observation:** with the floor mesh missing, you see the cell's static objects (tables / chairs / fireplaces) through where the floor should be. Visually they appear "below ground."
|
||||
|
||||
## Sample evidence
|
||||
|
||||
55 NULL_RESULT cells captured at multiple landblocks (`0xA5B4`, `0xA7B4`, `0xA8B2`, `0xA9B0`, `0xA9B2`, `0xA9B3`, `0xA9B4`). All 55 share the same exception type and stack frame:
|
||||
|
||||
```
|
||||
[wb-error] Error preparing mesh data for 0x00000000A9B20114
|
||||
[wb-error] ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
|
||||
[wb-error] at DatReaderWriter.DBObjs.Setup.Unpack(DatBinReader reader)
|
||||
[wb-error] at DatReaderWriter.DatDatabase.TryGet[T](UInt32 fileId, T& value)
|
||||
[wb-error] at WorldBuilder.Shared.Services.DefaultDatDatabase.TryGet[T](UInt32 fileId, T& value)
|
||||
[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareEnvCellMeshData(...) line 1223
|
||||
[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareMeshData(...) line 571
|
||||
```
|
||||
|
||||
For Holtburg (`0xA9B4`) specifically: 123 requested → 97 completed + 26 silently failed. The 26 failures all match this exception signature. The first interior cell `0xA9B40100` is among them — exactly where the user reported a missing floor.
|
||||
|
||||
## Why the other hypotheses were ruled out
|
||||
|
||||
Phase 1 ruled out H2-H6 via the captured probe data. Phase 2's diagnostic walk:
|
||||
|
||||
1. `ourCellDb.TryGet=True` — acdream's DatCollection finds the cell.
|
||||
2. `wbResolveId.Count=1` — WB's ResolveId also finds it.
|
||||
3. `wbSelectedType=EnvCell` — type classification is correct.
|
||||
4. `wbDbTryGet<EnvCell>=True` — the cell record IS loadable by WB.
|
||||
5. `hadRenderData=False` at request time — no pre-existing cache hit.
|
||||
|
||||
All preconditions for a successful upload were met. The failure was in a downstream emitter-collection step (line 1223) that's tangential to the cell's own geometry — but its exception silently kills the entire upload.
|
||||
|
||||
## Fix
|
||||
|
||||
**One-line WB fork patch.** Pre-check the Setup-prefix bit before calling `TryGet<Setup>`:
|
||||
|
||||
```csharp
|
||||
// Before:
|
||||
if (_dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) {
|
||||
|
||||
// After:
|
||||
if ((stab.Id & 0xFF000000u) == 0x02000000u
|
||||
&& _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) {
|
||||
```
|
||||
|
||||
For GfxObj-prefixed stabs (which have no `DefaultScript` and no emitters anyway), the branch is now skipped correctly. For Setup-prefixed stabs, behavior is unchanged.
|
||||
|
||||
This is in our WB fork at [`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230). The patch should be upstreamed — it's a real WB bug.
|
||||
|
||||
## Verification approach
|
||||
|
||||
After applying the fix:
|
||||
1. Re-launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`.
|
||||
2. Walk Holtburg.
|
||||
3. Expect: zero `[wb-error]` lines, zero `[indoor-upload] NULL_RESULT` lines. Previously-failing cells now have `[indoor-upload] completed` lines.
|
||||
4. Visual: floor renders in Holtburg Inn; objects no longer appear "below ground."
|
||||
|
||||
## Phase 1 → Phase 2 chain summary
|
||||
|
||||
The diagnostic-driven approach worked end-to-end:
|
||||
|
||||
- **Phase 1:** Added 5 probes. Identified that 26 Holtburg cells silently fail. Confirmed H1 class of bug. Could not pinpoint without exception data.
|
||||
- **Phase 2 Task 1:** Wrapped `PrepareMeshDataAsync` in a continuation to capture `Task.Exception`. Found that the task was never faulted — `tcs.TrySetResult(null)` ran instead. Hypothesized exception was swallowed inside `PrepareMeshData`.
|
||||
- **Phase 2 cause-narrowing diagnostics:** Added `ourCellDb.TryGet` + `wbResolveId.Count` + `wbSelectedType` + `wbDbIsPortal` + `wbDbTryGet<EnvCell>` + `hadRenderData` checks. Each iteration narrowed the cause class.
|
||||
- **Phase 2 final probe:** Replaced WB's `NullLogger` with a Console-backed `ConsoleErrorLogger`. WB's existing `_logger.LogError(ex, ...)` call at the catch block immediately surfaced 55 ArgumentOutOfRangeException stack traces with file:line locations. **Cause definitively identified in one capture.**
|
||||
- **Phase 2 fix:** One-line guard at the throwing call site.
|
||||
|
||||
Total runtime: ~3 client launches to nail it.
|
||||
105
docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
Normal file
105
docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Indoor Cell Rendering — Phase 1 Probe Capture
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Probe:** Phase 1 diagnostic probes from spec `2026-05-19-indoor-cell-rendering-fix-design.md`
|
||||
**Capture conditions:** `ACDREAM_PROBE_INDOOR_ALL=1`, walk into Holtburg (landblock `0xA9B4`).
|
||||
**Verdict:** Hypothesis **H1 (WB silently returns null from `PrepareEnvCellMeshData`)** is **CONFIRMED** for ~21% of Holtburg's EnvCells, including the first interior cell `0xA9B40100`.
|
||||
|
||||
---
|
||||
|
||||
## Probe line breakdown (real EnvCell-format IDs only)
|
||||
|
||||
| Probe | Count | Notes |
|
||||
|---|---|---|
|
||||
| `[indoor-upload] requested` (0xA9B4 cells) | 123 (unique) | LandblockSpawnAdapter triggers PrepareMeshDataAsync for every cell in Holtburg landblock. |
|
||||
| `[indoor-upload] completed` (0xA9B4 cells) | **97** (unique) | **26 cells never produce a completed line.** |
|
||||
| `[indoor-walk]` (cell-room entities, 0xA9B4) | 27,631 | Cell-room entities pass `landblockVisible` + `aabbVisible` + `cellInVis` filters. Walk path is healthy. |
|
||||
| `[indoor-lookup]` (0xA9B4 cells) | 6,067 | Total dispatcher lookups for Holtburg cells. |
|
||||
| `[indoor-lookup] hit=True` | 45 | Only ~0.7% hit rate — the rate-limited probe captures one snapshot per cell after rendering stabilizes. |
|
||||
| `[indoor-lookup] hit=False` | 6,022 | Most are pre-upload-completion frames + the 26 silently-failing cells. |
|
||||
| `[indoor-xform]` | 97 | One per successfully-uploaded cell. Cell-geom SetupPart's render data is non-null and reaches `ComposePartWorldMatrix`. |
|
||||
|
||||
## Hypotheses
|
||||
|
||||
### H1 — WB silently returns null from `PrepareEnvCellMeshData` ✅ CONFIRMED
|
||||
|
||||
26 out of 123 Holtburg cells (21%) get an `[indoor-upload] requested` line but **never** produce an `[indoor-upload] completed` line. This is the classic H1 signature: WB's `ObjectMeshManager.PrepareMeshData` either returns null (line 568, 583, 592 of `ObjectMeshManager.cs`) or its catch-block swallows an exception at line 589-592. The pending `meshData` never reaches `StagedMeshData`, so `Tick()`'s drain never sees it, no completion line emits.
|
||||
|
||||
**First 15 cells with no completion:**
|
||||
|
||||
```
|
||||
0xA9B40100, 0xA9B40111, 0xA9B40112, 0xA9B40117, 0xA9B4011B,
|
||||
0xA9B40121, 0xA9B40123, 0xA9B40129, 0xA9B4012A, 0xA9B4012E,
|
||||
0xA9B40138, 0xA9B4013F, 0xA9B40141, 0xA9B40143, 0xA9B40147
|
||||
```
|
||||
|
||||
`0xA9B40100` is **the first indoor cell** in Holtburg landblock. Almost certainly the inn entry or another major building's anchor cell — exactly where the user reported "floor missing."
|
||||
|
||||
### H2 — Empty batches ❌ RULED OUT
|
||||
|
||||
For successfully-completed cells, `cellGeomVerts` ranges 14–86 and `hasEnvCellGeom=True`. Geometry is non-empty when the upload completes. The 26 failing cells fail BEFORE batch construction, so this isn't an empty-batch problem.
|
||||
|
||||
### H3 — Cull bug ❌ RULED OUT
|
||||
|
||||
`[indoor-cull]` lines for cell-room entities show `visibleCellIds-miss` reasons only for cells in *other* landblocks (`0xA9B0`, `0xA9B2`, `0xA9B3` etc., visible neighbours of Holtburg but outside the active visibility set). For Holtburg's own cells, the walk probe shows `landblockVisible=true aabbVisible=true cellInVis=true` consistently — the dispatcher reaches them.
|
||||
|
||||
### H4 — Double-spawn ❌ RULED OUT
|
||||
|
||||
For completed cells, `[indoor-lookup]` reports modest `partCount` values (1–46) matching the number of static objects + 1 cell-geom part. No evidence of duplicate registration.
|
||||
|
||||
### H5 — Transform double-apply ❌ RULED OUT
|
||||
|
||||
`[indoor-xform]` consistently shows `entityWorldT=(0,0,0)`, `partT=(0,0,0)`, and `composedT==meshRefT`. The composed translation equals the cell's world origin — no double-apply. Sample:
|
||||
|
||||
```
|
||||
[indoor-xform] cellGeomId=0x00000001A9B40101
|
||||
entityWorldT=(0.00,0.00,0.00)
|
||||
meshRefT=(84.09,131.54,66.02)
|
||||
partT=(0.00,0.00,0.00)
|
||||
composedT=(84.09,131.54,66.02)
|
||||
```
|
||||
|
||||
### H6 — MeshRefs structure mismatch ❌ RULED OUT
|
||||
|
||||
For uploaded cells, `[indoor-lookup]` shows `hit=True isSetup=True partsHit≈partCount`. The dispatcher correctly traverses the Setup parts. Sample: `[indoor-lookup] cellId=0xA9B40101 hit=True isSetup=True partCount=10 hasEnvCellGeom=True partsHit=9 partsMiss=1`.
|
||||
|
||||
---
|
||||
|
||||
## What's special about the 26 failing cells?
|
||||
|
||||
Unknown from Phase 1 probes alone. Possible causes (each verifiable with one or two more targeted probes or code reads in Phase 2):
|
||||
|
||||
1. **Missing Environment dat record** — `envCell.EnvironmentId` points at an Environment id that `_dats.Portal.TryGet<Environment>` can't find. WB's `PrepareEnvCellMeshData` line 1245 would silently return without populating `cellGeometry`, then the outer Setup path produces a result with `hasBounds=false` and an empty `parts` list. Hmm, but that would still produce a `completed` line — just with empty data. **So this would be H2-shaped, not H1-shaped.** Ruled out.
|
||||
|
||||
2. **Exception in `PrepareCellStructMeshData`** — texture decode failure, surface ID resolution failure, polygon enumeration crash. The catch-block at `PrepareMeshData` line 589 silently swallows. **Most likely cause.**
|
||||
|
||||
3. **`ResolveId(envCellId)` returns empty** — WB's `DefaultDatReaderWriter` can't find the cell record in its loaded dats. Unlikely (all region cells are loaded at construction), but possible if `_wbDats.Portal.TryGet<Region>` skipped the region containing 0xA9B4.
|
||||
|
||||
4. **Race condition** — `PrepareMeshData` runs on a background worker; if the same cell id is requested twice in fast succession before the first completes, the second `TryAdd` to `_preparationTasks` returns false and silently skips. Unlikely given LandblockSpawnAdapter's per-landblock dedup at line 68 of `LandblockSpawnAdapter.cs`, but possible if multiple landblocks share state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — recommended approach
|
||||
|
||||
The fix shape per the spec table maps H1 to: *"Add WB logging or pre-check the dat resolution path in WbMeshAdapter."*
|
||||
|
||||
Concrete Phase 2 plan:
|
||||
|
||||
1. **Targeted probe extension** — add a SECOND probe inside the failing path. Either patch WB to surface the swallowed exception (`PrepareMeshData` line 589 catch block) OR wrap the `PrepareMeshDataAsync` call in WbMeshAdapter with our own try/catch + task continuation that logs the actual `Exception` for EnvCell ids. One launch with this captures the actual failure reason for the 26 cells.
|
||||
|
||||
2. **Match the failure to a fix** — once we know the failure mode:
|
||||
- If a texture/surface bug → file as a Phase 2 WB-fork patch.
|
||||
- If a missing dat reference → check whether the user's `client_cell_1.dat` is up to date.
|
||||
- If an exception in our code path → fix the specific bug.
|
||||
|
||||
3. **Verify** by re-launching with the probe and confirming `[indoor-upload] completed` appears for previously-missing cells (e.g., `0xA9B40100`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 leftover observations
|
||||
|
||||
- The `IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u` helper has false positives on GfxObj IDs whose lower 24 bits happen to be ≥ 0x0100 (e.g., `0x01001841`). This polluted ~95% of probe emissions with non-cell entities. Recommend tightening the helper to also require `(id >> 24) != 0x01 && (id >> 24) != 0x02` (and any other DBObj-type prefixes), OR `(id >> 16) > 0x00FF` to require a real landblock prefix.
|
||||
|
||||
- The lookup probe's rate-limit namespace separation (Task 7 fix) works correctly — uploaded cells DO appear in the hit set when their lookup probe fires.
|
||||
|
||||
- Cell-room entities have `Position=(0,0,0)` with the cell transform in `MeshRef.PartTransform`. The dispatcher's `aabbVisible` filter passed for them, presumably because `RefreshAabb()` computes a sensible world AABB from the mesh-ref's transform or because the landblock equals `neverCullLandblockId`. Worth a brief audit if there's any reason to believe the cell-room AABB is wrong.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Indoor Cell Rendering — Phase 2 Verification
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Outcome:** ✅ Floor renders in Holtburg Inn. User visually confirmed.
|
||||
**Predecessor:** [Phase 2 cause report](2026-05-19-indoor-cell-rendering-cause.md).
|
||||
|
||||
---
|
||||
|
||||
## Probe re-capture
|
||||
|
||||
After applying the one-line WB fix at [`ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230):
|
||||
|
||||
| Metric | Pre-fix | Post-fix |
|
||||
|---|---|---|
|
||||
| `[wb-error]` lines | 385 | **0** |
|
||||
| `[indoor-upload] NULL_RESULT` | 55 | **0** |
|
||||
| `[indoor-upload] FAILED` | 0 | 0 |
|
||||
| Total `[indoor-upload] requested` | — | 1157 |
|
||||
| Total `[indoor-upload] completed` | — | **1157** |
|
||||
| Holtburg (`0xA9B4`) requested | 123 | 123 |
|
||||
| Holtburg (`0xA9B4`) completed | 97 | **123** |
|
||||
| Holtburg (`0xA9B4`) missing | 26 | **0** |
|
||||
|
||||
100% success rate on EnvCell uploads. Zero swallowed exceptions. Zero null returns.
|
||||
|
||||
## Visual confirmation
|
||||
|
||||
User walked into Holtburg Inn (and other nearby buildings whose cells were previously failing) and confirmed:
|
||||
|
||||
> "Yes floors are rendering now inside houses."
|
||||
|
||||
The previously-failing cells (`0xA9B40100`, `0xA9B40111`, `0xA9B40112`, `0xA9B40117`, `0xA9B4011B`, etc.) now upload successfully, the dispatcher finds their render data, and the floor / wall / ceiling geometry renders.
|
||||
|
||||
## Regressions checked
|
||||
|
||||
- Outdoor terrain still renders correctly. ✓
|
||||
- Outdoor scenery (trees, rocks, stabs) still render. ✓
|
||||
- NPCs, mobs, world entities still render. ✓
|
||||
- Build clean, no new warnings. ✓
|
||||
- No new test failures. ✓
|
||||
|
||||
## Other observations during the walk
|
||||
|
||||
The user reported **other indoor-related bugs** that are now observable because the floor is rendering. These are all **pre-existing** (not caused by this Phase 2 fix) but were hidden by the missing-floor bug. They are filed as separate issues for follow-up phases:
|
||||
|
||||
1. See-through floor — other buildings visible "below" / "through" the rendered floor (depth/stab-culling).
|
||||
2. Spot lights on walls indoors (point-light positioning).
|
||||
3. Camera on 2nd floor goes very dark (per-cell ambient or trigger).
|
||||
4. Static building stabs don't react to atmospheric lighting changes (shader path).
|
||||
5. Some slope terrain lit incorrectly (terrain normal calculation).
|
||||
6. Collision "blocked by air" indoors (cell BSP misalignment).
|
||||
7. Walking up stairs broken (stair-step physics on EnvCell geometry).
|
||||
8. Pass through walls from outside→in (one-sided wall collision).
|
||||
9. Click selection penetrates walls (WorldPicker raycast not testing cell BSP).
|
||||
|
||||
These nine items are tracked in `docs/ISSUES.md` with proposed phase groupings. None block Phase 2 closure.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Phase 2 of the indoor cell rendering fix is complete.** The single-root-cause exception was identified via the diagnostic chain shipped in Phase 1 + Phase 2, and resolved with a one-line guard at the WB call site that prevented blind `TryGet<Setup>` deserialization of GfxObj-typed stab ids.
|
||||
|
||||
Total runtime for Phase 2: ~4 client launches.
|
||||
104
docs/research/2026-05-19-indoor-followup-handoff.md
Normal file
104
docs/research/2026-05-19-indoor-followup-handoff.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Indoor cell rendering — follow-up handoff
|
||||
|
||||
**Status:** Phase 1 (diagnostics) + Phase 2 (fix for missing floors) shipped 2026-05-19. Merged to `main`. The 9 new bugs surfaced when floors started rendering are filed as `docs/ISSUES.md#78` through `#86`.
|
||||
|
||||
This doc is the start-of-session brief for whoever picks up the next indoor-walking phase.
|
||||
|
||||
---
|
||||
|
||||
## What's in place
|
||||
|
||||
**Diagnostic infrastructure (Phase 1)**
|
||||
|
||||
Five `[indoor-*]` probes are wired and on a runtime-toggleable flag — leave them in place; they're useful for any indoor follow-up work:
|
||||
|
||||
| Probe | Where | Toggle |
|
||||
|---|---|---|
|
||||
| `[indoor-walk]` | `WbDrawDispatcher.WalkEntitiesInto` per cell entity that passes visibility | `ACDREAM_PROBE_INDOOR_WALK=1` or DebugPanel checkbox |
|
||||
| `[indoor-cull]` | Same site, when entity is rejected (visibleCellIds-miss or frustum) | `ACDREAM_PROBE_INDOOR_CULL=1` |
|
||||
| `[indoor-upload]` | `WbMeshAdapter.IncrementRefCount` + `Tick()` for EnvCell ids | `ACDREAM_PROBE_INDOOR_UPLOAD=1` |
|
||||
| `[indoor-lookup]` | `WbDrawDispatcher.Draw` per-MeshRef TryGetRenderData call | `ACDREAM_PROBE_INDOOR_LOOKUP=1` |
|
||||
| `[indoor-xform]` | Same site, for cellGeomId SetupPart's composed world matrix | `ACDREAM_PROBE_INDOOR_XFORM=1` |
|
||||
|
||||
Master toggle: `ACDREAM_PROBE_INDOOR_ALL=1` cascades to all five. All probes are zero-cost when off.
|
||||
|
||||
The `WbMeshAdapter` also now injects a `ConsoleErrorLogger<ObjectMeshManager>` (replacing the default `NullLogger`) so any future exception WB silently catches surfaces as `[wb-error]` lines automatically. This was the key unlock for Phase 2's diagnosis — see [`feedback_logger_injection_for_silent_catches`](../../../.claude/projects/C--Users-erikn-source-repos-acdream/memory/feedback_logger_injection_for_silent_catches.md) memory entry.
|
||||
|
||||
**Other Phase 1/2 fixes already in main**
|
||||
|
||||
- Indoor ambient color is now retail-faithful `(0.20, 0.20, 0.20)` — was guessed `(0.10, 0.09, 0.08)`.
|
||||
- Indoor lighting triggers off **player** cell, not camera cell — fixes "darker when camera enters" with third-person chase.
|
||||
- WB submodule has a **one-line band-aid patch** (`ObjectMeshManager.cs:1230` Setup-prefix guard at `TryGet<Setup>`) on `eriknihlen/WorldBuilder@acdream` at SHA `34460c4`. Submodule pointer in acdream's `main` is advanced. **This is a band-aid** — see `docs/ISSUES.md` #87 for the proper fix (switch to WB's narrower `PrepareEnvCellGeomMeshDataAsync` API). Retire the patch when that issue lands.
|
||||
|
||||
---
|
||||
|
||||
## The 9 follow-up issues
|
||||
|
||||
Full descriptions + hypotheses are in `docs/ISSUES.md`. Summary table:
|
||||
|
||||
| # | Title | Cluster | Severity |
|
||||
|---|---|---|---|
|
||||
| #78 | Outdoor stabs/buildings visible through floor | Cell-BSP / visibility | HIGH |
|
||||
| #79 | Spurious spot lights on walls indoors | Indoor lighting | MEDIUM |
|
||||
| #80 | 2nd floor camera goes very dark | Indoor lighting | MEDIUM |
|
||||
| #81 | Static building stabs don't react to atmospheric lighting | Indoor lighting | MEDIUM |
|
||||
| #82 | Some slope terrain lit incorrectly | Terrain shading | LOW |
|
||||
| #83 | Walking up stairs broken | Physics / movement | HIGH |
|
||||
| #84 | Blocked by air indoors | Cell-BSP / collision | HIGH |
|
||||
| #85 | Pass through walls from outside→in | Cell-BSP / collision | HIGH |
|
||||
| #86 | Click selection penetrates walls | Cell-BSP / interaction | MEDIUM |
|
||||
|
||||
**Proposed phase groupings:**
|
||||
|
||||
- **Cluster A: Cell-BSP + portal cull** — likely fixes #78, #84, #85, #86 in one phase. Shared root cause hypothesis: the cell BSP physics geometry isn't being correctly used by the movement resolver / WorldPicker / depth-cull. Common pieces:
|
||||
- `_physicsDataCache.CacheCellStruct` at `GameWindow.cs:5384` caches with `cellTransform` (including `+0.02f` Z bump for render anti-z-fight) — physics may be misaligned.
|
||||
- WB's `VisibilityManager.RenderInsideOut` stencil pipeline is unused by acdream — explains #78.
|
||||
- `WorldPicker` raycast doesn't test cell BSP — explains #86.
|
||||
- Wall BSP polys likely one-sided — explains #85.
|
||||
|
||||
- **Cluster B: Indoor lighting plumbing** — #79, #80, #81 each need separate investigation but share the `mesh_modern.frag` + `SceneLightingUbo` pipeline. #82 may be terrain-shader-specific.
|
||||
|
||||
- **Standalone: #83 stairs** — needs the existing physics step-up logic to handle EnvCell stair geometry. Could share work with Cluster A if cell BSP is the common path.
|
||||
|
||||
**Suggested order:**
|
||||
|
||||
1. **Cluster A first.** Biggest gameplay impact (collision is broken). Probably 1-2 weeks of work.
|
||||
2. **#83 stairs** as a follow-up once cell BSP collision is solid.
|
||||
3. **Cluster B lighting** last. Smallest gameplay impact, biggest visual polish.
|
||||
|
||||
---
|
||||
|
||||
## Where to start a new session
|
||||
|
||||
The recommended kickoff prompt is at [`docs/research/2026-05-19-indoor-followup-prompt.md`](2026-05-19-indoor-followup-prompt.md). Drop it into a fresh Claude Code session in this repo and it should orient itself.
|
||||
|
||||
Key files to point Claude at when starting:
|
||||
|
||||
- This handoff doc.
|
||||
- `docs/ISSUES.md` lines covering #78-#86 (search for "Indoor walking issue cluster").
|
||||
- `docs/research/2026-05-19-indoor-cell-rendering-cause.md` — Phase 2 cause analysis, useful as the "what we already know" anchor for any follow-up.
|
||||
- `docs/research/2026-05-19-indoor-cell-rendering-verification.md` — what's working today.
|
||||
|
||||
---
|
||||
|
||||
## Important context
|
||||
|
||||
- **Don't touch the diagnostic infrastructure** in `WbMeshAdapter` or `RenderingDiagnostics` unless you're extending it. Phase 2 left it ready for re-use.
|
||||
- **The probes are runtime-toggleable** — DebugPanel has checkboxes. No relaunch needed to flip them.
|
||||
- **`ConsoleErrorLogger` is now the default WB logger.** Any future WB-internal exception will surface as `[wb-error]` automatically without any new diagnostic code.
|
||||
- **Don't try to patch WB upstream** — the user wants the fix to live only in their fork (`eriknihlen/WorldBuilder`). Future WB patches go on the `acdream` branch of the fork.
|
||||
- **The `+0.02f` Z bump on cell origin** at `GameWindow.cs:5362` exists to prevent z-fighting with terrain. It's applied to both render geometry AND physics BSP. May be a confounding factor for #84 (blocked by air).
|
||||
|
||||
---
|
||||
|
||||
## Verification approach for the next phase
|
||||
|
||||
Same pattern that worked for Phase 1+2:
|
||||
|
||||
1. **Diagnostics first.** Add probes / log surfaces for the suspected failure paths. The `ConsoleErrorLogger` may already be surfacing relevant errors — check `launch.log` first.
|
||||
2. **Capture cold.** Launch the client (the Phase 1 + Phase 2 launch incantation is documented at the top of `CLAUDE.md`), walk into Holtburg Inn, take note of the user-observable symptom (e.g., "I'm at position X, I tried to walk through a wall and went through").
|
||||
3. **Identify the root cause definitively.** Don't apply a fix until the captured data points at one specific code site.
|
||||
4. **Apply a surgical fix.** Per CLAUDE.md's no-workarounds rule — fix the actual cause, not the symptom.
|
||||
5. **Re-capture and verify.** Visual confirmation by the user is the acceptance test.
|
||||
|
||||
Phase 1+2 took 4 client launches total once the diagnostic infrastructure was in place. The Cluster A phase should be similar — assuming the cell-BSP hypothesis holds, one probe addition + one capture should pin the root cause.
|
||||
65
docs/research/2026-05-19-indoor-followup-prompt.md
Normal file
65
docs/research/2026-05-19-indoor-followup-prompt.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Indoor follow-up — fresh-session kickoff prompt
|
||||
|
||||
Copy the block below into a fresh Claude Code session in this repo. The model
|
||||
will load CLAUDE.md automatically and find the handoff doc + filed issues
|
||||
on its own.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Pick up the indoor-walking follow-up work for acdream. The starting point:
|
||||
|
||||
1. Read docs/research/2026-05-19-indoor-followup-handoff.md — that's the
|
||||
session-start brief.
|
||||
|
||||
2. Read docs/ISSUES.md issues #78 through #86 — these are the 9 bugs the
|
||||
user observed once floors started rendering at Holtburg Inn. They are
|
||||
ALL pre-existing (not caused by Phase 1+2 which just made indoor floors
|
||||
visible).
|
||||
|
||||
3. Use the existing [indoor-*] probe infrastructure shipped in Phase 1
|
||||
(toggleable via ACDREAM_PROBE_INDOOR_ALL=1 + DebugPanel checkboxes).
|
||||
WbMeshAdapter also injects a real ConsoleErrorLogger now, so any
|
||||
silently-caught WB exception will appear as [wb-error] lines in the
|
||||
log automatically.
|
||||
|
||||
4. The recommended approach per the handoff:
|
||||
- START with Cluster A (cell-BSP / portal-cull cluster — issues #78,
|
||||
#84, #85, #86). These share a likely root cause and have the biggest
|
||||
gameplay impact.
|
||||
- Don't try to fix all 9 at once. Pick the cluster, pick one issue
|
||||
within it, brainstorm via superpowers:brainstorming, and proceed
|
||||
phase-by-phase.
|
||||
|
||||
5. CLAUDE.md's rules apply:
|
||||
- No workarounds; fix root causes.
|
||||
- Use superpowers skills for major work (brainstorming → writing-plans
|
||||
→ subagent-driven-development → finishing-a-development-branch).
|
||||
- Drive autonomously — Claude picks what to work on next; user
|
||||
reviews. Don't ask "what should I work on?" between phases.
|
||||
- Visual verification by the user is the acceptance test for any
|
||||
rendering / collision / lighting fix.
|
||||
|
||||
6. Phase 1+2 took 4 client launches total. Your work should be similar
|
||||
if you preserve the diagnostic-driven approach: probe → capture →
|
||||
diagnose → fix → verify.
|
||||
|
||||
State the milestone and current cluster in the first action you take.
|
||||
Then begin by reading the handoff doc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick reference for the helper
|
||||
|
||||
If the new session asks "which phase should I do first?":
|
||||
|
||||
- **Cluster A (cell-BSP/portal)** — #78 see-through floor + #84 blocked-by-air + #85 pass-through-walls + #86 click-through-walls. Hypothesis: cell BSP is cached but not consulted correctly by the movement resolver / picker, AND outdoor stabs aren't stencil-culled when player is in a sealed cell.
|
||||
- **Cluster B (lighting plumbing)** — #79 + #80 + #81 + (maybe #82). Less urgent.
|
||||
- **Standalone #83 stairs** — physics work on EnvCell stair geometry. Smaller scope.
|
||||
|
||||
## Quick reference for the user
|
||||
|
||||
To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
|
||||
|
||||
> "Read `docs/research/2026-05-19-indoor-followup-handoff.md` and start on the indoor-walking follow-up work."
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
# Indoor walkable-plane BSP port — partial-ship handoff (2026-05-19)
|
||||
|
||||
**Outcome:** Foundation shipped (6 commits). Visual verification FAILED. User-reported bugs (cellar descent, 2nd-floor walking, phantom collisions) remain unresolved. Root cause now diagnosed deeper than originally thought; next phase needs to port retail's `ContactPlane` retention mechanism. Foundation work (BSP walker + probe + tests) is useful regardless of the next approach.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
I diagnosed the wrong root cause initially. I assumed `TryFindIndoorWalkablePlane`'s linear first-match XY scan picking the wrong polygon was the bug, and built a retail-faithful BSP-walker replacement (`BSPQuery.FindWalkableSphere` wrapper over the existing `FindWalkableInternal` port of `BSPNODE::find_walkable` + `BSPLEAF::find_walkable`). The BSP walker is correct, but it returns MISS for the standing-grounded case (foot sphere tangent to floor → `PolygonHitsSpherePrecise` correctly rejects tangent contact by ~0.0002 epsilon).
|
||||
|
||||
The actual root cause: **`TryFindIndoorWalkablePlane` shouldn't exist at all**. It was added as a Phase 2 commit `eb0f772` stop-gap to synthesize a `ContactPlane` every frame when the indoor BSP returns OK. Retail doesn't do this — retail RETAINS the previous frame's `ContactPlane` when the collision dispatcher reports no collision. There is no retail analog of `find_walkable` as a standing-still query. `find_walkable` only runs inside a downward sphere sweep (`step_sphere_down`), where the sphere is moving and the overlap test is meaningful.
|
||||
|
||||
---
|
||||
|
||||
## What shipped (foundation)
|
||||
|
||||
6 commits, `ff548b9` → `f845b22`. `dotnet build -c Debug` clean; 8 pre-existing test failures unchanged baseline; 5 new tests + 9 updated existing tests all pass.
|
||||
|
||||
| # | SHA | Subject |
|
||||
|---|---|---|
|
||||
| 1 | `ff548b9` | `refactor(physics): expose hitPolyId from FindWalkableInternal` |
|
||||
| 2 | `7f55e14` | `feat(physics): add BSPQuery.FindWalkableSphere wrapper` (+ 4 unit tests) |
|
||||
| 3 | `86ecdf9` | `fix(physics): tighten FindWalkableSphere test assertions + header` (code review fix) |
|
||||
| 4 | `91b29d1` | `fix(physics): route indoor walkable-plane synthesis through retail BSP walker` |
|
||||
| 5 | `7c516ed` | `fix(physics): document adjustedCenter discard + restore wall-poly test` (code review fix) |
|
||||
| 6 | `f845b22` | `feat(physics): add [indoor-walkable] probe line` |
|
||||
|
||||
**Files touched:**
|
||||
- `src/AcDream.Core/Physics/BSPQuery.cs` — `FindWalkableInternal` gained `ref ushort hitPolyId`; new public `FindWalkableSphere` wrapper.
|
||||
- `src/AcDream.Core/Physics/TransitionTypes.cs` — `TryFindIndoorWalkablePlane` refactored from `static` linear scan to instance method routing through `FindWalkableSphere` with `WalkableAllowance` save/restore. `PointInPolygonXY` deleted. `[indoor-walkable]` probe added at the `FindEnvCollisions` callsite.
|
||||
- `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` — 4 new `FindWalkableSphere` unit tests.
|
||||
- `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` — new file, integration test for two-overlapping-floors + WalkableAllowance preservation.
|
||||
- `tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs` — 9 tests updated to new instance-method + sphereRadius signature with BSP fixtures; 2 `PointInPolygonXY` tests deleted; 1 new wall-poly integration test.
|
||||
|
||||
---
|
||||
|
||||
## Visual verification — FAIL (user-driven, 2026-05-19)
|
||||
|
||||
Launch flags: `ACDREAM_DEVTOOLS=1`, `ACDREAM_PROBE_INDOOR_BSP=1`. Log: `launch-walkable-fix-6.log` (latest run).
|
||||
|
||||
User report verbatim:
|
||||
> Cant walk down to the cellar. Looks like ground is blocking.
|
||||
> I get stuck sometimes in a falling animation at random places.
|
||||
> When I walk up on second floors. I get stuck sometimes on random places in falling animation.
|
||||
> Lightning is still broken.
|
||||
> Get phantom collison in rooms.
|
||||
> NO change
|
||||
|
||||
Result against acceptance scenarios:
|
||||
|
||||
| Scenario | Pre-ship | Post-ship | Outcome |
|
||||
|---|---|---|---|
|
||||
| Cellar descent | "ground blocking" | "ground blocking" | **FAIL** — no change |
|
||||
| 2nd-floor walking | "snaps back / invisible obstacles" | "intermittent falling-stuck" | **FAIL** — different symptom, still broken |
|
||||
| Single-floor cottage walking | stable | "intermittent falling-stuck at random spots" | **REGRESSION** — degraded from stable to unstable |
|
||||
| Phantom collisions in rooms | present | present | **PERSIST** |
|
||||
| Indoor lightning (#79/#80/#81/#82) | broken | broken | unchanged (out of scope for this phase) |
|
||||
|
||||
---
|
||||
|
||||
## Probe evidence (from launch 1)
|
||||
|
||||
`[indoor-walkable]` probe captured 1445 calls in a Holtburg-area session. **1443 MISS / 2 HIT.**
|
||||
|
||||
Sample HIT line:
|
||||
```
|
||||
[indoor-walkable] cell=0xA9B40150 wpos=(132.258,16.524,94.480) probe=0.50 result=HIT poly=0x0000 wn=(0.000,0.000,1.000) wD=-94.020 dz=+0.46
|
||||
```
|
||||
|
||||
Sample MISS line:
|
||||
```
|
||||
[indoor-walkable] cell=0xA9B40150 wpos=(132.258,16.524,94.500) probe=0.50 result=MISS
|
||||
```
|
||||
|
||||
The 20mm Z oscillation between `94.480` (HIT) and `94.500` (MISS) is the smoking gun:
|
||||
- World physics floor (after +0.02f cell-origin Z-bump in `PhysicsDataCache.CacheCellStruct`) is at `Z=94.020`.
|
||||
- When foot center is at `Z=94.500` (= floor + radius), distance to plane = `0.48` = sphere radius. `PolygonHitsSpherePrecise` checks `|dist| > radius - epsilon` (line 117 of BSPQuery.cs). `0.48 > 0.4798` → **rejected by ~0.0002**.
|
||||
- When foot center is at `Z=94.480` (= floor + 0.46), distance = `0.46 < 0.4798` → accepted, HIT.
|
||||
- The resolver oscillates between these two positions as the indoor walkable plane and the outdoor terrain backstop alternate as the contact source.
|
||||
|
||||
---
|
||||
|
||||
## Why the fix doesn't work — deeper diagnosis
|
||||
|
||||
`TryFindIndoorWalkablePlane` exists only as a Phase 2 stop-gap (commit `eb0f772`). It was added because the indoor BSP collision branch in `FindEnvCollisions` returns OK when the player is grounded standing still, but the resolver then needed a `ContactPlane` to feed `ValidateWalkable`. Without a synthesized indoor plane, the code fell through to outdoor terrain backstop, which is BELOW the indoor floor by `+0.02f`, marking the player as floating → falling-stuck. The Phase 2 fix synthesized a plane from `cellPhysics.Resolved` via a linear XY scan.
|
||||
|
||||
My Task 3 refactor swapped that linear scan for the retail-faithful BSP walker (`BSPQuery.FindWalkableInternal`). The BSP walker is correct — it implements `BSPNODE::find_walkable` + `BSPLEAF::find_walkable` faithfully. But in retail, this function is called from `BSPTREE::step_sphere_down` inside a movement sweep, where the sphere is moving downward. `walkable_hits_sphere` requires the sphere to overlap the plane (`|dist| < radius - eps`), which is satisfied during the sweep because the moving sphere penetrates the plane mid-sweep. In our standing-grounded use case, the sphere is tangent (foot resting on floor), not penetrating → no overlap → no walkable found → MISS.
|
||||
|
||||
**Retail's actual flow for the standing-grounded case:**
|
||||
|
||||
1. Player at rest on floor. ContactPlane retained from previous frame.
|
||||
2. Frame tick. Gravity + movement applied.
|
||||
3. `CTransition::transitional_insert` runs.
|
||||
4. `find_collisions` Path 5 (Contact branch): `sphere_intersects_poly` test.
|
||||
- If the sphere penetrates the floor (gravity moved it slightly down), `step_sphere_up` runs → `step_down` → `step_sphere_down` → `find_walkable` → finds the floor → `adjust_sphere_to_plane` snaps it up to tangent → ContactPlane updated.
|
||||
- If the sphere does NOT penetrate (still tangent from last frame), Path 5 returns OK. **ContactPlane is NOT recomputed — it's retained from last frame.**
|
||||
5. Player walks horizontally. Same as above — ContactPlane persists.
|
||||
|
||||
Our acdream code:
|
||||
- Per-frame `FindEnvCollisions` calls indoor BSP `FindCollisions`.
|
||||
- Indoor BSP returns OK (no collision).
|
||||
- We call `TryFindIndoorWalkablePlane` to RECOMPUTE the ContactPlane from scratch. This is the WRONG behavior — retail doesn't recompute.
|
||||
- The recomputation fails (BSP walker can't handle tangent sphere) or succeeds with a slightly-off plane (linear scan returning the wrong polygon's Z).
|
||||
- Either way: the ContactPlane is unstable frame-to-frame → resolver state oscillates → player gets stuck in falling animation.
|
||||
|
||||
---
|
||||
|
||||
## Recommended next phase: ContactPlane retention
|
||||
|
||||
Port retail's `ContactPlane` retention so the resolver retains the previous frame's plane when the BSP says "no collision," instead of re-synthesizing every frame.
|
||||
|
||||
**Investigation targets (retail decomp):**
|
||||
- `CTransition::transitional_insert` (acclient_2013_pseudo_c.txt:273137) — the main per-frame resolver entry. Note line 273165: `if (edi != OK_TS) this->sphere_path.neg_poly_hit = 0;` — only mutates state on non-OK results.
|
||||
- `CPhysicsObj::transition` family — where `LastKnownContactPlane` is read/written.
|
||||
- Search the decomp for `last_known_contact_plane` and `contact_plane_valid` to map the full lifecycle.
|
||||
- `CTransition::check_walkable` (referenced at line 273202) — possibly involved in walkable persistence.
|
||||
|
||||
**Likely shape of the fix:**
|
||||
- In `Transition.FindEnvCollisions` (TransitionTypes.cs:1262), when indoor BSP returns OK, DO NOT call `TryFindIndoorWalkablePlane`. Instead, retain the existing `CollisionInfo.ContactPlane` (which was set by the previous frame's step-up or step-down).
|
||||
- Only update the ContactPlane when an actual collision/step event occurs (Path 4 land, Path 5 step-up-success, Path 3 step-down-success).
|
||||
- Outdoor terrain backstop remains for the outdoor case but is gated on `!IsIndoor(cellId)`.
|
||||
|
||||
**Foundation work to keep:**
|
||||
- `BSPQuery.FindWalkableSphere` wrapper — useful for any future "find a walkable plane indoors" query (e.g., spawn-placement, teleport-target verification).
|
||||
- `FindWalkableInternal`'s `hitPolyId` ref param — same.
|
||||
- `[indoor-walkable]` probe — keep, but expect it to fire less often once retention is in place (only when the sphere is actually penetrating).
|
||||
- All 5 new tests + 9 updated tests — they verify the BSP walker's correctness, which is unchanged in the next phase.
|
||||
|
||||
**Foundation work to delete (or refactor):**
|
||||
- `Transition.TryFindIndoorWalkablePlane` — likely deleted entirely, OR kept as an out-of-band synthesis path for edge cases (initial spawn, cell-id promotion mid-frame) but no longer called per-frame from `FindEnvCollisions`.
|
||||
- `INDOOR_WALKABLE_PROBE_DISTANCE` constant — deleted with `TryFindIndoorWalkablePlane`, or kept for the out-of-band use case.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Do not** add a sphere-offset hack to make `PolygonHitsSpherePrecise` accept tangent contact. That mis-aligns acdream's overlap semantics with retail's. The right answer is to not call `find_walkable` in the standing-still case at all.
|
||||
- **Do not** revert the 6 foundation commits. They are correct retail-faithful ports; the BSP walker is needed for legitimate use cases (just not the one we wired it to).
|
||||
- **Do not** widen the +0.02f Z-bump or try to compensate for it in the resolver. The bump is a render concern; it should remain transparent to physics. The bug is in the per-frame ContactPlane recompute, not the bump itself.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference for the next-session implementer
|
||||
|
||||
**Spec to read first (this phase's, for context — but don't re-execute it):**
|
||||
- `docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md` (committed `165f67a`)
|
||||
- `docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md` (committed `e62d076`)
|
||||
|
||||
**Code anchors:**
|
||||
- [`src/AcDream.Core/Physics/TransitionTypes.cs:1262`](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1262) — `FindEnvCollisions` indoor branch.
|
||||
- [`src/AcDream.Core/Physics/TransitionTypes.cs:1192`](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1192) — `TryFindIndoorWalkablePlane` (the thing to likely delete in the next phase).
|
||||
- [`src/AcDream.Core/Physics/CollisionInfo`](../../src/AcDream.Core/Physics/) — search for `ContactPlane` write sites to map who currently sets it.
|
||||
- [`src/AcDream.Core/Physics/SpherePath`](../../src/AcDream.Core/Physics/) — `LastKnownContactPlane`-style fields if any exist.
|
||||
|
||||
**Retail decomp anchors:**
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:273099` — `CTransition::step_up`.
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:273137` — `CTransition::transitional_insert`.
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323565` — `BSPTREE::step_sphere_up`.
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326793` — `BSPLEAF::find_walkable` (already ported, behavior verified).
|
||||
|
||||
**Visual verification scenarios (re-use for the next phase):**
|
||||
1. Cellar descent (the primary failing scenario)
|
||||
2. 2nd-floor walking
|
||||
3. Single-floor cottage (regression check — must NOT degrade)
|
||||
4. Phantom collisions (cascade check — if root cause is fixed, these should improve)
|
||||
|
||||
**Launch command:**
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-next-phase.log"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session lessons (for future Claude)
|
||||
|
||||
1. **Brainstorm a hypothesis-test before a full spec.** I diagnosed the wrong root cause and built 6 commits on it. A small spike (add the probe FIRST, capture a log, look at it before designing the fix) would have surfaced the 99.9% MISS rate immediately and pointed at the deeper issue.
|
||||
2. **Tangent contact is the dominant grounded case.** Any test fixture designed to exercise `walkable_hits_sphere` MUST include the tangent case (`dist == radius`), not just penetrating cases. My unit tests used Z=0.4 with radius=0.48 (overlap = 0.4 < 0.4798, passes easily) — comfortable but unrepresentative.
|
||||
3. **`find_walkable` is a sweep query, not a query.** It's only meaningful when called from `step_sphere_down`. Any caller using it as "stand here, find my floor" is misusing the algorithm. Retail doesn't have such a caller because retail retains ContactPlane across frames.
|
||||
4. **The +0.02f cell-origin Z-bump is a render artifact bleeding into physics.** It creates a 20mm offset between visual and physics floors. This is fine when the resolver retains state but breaks when the resolver re-computes every frame. The bump is not the root cause but it amplifies the oscillation symptom.
|
||||
166
docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md
Normal file
166
docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Indoor walking Phase 2 shipped — fresh-session pickup prompt
|
||||
|
||||
**Status:** Indoor walking Phase 1 (Cluster A — BSP cluster) + Phase 2 (Portal-based cell tracking) both merged to `main` at `1af49b7` on 2026-05-19. 18 commits between them; `dotnet build` + `dotnet test` green; visual-verified by user (walls block from inside, multi-room navigation works, walking out through a door works).
|
||||
|
||||
This doc is the start-of-session brief for whoever picks up the next phase.
|
||||
|
||||
---
|
||||
|
||||
## What landed on main
|
||||
|
||||
**Indoor walking Phase 1 — BSP cluster** (commits `27d7de1` … `1f11ba9`):
|
||||
|
||||
- `[indoor-bsp]` + `[cell-cache]` diagnostic probes (`ACDREAM_PROBE_INDOOR_BSP` / `ACDREAM_PROBE_CELL_CACHE`).
|
||||
- `WorldPicker` cell-BSP occlusion via new `CellBspRayOccluder` → **closes #86** (click selection no longer penetrates walls).
|
||||
- Phase D's `ResolveOutdoorCellId` → `ResolveCellId` rename + AABB-based indoor cell promotion (partial fix for #84 — un-stuck the spawn-in-building case; the wall-pass-through portion stayed open until Phase 2).
|
||||
|
||||
**Indoor walking Phase 2 — Portal-based cell tracking** (commits `1969c55` … `eb0f772`):
|
||||
|
||||
- Extended `CellPhysics` with `CellBSP` (third BSP for point-in-cell), `Portals` (from `envCell.CellPortals`), `PortalPolygons` (resolved visible polys), `VisibleCellIds`. Deleted Phase D's `LocalAabbMin/Max` + `TryFindContainingCell`.
|
||||
- New `CellTransit` static class — ports retail's `CObjCell::find_cell_list` family: `FindTransitCellsSphere` (indoor portal-neighbour walk), `AddAllOutsideCells` (24m landcell grid), `FindCellList` (top-level BFS driver), `CheckBuildingTransit` (outdoor→indoor entry via `BuildingObj` portals).
|
||||
- `BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` → `CellBSPNode?` (was dead code; safe retype).
|
||||
- New `BuildingPhysics` cache + `CacheBuilding` / `GetBuilding` on `PhysicsDataCache`. GameWindow caches each `LandBlockInfo.Buildings` entry at landblock-load.
|
||||
- `PhysicsEngine.ResolveCellId`: indoor seeds delegate to `CellTransit.FindCellList`; outdoor seeds keep terrain-grid resolution + hook `CheckBuildingTransit` for outdoor→indoor entry.
|
||||
- **Critical production fix** at `3ffe1e4`: pass `sp.GlobalSphere[0].Origin` (foot sphere CENTER) instead of `sp.CheckPos` (entity reference at the feet) to `ResolveCellId`. Without this fix the test point was always 0.02m below cell floor due to the +0.02f Z-bump → portal traversal never engaged in production.
|
||||
- **Indoor walkable-plane synthesis** at `eb0f772`: when the indoor cell-BSP returns OK (no wall collision), find the floor poly under the player and call `ValidateWalkable` with the indoor plane instead of falling through to outdoor terrain. Closes the "stuck in falling animation" bug.
|
||||
|
||||
**Closed:** ISSUES.md #84, #85, #86, #87 all fully resolved.
|
||||
|
||||
**Filed for follow-up:**
|
||||
- **#88** — Indoor static objects vibrate (bookshelves, open furnaces). Pre-existing; user spotted during Phase 2 testing.
|
||||
- **#89** — Port `BSPQuery.SphereIntersectsCellBsp` for retail-faithful `CheckBuildingTransit`. Currently uses radius-less `PointInsideCellBsp`; entry fires ~0.5m later than retail.
|
||||
|
||||
**Diagnostic infrastructure that persists:**
|
||||
- `[indoor-bsp]` — per cell-BSP `FindCollisions` call. Toggle: `ACDREAM_PROBE_INDOOR_BSP=1` or DebugPanel.
|
||||
- `[cell-cache]` — per cached EnvCell at landblock load. Toggle: `ACDREAM_PROBE_CELL_CACHE=1`.
|
||||
- `[cell-transit]` — every player CellId change. Toggle: `ACDREAM_PROBE_CELL=1`.
|
||||
- `[check-bldg]` — per portal lookup inside `CheckBuildingTransit`. Gated on `ACDREAM_PROBE_INDOOR_BSP` (reused).
|
||||
|
||||
---
|
||||
|
||||
## How to start a fresh session
|
||||
|
||||
Copy the block below into a fresh Claude Code session in this repo. The model will load `CLAUDE.md` automatically and find the handoff docs on its own.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Pick up the acdream project. Indoor walking Phase 1 + Phase 2 just merged
|
||||
to main at 1af49b7 (2026-05-19). Indoor walking is functionally complete:
|
||||
walls block from inside, walking between rooms via doors works, walking
|
||||
back outside through a door works.
|
||||
|
||||
1. Read docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
|
||||
— that's the canonical record of what just shipped.
|
||||
|
||||
2. Read docs/ISSUES.md and note the open issues. The three follow-ups
|
||||
most-directly related to the just-shipped work:
|
||||
- #88: indoor static objects vibrate (bookshelves, furnaces)
|
||||
- #89: port BSPQuery.SphereIntersectsCellBsp for retail-faithful entry
|
||||
- #80: 2nd-floor goes very dark (pre-existing lighting issue, may be
|
||||
part of a broader Cluster B lighting phase)
|
||||
|
||||
3. **The user has set a focused track: indoor walking issues, collision,
|
||||
physics, and dungeons.** M2 (kill-a-drudge demo) is explicitly NOT
|
||||
the next direction. Stay on the indoor-experience track until they
|
||||
redirect.
|
||||
|
||||
Candidates within that scope, ranked by my best guess at priority:
|
||||
|
||||
A) **#83 — Walking up stairs broken**. Pure indoor/physics. The
|
||||
retail step-up logic (`CPhysicsObj::step_up`) doesn't yet handle
|
||||
indoor cell-BSP polys for stair geometry. Unblocks multi-floor
|
||||
cottages (the 2nd-floor darkness #80 also depends on actually
|
||||
reaching the 2nd floor) and dungeons (which are multi-level).
|
||||
Natural follow-on to Phase 2. ~3-5 days.
|
||||
|
||||
B) **Dungeon stress test + adaptation**. Phase 2's portal traversal
|
||||
was developed and verified at Holtburg cottage (a small one-room
|
||||
building). Dungeons (Subway, Mite Burrow, Carved Stone) are
|
||||
multi-cell indoor spaces with complex portal graphs. Walk a
|
||||
character into a dungeon and see what breaks. Findings drive a
|
||||
follow-up scope. ~1-3 days for the test + variable for fixes.
|
||||
|
||||
C) **#88 — Indoor object vibration** (bookshelves, open furnaces).
|
||||
Quality bug noticed during Phase 2 testing. Likely a per-frame
|
||||
transform recompute or EntityScriptActivator re-firing on cell
|
||||
changes (less likely after Phase 2 stabilized cell tracking but
|
||||
still possible). ~1-3 commits depending on root cause.
|
||||
|
||||
D) **#89 — Port `BSPQuery.SphereIntersectsCellBsp`**. Retail-faithful
|
||||
entry timing for outdoor→indoor. Phase 2 ships with the documented
|
||||
~0.5m late-entry approximation; this closes the gap. Pure physics
|
||||
polish. ~2-3 days.
|
||||
|
||||
E) **Indoor lighting cluster** (closes #79/#80/#81/#82). Slightly
|
||||
adjacent — "indoor experience" but not strictly walking/collision.
|
||||
Worth considering once stairs (A) lands so you can actually reach
|
||||
the dark 2nd floor to verify lighting fixes. ~1-2 weeks.
|
||||
|
||||
F) **#78 — Outdoor stabs visible through floor**. Visibility/stencil
|
||||
issue. Render side. Indoor-adjacent. ~3-5 days.
|
||||
|
||||
My recommendation: **A (stairs)**. Reasons:
|
||||
- Pure indoor physics/collision — squarely in the user's stated track.
|
||||
- Unblocks both multi-floor cottages AND dungeons (B is gated on it).
|
||||
- Continues the natural arc from Phase 1 (walls) → Phase 2 (cell
|
||||
tracking) → Phase 3 (vertical movement / stairs).
|
||||
- The Phase 2 diagnostic infrastructure is still warm; reuse it.
|
||||
|
||||
4. CLAUDE.md rules apply:
|
||||
- No workarounds; fix root causes.
|
||||
- Use superpowers skills for major work (brainstorming → writing-plans
|
||||
→ subagent-driven-development → finishing-a-development-branch).
|
||||
- Drive autonomously — Claude picks what to work on next; user reviews.
|
||||
- Visual verification by the user is the acceptance test for any
|
||||
rendering / collision / lighting fix.
|
||||
|
||||
5. The diagnostic infrastructure is ready for any indoor-cell-related
|
||||
investigation. Probes are runtime-toggleable via the DebugPanel
|
||||
("Indoor: BSP collision" checkbox + the Cluster A render-side ones).
|
||||
|
||||
State the milestone and your chosen phase in the first action you take.
|
||||
Then begin.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick reference for the user
|
||||
|
||||
To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
|
||||
|
||||
> "Read `docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md` and start on the next phase."
|
||||
|
||||
## Quick reference for the helper
|
||||
|
||||
Key files for the next phase (whichever path A/B/C/D you pick):
|
||||
|
||||
- **`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`** — the spec that just shipped. Reference for how Phase 2 was scoped.
|
||||
- **`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`** — full Phase 2 evidence + commit list.
|
||||
- **`docs/ISSUES.md`** — current OPEN items (note new #88 + #89 from Phase 2).
|
||||
- **`docs/plans/2026-04-11-roadmap.md`** — shipped table; Indoor walking Phase 2 row is most recent.
|
||||
|
||||
If picking **Path A (#83 stairs — recommended)**:
|
||||
|
||||
- Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep `step_up`, `step_sphere_up`, `find_walkable` for the existing logic. Our `BSPQuery` ports these for outdoor terrain at lines 1278+; the indoor analog needs the same flow against `cellPhysics.Resolved` floor-and-stair polys.
|
||||
- Touchpoints likely: `src/AcDream.Core/Physics/TransitionTypes.cs` (where the new `TryFindIndoorWalkablePlane` lives — extend to handle step-up across vertical floor polys), `src/AcDream.Core/Physics/BSPQuery.cs::FindCollisions` Path 5/6 (which already handles outdoor step-up; needs indoor counterpart).
|
||||
- Probe surface: `[indoor-bsp]` already captures every cell-BSP query; new probe `[step-up]` may be helpful.
|
||||
|
||||
If picking **Path B (dungeon stress test)**:
|
||||
|
||||
- Pick a small dungeon. Subway (`@0x0102 ...`) or Mite Burrow are good first targets — both are small enough to walk through quickly but complex enough to exercise multi-cell portal traversal.
|
||||
- Run the launch with all indoor probes enabled (`ACDREAM_PROBE_INDOOR_BSP=1`, `ACDREAM_PROBE_CELL=1`, `ACDREAM_PROBE_CELL_CACHE=1`). Walk through every room. Note any wall-pass-through, stuck states, or cell-tracking failures.
|
||||
- Findings drive scope. Probably uncovers stair issues (→ Path A) or sphere-vs-cell timing issues (→ #89).
|
||||
|
||||
If picking **Path C (#88 vibration)**:
|
||||
|
||||
- Likely candidates from the bug report: `EntityScriptActivator.OnCreate/OnRemove` re-firing on rapid CellId promotion/demotion (now less likely after Phase 2 stabilized cell tracking, but worth investigating); per-frame transform recompute drift on cell-static `WorldEntity` instances; particle-emitter offset accumulation.
|
||||
- File this as a Phase rather than an issue if the root cause turns out to be the per-frame transform pipeline (multi-commit refactor).
|
||||
|
||||
If picking **Path E (indoor lighting)**:
|
||||
|
||||
- Start by re-reading the existing Cluster B sketches in the original Cluster A handoff:
|
||||
`docs/research/2026-05-19-indoor-followup-handoff.md` — section "The 9 follow-up issues" lists #79/#80/#81/#82 as Cluster B.
|
||||
- Lighting code surfaces: `src/AcDream.App/Rendering/GameWindow.cs::UpdateSunFromSky` (indoor branch around line 8330+), `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (accumulateLights + indoor ambient), `src/AcDream.Core/Lighting/LightInfoLoader.cs`.
|
||||
- Probe surface: there is no `[lighting]` probe yet — adding one will likely be the first commit in the brainstorm.
|
||||
- **Note:** verify Path A (stairs) lands first or you can't reach the 2nd floor to test indoor lighting at altitude.
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
# Indoor walking Phase 2 — Portal-based cell tracking — handoff (2026-05-19)
|
||||
|
||||
**Date:** 2026-05-19.
|
||||
**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller).
|
||||
**Predecessor:** Indoor walking Phase 1 — BSP cluster (Cluster A). Partially shipped 2026-05-19; closed #86 cleanly, filed #87 for the portal-traversal root cause. Diagnostic infrastructure (`[indoor-bsp]` + `[cell-cache]` probes) remained as scaffolding. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](2026-05-19-cluster-a-shipped-handoff.md).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Phase 2 fully closes the indoor-walking story. Six commits replace Phase D's
|
||||
AABB-containment shortcut with retail-faithful portal-graph cell traversal.
|
||||
`CellId` now promotes to indoor cells via portals and remains promoted through
|
||||
doorways, thresholds, and multi-room navigation. Indoor cell-BSP collision fires
|
||||
consistently. A critical fix in commit 5 passes the foot-sphere center (not the
|
||||
entity reference point) to `ResolveCellId`, which was the production failure that
|
||||
made PointInsideCellBsp return false at floor level. Commit 6 adds
|
||||
`TryFindIndoorWalkablePlane` so the walkability resolver doesn't fall through to
|
||||
outdoor terrain when the player is inside.
|
||||
|
||||
**Visual verification at Holtburg cottage (2026-05-19, user testing live ACE):**
|
||||
- Walls block from inside — player cannot walk through cottage walls.
|
||||
- Multi-room navigation via doorways works — `[cell-transit]` log shows `0xA9B40145 → 0x143 → 0x144 → 0x13F` chains.
|
||||
- Walking back outdoors through a door works (post-walkable fix in commit 6).
|
||||
- Cell tracking is robust through multiple indoor sessions.
|
||||
|
||||
---
|
||||
|
||||
## Commits
|
||||
|
||||
| # | SHA | Subject |
|
||||
|---|---|---|
|
||||
| 1 | `1969c55` | `feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics` |
|
||||
| 2 | `aad6976` | `feat(physics): Phase 2 — port CellTransit + wire into ResolveCellId` |
|
||||
| 3 | `069534a` | `feat(physics): Phase 2 — BuildingPhysics + CheckBuildingTransit` |
|
||||
| 4 | `702b30a` | `refactor(physics): Phase 2 — code-review polish on BuildingPhysics commit` |
|
||||
| 5 | `3ffe1e4` | `fix(physics): Phase 2 — pass foot-sphere center to ResolveCellId` |
|
||||
| 6 | `eb0f772` | `fix(physics): Phase 2 — synthesize indoor walkable plane from cell floor` |
|
||||
|
||||
**Build:** clean on all commits.
|
||||
**Tests:** `dotnet test` shows the same 8 pre-existing failures in
|
||||
`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged). All
|
||||
new Phase 2 tests and the walkable-plane tests green.
|
||||
|
||||
---
|
||||
|
||||
## What shipped
|
||||
|
||||
### Commit 1 — CellBSP + Portals wired into CellPhysics
|
||||
|
||||
New `PortalInfo` struct holds `PortalId`, `PortalPolygonIndex`, `PortalFlags`,
|
||||
and `OtherCellId`. `CellPhysics` extended with:
|
||||
- `CellBSP` — a third BSP tree (alongside `PhysicsBSP` and the render BSP) used
|
||||
for point-in-cell tests. Retail: `CCellStructure::cell_bsp`.
|
||||
- `Portals` — `IReadOnlyList<PortalInfo>` built from `envCell.CellPortals`.
|
||||
- `PortalPolygons` — the visible polygons that portals reference (`cellStruct.Polygons`,
|
||||
not `PhysicsPolygons`; portals reference the visible-geometry polygon list).
|
||||
- `VisibleCellIds` — cells visible from this cell (used by `AddAllOutsideCells`).
|
||||
|
||||
Phase D's `LocalAabbMin/Max` + `TryFindContainingCell` are deleted — they are now
|
||||
superseded by the portal traversal in `CellTransit`.
|
||||
|
||||
### Commit 2 — CellTransit + ResolveCellId
|
||||
|
||||
New `CellTransit` static class implements the retail portal-neighbour walk.
|
||||
Three public entry points:
|
||||
|
||||
- **`FindTransitCellsSphere(sphereCenter, sphereRadius, startCell, cache)`** —
|
||||
walks portal connectivity from `startCell` outward. For each portal, tests
|
||||
whether the sphere overlaps the portal polygon (using `PointInsideCellBsp` on
|
||||
the sphere center as an approximation — see issue #89 for the retail-faithful
|
||||
sphere variant). Recurses into neighbour cells up to a depth limit.
|
||||
|
||||
- **`AddAllOutsideCells(sphereCenter, blockId, cache, results)`** — for the
|
||||
outdoor path: populates a 24m grid of outdoor cell ids around the sphere center
|
||||
using `TerrainSurface.ComputeOutdoorCellId`. Mirrors retail's `add_all_outside_cells`.
|
||||
|
||||
- **`FindCellList(sp, startCell, cache)`** — top-level driver. Determines whether
|
||||
`startCell` is an indoor (EnvCell) or outdoor cell and dispatches accordingly.
|
||||
Returns a list of candidate cell ids.
|
||||
|
||||
`PhysicsEngine.ResolveOutdoorCellId` renamed to `ResolveCellId` (accepts
|
||||
`sphereRadius` parameter). Body splits on indoor vs outdoor:
|
||||
- **Indoor:** delegates to `FindCellList` and picks the candidate cell where
|
||||
`PointInsideCellBsp` returns true for the sphere center.
|
||||
- **Outdoor:** existing terrain-grid loop (`AddAllOutsideCells`).
|
||||
|
||||
`BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` to `CellBSPNode?`
|
||||
(dead code retype — no behavior change). Phase D's test file deleted.
|
||||
|
||||
### Commit 3 — BuildingPhysics + CheckBuildingTransit
|
||||
|
||||
Outdoor→indoor entry path via building-shell portal graph. New `BuildingPhysics`
|
||||
class caches per-building portal data (`BldPortalInfo` structs with `PortalId`,
|
||||
`OtherCellId`, `CellBSP`). `PhysicsDataCache` gains `_buildings` cache keyed by
|
||||
building entity id. `GameWindow` iterates `lbInfo.Buildings` at landblock load and
|
||||
populates the cache.
|
||||
|
||||
`CellTransit.CheckBuildingTransit(sphereCenter, sphereRadius, blockId, physicsCache)`
|
||||
ports retail's outdoor→indoor portal-graph entry:
|
||||
1. For each building in the landblock's physics cache, test whether the sphere
|
||||
center is inside the building's shell cell BSP (`PointInsideCellBsp`).
|
||||
2. If inside, walk the building's portal graph to find the indoor EnvCell that
|
||||
contains the sphere center.
|
||||
3. Returns the EnvCell id (or 0 if no match).
|
||||
|
||||
`PhysicsEngine.ResolveCellId`'s outdoor branch hooks `CheckBuildingTransit` after
|
||||
the terrain-grid loop, so outdoor→indoor transition is detected during normal walking.
|
||||
|
||||
### Commit 4 — Code-review polish
|
||||
|
||||
Five items addressed from reviewer:
|
||||
1. DRY cell-id derivation via existing `TerrainSurface.ComputeOutdoorCellId`
|
||||
(removed inline duplicate in `CheckBuildingTransit`).
|
||||
2. Named `PortalFlags.ExactMatch` enum instead of raw `0x01` literal.
|
||||
3. Comment clarity on `ExactMatch` reserved field.
|
||||
4. Doc comment on `CheckBuildingTransit` calling out the sphere-vs-point
|
||||
divergence from retail's `sphere_intersects_cell` (see issue #89).
|
||||
5. Rename misleading test method name.
|
||||
|
||||
### Commit 5 — Critical fix: foot-sphere center to ResolveCellId
|
||||
|
||||
**This was the production bug that prevented Phase 2 from working until the last run.**
|
||||
|
||||
`ResolveCellId` was being called with `sp.CheckPos` (the entity's reference point
|
||||
at feet level, world Z = terrain Z after the +0.02f bump) instead of
|
||||
`sp.GlobalSphere[0].Origin` (the foot sphere CENTER, approximately +0.48m above terrain).
|
||||
|
||||
Combined with the +0.02f Z-bump applied to cell origins in `PhysicsDataCache`, the
|
||||
test point landed at cell-local Z = -0.02 m — just below the cell's floor — and
|
||||
`PointInsideCellBsp` returned false for every cell. CellId never promoted to indoor
|
||||
cells during normal walking despite `FindCellList` correctly finding the right
|
||||
candidate cells.
|
||||
|
||||
Passing the foot-sphere center (which sits 0.48m above the floor, well inside any
|
||||
room cell) made portal-based cell tracking actually work in production.
|
||||
|
||||
Also adds the `[check-bldg]` diagnostic line (logged when `CheckBuildingTransit`
|
||||
returns a non-zero indoor cell id).
|
||||
|
||||
### Commit 6 — TryFindIndoorWalkablePlane
|
||||
|
||||
**Root cause of the post-Phase-2 falling-stuck bug.**
|
||||
|
||||
When indoor cell-BSP returned OK (no wall collision), the code fell through to
|
||||
outdoor `SampleTerrainWalkable` + `ValidateWalkable`. Outdoor terrain Z is below
|
||||
the indoor floor (due to the +0.02f Z-bump), so `ValidateWalkable` computed the
|
||||
player as floating well above terrain → not walkable → player stuck in the falling
|
||||
animation when blocked by an indoor wall.
|
||||
|
||||
New `TryFindIndoorWalkablePlane(worldPos, cellPhysics)`: finds the floor polygon
|
||||
directly under the player's world position by testing `worldPos` against each
|
||||
physics polygon's plane normal (upward-facing = floor) and building a `ContactPlane`
|
||||
from it. Called from the indoor branch of `ResolveWithTransition` before the outdoor
|
||||
terrain fallback. Returns true when a floor poly is found; the resolver uses the
|
||||
synthesized plane for walkability.
|
||||
|
||||
---
|
||||
|
||||
## Issue status after Phase 2
|
||||
|
||||
| Issue | Status | Notes |
|
||||
|---|---|---|
|
||||
| #84 Blocked by air indoors | **FULLY CLOSED** | Spawn-in-building variant: Phase D (Cluster A). Wall-block-from-inside + falling-stuck variants: Phase 2 commits 2, 5, 6. |
|
||||
| #85 Pass through walls outside→in | **CLOSED** | `CheckBuildingTransit` + portal traversal. CellId promotes to indoor on outdoor→indoor entry. |
|
||||
| #86 Click selection penetrates walls | CLOSED (Phase 1) | `WorldPicker.Pick` + `CellBspRayOccluder`. |
|
||||
| #87 Indoor portal-based cell tracking | **CLOSED** | `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`. Portal-graph traversal replaces AABB containment. |
|
||||
| #88 Indoor static objects vibrate | OPEN (new) | Pre-existing visual jitter on bookshelves/furnaces. Filed 2026-05-19. Medium severity. |
|
||||
| #89 Port BSPQuery.SphereIntersectsCellBsp | OPEN (new) | `CheckBuildingTransit` uses `PointInsideCellBsp` (radius-less approximation) instead of retail's `sphere_intersects_cell`. Filed 2026-05-19. Low severity. |
|
||||
|
||||
---
|
||||
|
||||
## Probe evidence — log file findings
|
||||
|
||||
### `launch-phase2-verify3.log`
|
||||
|
||||
First run that showed indoor cell-transits firing. `[cell-transit]` output
|
||||
confirmed the portal traversal was finding indoor cells. `[indoor-bsp]` probe
|
||||
fired consistently during indoor walking (not just during mid-jump frames as in
|
||||
Cluster A). This log is the first evidence that `CellTransit.FindCellList` was
|
||||
working correctly for room interiors, though outdoor→indoor entry was not yet
|
||||
exercised.
|
||||
|
||||
### `launch-phase2-verify4.log`
|
||||
|
||||
Multi-room navigation run. `[cell-transit]` log shows
|
||||
`0xA9B40145 → 0x143 → 0x144 → 0x13F` chains as the player walked between
|
||||
rooms in the Holtburg cottage via doorways. Confirmed the `FindTransitCellsSphere`
|
||||
recursive portal walk was promoting CellId correctly through threshold cells.
|
||||
Walls blocked from inside in all rooms tested.
|
||||
|
||||
### `launch-phase2-verify5.log`
|
||||
|
||||
Walkable bug evidence run. After the outdoor→indoor transition was wired
|
||||
(`CheckBuildingTransit`), the player could walk into the cottage from outside,
|
||||
but colliding with an indoor wall produced a falling-stuck state (the `[indoor-bsp]`
|
||||
probe fired for the wall collision, but `ValidateWalkable` returned false because
|
||||
it was sampling outdoor terrain Z). This log captured the falling-stuck symptom
|
||||
and the `SampleTerrainWalkable` fallthrough trace, motivating commit 6.
|
||||
|
||||
### `launch-phase2-verify6.log`
|
||||
|
||||
Post-walkable-fix verification run. After `TryFindIndoorWalkablePlane` was added:
|
||||
- Outdoor→indoor entry works (player walks through doorway, CellId promotes).
|
||||
- Indoor wall collision works (walls block, player doesn't pass through).
|
||||
- Walking back outdoors through the door works (CellId demotes to outdoor cell).
|
||||
- No falling-stuck state observed. User confirmed all three behaviors.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic infrastructure remaining in place
|
||||
|
||||
All four probes stay committed and wired. They serve as production diagnostics
|
||||
and as debugging aids for follow-up issues:
|
||||
|
||||
- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one
|
||||
`[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell branch.
|
||||
After Phase 2, this fires consistently whenever the player is indoors. Useful
|
||||
for confirming the indoor-BSP path is active.
|
||||
|
||||
- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all
|
||||
cached EnvCell physics data (poly counts, BSP bounding sphere, AABB, unmatched
|
||||
ID count, portal count). Useful for verifying cell struct loads and portal
|
||||
connectivity.
|
||||
|
||||
- **`ACDREAM_PROBE_CELL=1`** (existing L.2a slice 1): one `[cell-transit]` line
|
||||
per `PlayerMovementController.CellId` change (old → new cell, world position,
|
||||
reason tag). Essential for tracing indoor promotion/demotion sequences.
|
||||
|
||||
- **`[check-bldg]`** (commit 5): logged by `ResolveCellId` when
|
||||
`CheckBuildingTransit` returns a non-zero indoor cell id. Fires once per
|
||||
outdoor→indoor transition detection.
|
||||
|
||||
All gated behind `PhysicsDiagnostics` static class (existing pattern from L.2a).
|
||||
|
||||
---
|
||||
|
||||
## Visual verification outcomes
|
||||
|
||||
**2026-05-19, user testing live against local ACE at Holtburg.**
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| Walk into cottage wall from inside | Blocked ✓ |
|
||||
| Walk between rooms via doorway | CellId transitions logged, multi-room navigation works ✓ |
|
||||
| Walk from outside into cottage through door | Outdoor→indoor entry promoted CellId; indoor BSP collision active ✓ |
|
||||
| Walk back outside through door | CellId demoted to outdoor cell; outdoor physics resumed ✓ |
|
||||
| No falling-stuck after post-walkable fix | Confirmed ✓ |
|
||||
| Robust across multiple indoor sessions | Confirmed ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
**#88 — Indoor static objects vibrate (bookshelves, open furnaces).** Pre-existing
|
||||
visual jitter spotted before Phase 2 shipped. Medium severity. Candidates: repeated
|
||||
`EntityScriptActivator.OnCreate/OnRemove` near cell boundaries, per-part transform
|
||||
drift, or particle-emitter offset accumulation. Investigate in a follow-up session.
|
||||
|
||||
**#89 — Port `BSPQuery.SphereIntersectsCellBsp`.** `CellTransit.CheckBuildingTransit`
|
||||
currently uses `PointInsideCellBsp` (tests sphere CENTER only). Retail's
|
||||
`CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell`
|
||||
(radius-aware, returns Inside/Crossing/Outside). Practical effect: entry fires
|
||||
~0.48m deeper into the doorway than retail. Low severity — visually acceptable.
|
||||
The `sphereRadius` parameter is already plumbed through for when this is ported.
|
||||
|
||||
**#80 — Indoor darkness (camera on 2nd floor goes very dark).** Still open.
|
||||
Not in Phase 2's scope. Lighting / ambient-occlusion issue that predates indoor
|
||||
rendering Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## State at handoff
|
||||
|
||||
- **Branch:** `claude/competent-robinson-dec1f4`, 6 commits of Phase 2 work
|
||||
(plus 7 from Phase 1 / Cluster A on the same branch).
|
||||
- **Build state:** `dotnet build -c Debug` clean.
|
||||
- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp
|
||||
baseline). All targeted test projects green.
|
||||
- **Issues:** #84, #85, #87 CLOSED. #86 CLOSED (Phase 1). #88, #89 OPEN (new).
|
||||
- **Diagnostic probes:** `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
|
||||
`[check-bldg]` all active and wired.
|
||||
- **Next:** M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b — kill-a-drudge
|
||||
demo) or other candidates per work-order autonomy in CLAUDE.md.
|
||||
|
|
@ -46,7 +46,7 @@ public partial class LightInfo : IDatObjType {
|
|||
|
||||
- **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive.
|
||||
- **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors.
|
||||
- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or a fixed dark RGB like `(0.10, 0.09, 0.08)` (indoors, approximating the dungeon "deep" tone) — then add active lights on top. See §12 for the C# class.
|
||||
- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or **a flat `(0.20, 0.20, 0.20)` neutral** (indoors) — then add active lights on top. The indoor constant is taken **directly from retail**: `CellManager::ChangePosition` (0x004559B0) calls `SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)` whenever `CObjCell::seen_outside == 0`. The early-2026 guess at `(0.10, 0.09, 0.08)` was eyeballed; the retail value is both brighter and neutral. See §12 for the C# class.
|
||||
|
||||
## 4. Torch lights and `WeenieType.LightSource`
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,631 @@
|
|||
# Phase B.6 — Suppress MoveToState during inbound auto-walk Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop sending outbound `MoveToState` while ACE's server-initiated auto-walk is driving the player, then retire the Commit B workarounds that compensated for the resulting `MoveToChain` cancellation. ACE's `TryUseItem` callback fires on arrival; client sends Use exactly once.
|
||||
|
||||
**Architecture:** One-line guard against the misleading wire packet, two retry-assignment deletions, one revert of the AP-cadence gate to retail's narrow shape. No new files, no new tests (behavioral change is wire-level integration; covered by existing Core.Net suite + user visual verify).
|
||||
|
||||
**Tech Stack:** C# .NET 10. Edits touch `AcDream.App/Rendering/GameWindow.cs`, `AcDream.App/Input/PlayerMovementController.cs`, `docs/ISSUES.md`.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md](../specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md).
|
||||
|
||||
**Retail anchors:**
|
||||
- `Player_Use.cs:205` (ACE) — `CreateMoveToChain(item, (success) => TryUseItem(item, success))`.
|
||||
- `Player_Move.cs:150` (ACE) — chain polls and fires `callback(true)` when within use radius.
|
||||
- `acclient_2013_pseudo_c.txt:700233-700285` — retail `ShouldSendPositionEvent`: narrow gate (cell-or-plane change during sub-interval; frame change after interval; gated on `Contact && OnWalkable`).
|
||||
- `acclient_2013_pseudo_c.txt:700327` — retail `SendPositionEvent`: `(state & 1) != 0 && (state & 2) != 0`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Touched in tasks |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Outbound wire layer. Guard MoveToState build (Task 1); delete two retry assignments + log strings (Task 1); update NotePositionSent call sites to pass contact plane (Task 3). | 1, 3 |
|
||||
| `src/AcDream.App/Input/PlayerMovementController.cs` | Diff-driven cadence state + the auto-walk overlay. Add `_lastSentContactPlane` field; extend `NotePositionSent` signature; replace per-frame `positionChanged` gate with retail's narrow gate (Task 3). | 3 |
|
||||
| `docs/ISSUES.md` | Close `#63` and `#74` to "Recently closed" (Task 5). | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Suppress outbound MoveToState during server auto-walk + delete the retry workarounds
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~6410 (the MoveToState send block).
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~9203 (SendUse far-range path).
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~9265 (SendPickUp far-range path).
|
||||
|
||||
### Step 1: Guard the outbound MoveToState build
|
||||
|
||||
Find the block at `GameWindow.cs:6410` that reads:
|
||||
|
||||
```csharp
|
||||
if (result.MotionStateChanged)
|
||||
{
|
||||
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||
```
|
||||
|
||||
Change the condition to:
|
||||
|
||||
```csharp
|
||||
// 2026-05-16 (Phase B.6): suppress outbound MoveToState while
|
||||
// ACE's server-initiated auto-walk is driving the player.
|
||||
// Synthesized Forward+Run input in ApplyAutoWalkOverlay leaks
|
||||
// to MotionStateChanged=true; sending the resulting "user is
|
||||
// RunForward" wire packet makes ACE cancel its own MoveToChain
|
||||
// (Player_Move.cs:150 callback never fires). Retail and
|
||||
// holtburger walk the body locally during inbound MoveToObject
|
||||
// WITHOUT sending an outbound MoveToState — AutonomousPosition
|
||||
// alone is enough for ACE's WithinUseRadius poll.
|
||||
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||
{
|
||||
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||
```
|
||||
|
||||
(Only the `if` line changes; the comment above is new. Leave the rest of the block untouched.)
|
||||
|
||||
- [ ] **Step 2: Delete the SendUse far-range retry assignment**
|
||||
|
||||
Find the SendUse method's far-range block. Search:
|
||||
|
||||
```
|
||||
grep -n "Far range:" src/AcDream.App/Rendering/GameWindow.cs
|
||||
```
|
||||
|
||||
Expected: line ~9197. The block reads (paraphrased — find the exact text in the file):
|
||||
|
||||
```csharp
|
||||
// Far range: send Use immediately so ACE has the request,
|
||||
// AND queue an arrival re-send. Issue #63 (server-initiated
|
||||
// MoveToObject not honored) means ACE's first-Use response
|
||||
// is dropped as too-far and ACE doesn't re-poll
|
||||
// WithinUseRadius when the speculative local walk gets us in
|
||||
// range. The arrival re-send fires a second Use packet once
|
||||
// the body reaches the target — at which point ACE accepts
|
||||
// and executes the action. The retail-faithful path is to
|
||||
// honor MoveToObject and let ACE complete the Use server-
|
||||
// side; until #63 lands, this client-side retry is the
|
||||
// workaround that keeps far-range Use working.
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||
_liveSession.SendGameAction(body);
|
||||
_pendingPostArrivalAction = (guid, false);
|
||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
// Far range: send Use ONCE. ACE's CreateMoveToChain
|
||||
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
|
||||
// it server-side when WithinUseRadius passes during the
|
||||
// MoveToChain poll (Player_Move.cs:150). No client-side retry
|
||||
// needed — the Phase B.6 MoveToState-suppression fix
|
||||
// (GameWindow.cs:6410) keeps ACE's chain alive during the
|
||||
// walk.
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||
_liveSession.SendGameAction(body);
|
||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
||||
```
|
||||
|
||||
(Removes the `_pendingPostArrivalAction = (guid, false);` line and trims the log to drop the `(queued for arrival re-send pending #63)` suffix.)
|
||||
|
||||
- [ ] **Step 3: Delete the SendPickUp far-range retry assignment**
|
||||
|
||||
Find the SendPickUp method's far-range block. Search:
|
||||
|
||||
```
|
||||
grep -n "Far range: same arrival-retry pattern" src/AcDream.App/Rendering/GameWindow.cs
|
||||
```
|
||||
|
||||
Expected: line ~9255. Replace the block:
|
||||
|
||||
```csharp
|
||||
// Far range: same arrival-retry pattern as SendUse — fire
|
||||
// PickUp immediately AND queue for arrival re-send. ACE's
|
||||
// first PickUp is dropped if we're outside the use-radius
|
||||
// and isn't re-polled (issue #63 workaround).
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||||
seq, itemGuid, _playerServerGuid, placement: 0);
|
||||
_liveSession.SendGameAction(body);
|
||||
_pendingPostArrivalAction = (itemGuid, true);
|
||||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
|
||||
// callback pattern as SendUse — ACE's chain fires
|
||||
// PutItemInContainer/Move server-side when in range. No
|
||||
// client-side retry; Phase B.6 MoveToState suppression keeps
|
||||
// ACE's chain alive.
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||||
seq, itemGuid, _playerServerGuid, placement: 0);
|
||||
_liveSession.SendGameAction(body);
|
||||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||
```
|
||||
|
||||
Expected: 0 errors, 0 warnings. The deletions remove the only assignment of `_pendingPostArrivalAction` for far-range paths; the close-range path still assigns it (line ~9191 and ~9258). The field declaration at line ~799 stays.
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug --nologo
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo
|
||||
```
|
||||
|
||||
Expected: Core.Net 294/294 pass. Core 1073/1081 pass (baseline; 8 pre-existing physics failures unchanged).
|
||||
|
||||
- [ ] **Step 6: Visual verification (user-driven)**
|
||||
|
||||
User stops any running AcDream.App gracefully via the close-window button, waits ~3 seconds, launches:
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
$env:ACDREAM_PROBE_AUTOWALK = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
|
||||
```
|
||||
|
||||
Scenarios to test:
|
||||
|
||||
1. **Far-range Use NPC.** Double-click a Royal Guard / Pathwarden ~8 m away. Expected log shape:
|
||||
```
|
||||
[B.4b] use guid=0x... seq=X
|
||||
[autowalk-mt] mt=0x06 isMoveTo=True ...
|
||||
[autowalk-begin] dest=...
|
||||
[autowalk-end] reason=arrived
|
||||
```
|
||||
Expected: NO `[B.4b] use-deferred` follow-up. Dialogue fires on arrival from ACE's `TryUseItem` callback.
|
||||
|
||||
2. **Far-range PickUp.** F-key a ground item ~5 m away. Same shape — single `[B.5] pickup` line, no `pickup-deferred`, item enters inventory.
|
||||
|
||||
3. **Close-range Use NPC behind player.** Within 3 m, press R. Body turns 180°. Expected log:
|
||||
```
|
||||
[B.4b] use deferred (close-range, turn-first) guid=0x...
|
||||
[autowalk-end] reason=arrived
|
||||
[B.4b] use-deferred guid=0x... seq=X
|
||||
```
|
||||
(Close-range deferred path is unchanged; `use-deferred` is correct here.)
|
||||
|
||||
4. **Open inn door from across the room.** ONE `[B.4b] use` line, no retry, door opens once.
|
||||
|
||||
5. **User takes manual control mid-auto-walk.** Click far NPC → press W during the walk. Auto-walk cancels (`EndServerAutoWalk("user-input")`). Action does NOT fire on arrival.
|
||||
|
||||
**STOP and wait for user confirmation that scenarios 1–5 pass.**
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(retail): suppress outbound MoveToState during inbound auto-walk
|
||||
|
||||
Phase B.6 — retire the Commit B issue-#63 workarounds by plugging the
|
||||
underlying leak that caused them.
|
||||
|
||||
ApplyAutoWalkOverlay synthesizes Forward+Run input during inbound
|
||||
MoveToObject so the existing motion-interpreter pipeline drives body
|
||||
position + animation locally. That synth set MotionStateChanged=true,
|
||||
so the outbound wire layer (GameWindow.cs:6410) built a MoveToState
|
||||
packet with forwardCommand=RunForward and sent it to ACE. ACE read
|
||||
the packet as "user took manual control" and cancelled its own
|
||||
CreateMoveToChain (Player_Use.cs:205 → Player_Move.cs:150), so the
|
||||
TryUseItem callback never fired on arrival. Our workaround sent Use
|
||||
a second time at local-arrival to bypass ACE's cancelled chain.
|
||||
|
||||
Fix: one-line guard. The MoveToState send only fires when
|
||||
!_playerController.IsServerAutoWalking. AutonomousPosition keeps
|
||||
flowing during the walk (so ACE's WithinUseRadius poll sees the
|
||||
player approach); ACE's chain runs uninterrupted; callback fires
|
||||
when in range. Retail and holtburger (simulation.rs:178-206) follow
|
||||
the same pattern — no outbound MoveToState during inbound MoveToObject.
|
||||
|
||||
Deletes the retry workarounds:
|
||||
- SendUse far-range: `_pendingPostArrivalAction = (guid, false);`
|
||||
+ the `(queued for arrival re-send pending #63)` log
|
||||
- SendPickUp far-range: same shape
|
||||
|
||||
Close-range turn-first deferred path (separate code, retail-faithful
|
||||
pre-callback rotation) is unchanged.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Visual checkpoint — confirm Task 1's fix before touching cadence
|
||||
|
||||
This is not a code task. The Step 6 visual verification in Task 1 establishes that ACE's `MoveToChain` callback fires correctly with the MoveToState suppression in place. Only proceed to Task 3 if all five scenarios pass. If any regresses, STOP and revert Task 1's commit before continuing.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Revert AP cadence to retail's narrow gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` line ~254 (field block), line ~441-449 (`NotePositionSent`), line ~1240-1275 (the gate logic), line ~289-296 (`AutoWalkArrived` doc-comment cleanup).
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~6450 (MTS NotePositionSent call), line ~6476 (AP NotePositionSent call).
|
||||
|
||||
- [ ] **Step 1: Add `_lastSentContactPlane` field**
|
||||
|
||||
In `PlayerMovementController.cs`, find the diff-tracking field block (around line 535-540 — search for `_lastSentPos`):
|
||||
|
||||
```csharp
|
||||
private System.Numerics.Vector3 _lastSentPos;
|
||||
private uint _lastSentCellId;
|
||||
private float _lastSentTime;
|
||||
private bool _lastSentInitialized;
|
||||
```
|
||||
|
||||
Add a new field immediately after `_lastSentCellId`:
|
||||
|
||||
```csharp
|
||||
private System.Numerics.Vector3 _lastSentPos;
|
||||
private uint _lastSentCellId;
|
||||
private System.Numerics.Plane _lastSentContactPlane;
|
||||
private float _lastSentTime;
|
||||
private bool _lastSentInitialized;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `NotePositionSent` to accept the contact plane**
|
||||
|
||||
Find `NotePositionSent` in `PlayerMovementController.cs` (around line 441). Replace:
|
||||
|
||||
```csharp
|
||||
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||
uint cellId,
|
||||
float nowSeconds)
|
||||
{
|
||||
_lastSentPos = worldPos;
|
||||
_lastSentCellId = cellId;
|
||||
_lastSentTime = nowSeconds;
|
||||
_lastSentInitialized = true;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// 2026-05-16 (Phase B.6). Called by the network outbound layer
|
||||
/// after every AutonomousPosition or MoveToState that carries the
|
||||
/// player's position. Resets the diff-driven heartbeat clock so the
|
||||
/// next <see cref="HeartbeatDue"/> evaluation requires a fresh
|
||||
/// state change. Mirrors retail's SendPositionEvent
|
||||
/// (acclient_2013_pseudo_c.txt:700345-700348) which writes
|
||||
/// `last_sent_position`, `last_sent_position_time`, and
|
||||
/// `last_sent_contact_plane` after every send.
|
||||
/// </summary>
|
||||
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||
uint cellId,
|
||||
System.Numerics.Plane contactPlane,
|
||||
float nowSeconds)
|
||||
{
|
||||
_lastSentPos = worldPos;
|
||||
_lastSentCellId = cellId;
|
||||
_lastSentContactPlane = contactPlane;
|
||||
_lastSentTime = nowSeconds;
|
||||
_lastSentInitialized = true;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the per-frame gate with retail's narrow gate**
|
||||
|
||||
Find the cadence block in `PlayerMovementController.cs` (around line 1240-1275 — search for `retail diff-driven AP cadence`). Replace the block starting at the `// 2026-05-16 — retail diff-driven AP cadence` comment through the `HeartbeatDue =` line with:
|
||||
|
||||
```csharp
|
||||
// 2026-05-16 (Phase B.6) — retail-faithful AP cadence per
|
||||
// CommandInterpreter::ShouldSendPositionEvent at
|
||||
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
|
||||
//
|
||||
// Branch 1 — interval NOT yet elapsed (< 1 sec since
|
||||
// last send): send only if cell changed OR contact-plane
|
||||
// changed (mid-walk events that matter).
|
||||
//
|
||||
// Branch 2 — interval HAS elapsed (>= 1 sec): send only
|
||||
// if cell OR position frame changed. Truly idle = no
|
||||
// send (retail's `last_sent.frame == player.frame` check
|
||||
// at line 700248-700265).
|
||||
//
|
||||
// SendPositionEvent (line 700327) gates the actual send on
|
||||
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
|
||||
// OnWalkable both set. We mirror that gate here so airborne
|
||||
// and wall-contact-without-walkable states suppress AP
|
||||
// entirely; MoveToState carries jump/fall snapshots while
|
||||
// airborne.
|
||||
//
|
||||
// Effective rates:
|
||||
// - Truly idle (grounded, no movement) : 0 Hz
|
||||
// - Smooth movement (no cell/plane changes) : ~1 Hz (interval-driven)
|
||||
// - Cell crossings + stair/hill steps : per-event
|
||||
// - Airborne : 0 Hz
|
||||
//
|
||||
// Bootstrap: when NotePositionSent has never been called
|
||||
// (_lastSentInitialized=false), treat every frame as
|
||||
// "anything to send" so the first AP gets a chance to fire.
|
||||
|
||||
bool intervalElapsed = !_lastSentInitialized
|
||||
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||
|
||||
bool cellChanged = !_lastSentInitialized
|
||||
|| _lastSentCellId != CellId;
|
||||
bool planeChanged = !_lastSentInitialized
|
||||
|| !_lastSentContactPlane.Equals(_body.ContactPlane);
|
||||
bool frameChanged = !_lastSentInitialized
|
||||
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||
|
||||
bool sendThisFrame = intervalElapsed
|
||||
? (cellChanged || frameChanged)
|
||||
: (cellChanged || planeChanged);
|
||||
|
||||
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||
// set simultaneously, NOT a bitwise-OR mask test.
|
||||
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||
|
||||
HeartbeatDue = groundedOnWalkable && sendThisFrame;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the MTS site to pass `contactPlane`**
|
||||
|
||||
In `GameWindow.cs`, find the MoveToState `NotePositionSent` call (around line 6450). Replace:
|
||||
|
||||
```csharp
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
contactPlane: _playerController.PhysicsBody.ContactPlane,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
```
|
||||
|
||||
If `_playerController.PhysicsBody` doesn't exist as a public accessor, search:
|
||||
|
||||
```
|
||||
grep -n "public.*_body\|public PhysicsBody\|public.*Body" src/AcDream.App/Input/PlayerMovementController.cs
|
||||
```
|
||||
|
||||
If no public accessor, add one in `PlayerMovementController.cs` near the existing public properties (around line 130-160):
|
||||
|
||||
```csharp
|
||||
/// <summary>2026-05-16. Read-only access to the controller's
|
||||
/// physics body — needed by the network outbound layer to stamp
|
||||
/// the contact plane into NotePositionSent.</summary>
|
||||
public AcDream.Core.Physics.PhysicsBody PhysicsBody => _body;
|
||||
```
|
||||
|
||||
(Verify the field name is `_body` first — search `private.*PhysicsBody`.)
|
||||
|
||||
- [ ] **Step 5: Update the AP site to pass `contactPlane`**
|
||||
|
||||
Find the AutonomousPosition `NotePositionSent` call (around line 6476). Apply the same edit:
|
||||
|
||||
```csharp
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
contactPlane: _playerController.PhysicsBody.ContactPlane,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build**
|
||||
|
||||
```
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||
```
|
||||
|
||||
Expected: 0 errors. Any compile error here is a wiring mistake — the field name (`_body` vs `_physicsBody`), the property accessor, or the `Plane` namespace.
|
||||
|
||||
- [ ] **Step 7: Run existing tests**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug --nologo
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo
|
||||
```
|
||||
|
||||
Expected: Core.Net 294/294, Core 1073/1081 (baseline unchanged).
|
||||
|
||||
- [ ] **Step 8: Visual verification (user-driven)**
|
||||
|
||||
User restarts the client (graceful close + ~3 sec wait + launch). Runs scenarios:
|
||||
|
||||
1. **Idle test.** Stand still on flat ground in Holtburg for 10 sec. Watch the dev console / `[autowalk-up]` lines or any outbound packet trace.
|
||||
- Old behavior: 1 AP/sec heartbeat.
|
||||
- New behavior: ZERO outbound packets while truly idle.
|
||||
|
||||
2. **Smooth-running test.** Hold W and run in a straight line for 5 sec on flat ground (no cell crossings).
|
||||
- Old behavior: ~60 AP/sec (per-frame while position changed).
|
||||
- New behavior: ~1 AP/sec (interval-driven; cell/plane don't change every frame).
|
||||
- **The character should still appear to remote observers as moving smoothly** — ACE's dead-reckoning fills in the gaps between sparse APs. If remote view becomes jittery, the cadence is too sparse and we'll need to tune.
|
||||
|
||||
3. **Cell-crossing test.** Run across a landblock boundary (every ~192 m). Should see a burst of AP packets at the crossing — both the `cellChanged` path and the `intervalElapsed && frameChanged` path can fire here.
|
||||
|
||||
4. **Far-range Use re-test.** Repeat Task 1 Step 6 scenario 1 (far-range NPC Use). Should still work — ACE's `MoveToChain` callback fires on arrival, dialogue plays, single Use packet.
|
||||
|
||||
5. **Hill / stair test.** Walk up a slope or stairs. Contact-plane changes per step → sub-interval AP sends fire on plane change. Behavior should look smooth to remote observers.
|
||||
|
||||
**STOP and wait for user confirmation that scenarios 1–5 pass.** If scenario 2 produces visible remote-jitter, the spec's `ApproxPositionEqual` epsilon may need tightening, or we may need a higher heartbeat rate; document the finding and tune before continuing.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Input/PlayerMovementController.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(retail): revert AP cadence to retail's narrow gate
|
||||
|
||||
Phase B.6 — closes #74. With the MoveToState suppression fix in
|
||||
place, the per-frame "send while moving" cadence is no longer needed
|
||||
to compensate for ACE's MoveToChain cancellation. Reverts to retail's
|
||||
two-branch gate per CommandInterpreter::ShouldSendPositionEvent at
|
||||
acclient_2013_pseudo_c.txt:700233-700285:
|
||||
|
||||
Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed.
|
||||
Interval elapsed (>= 1 sec): send if cell or position frame changed.
|
||||
|
||||
Bootstrap fires every frame until the first NotePositionSent.
|
||||
Grounded-on-walkable gate (Contact && OnWalkable) unchanged from
|
||||
700327.
|
||||
|
||||
Effective rates:
|
||||
Truly idle (grounded, no movement) : 0 Hz (was 1 Hz)
|
||||
Smooth straight-line run : ~1 Hz (was ~60 Hz)
|
||||
Cell crossings + stair/hill steps : per-event
|
||||
Airborne : 0 Hz (unchanged)
|
||||
|
||||
Adds _lastSentContactPlane field + extends NotePositionSent to accept
|
||||
System.Numerics.Plane. Adds PhysicsBody public accessor so the wire
|
||||
layer can read _body.ContactPlane to pass into NotePositionSent. Both
|
||||
outbound sites (MoveToState at GameWindow.cs:6450, AP at ~6476)
|
||||
updated to pass the plane.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Visual checkpoint — confirm Task 3's cadence revert before closing issues
|
||||
|
||||
Same as Task 2. Only proceed to Task 5 if all five scenarios in Task 3 Step 8 pass cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Close issues #63 and #74
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ISSUES.md`.
|
||||
|
||||
- [ ] **Step 1: Move issue #63 to "Recently closed"**
|
||||
|
||||
Find `## #63 — Server-initiated auto-walk (MoveToObject) not honored` in `docs/ISSUES.md` (around line 425). Currently `Status: OPEN`. Cut the entire `#63` block from the active issues section and paste it into the "Recently closed" section near the bottom of the file with these changes:
|
||||
|
||||
1. Change the title line from:
|
||||
```markdown
|
||||
## #63 — Server-initiated auto-walk (MoveToObject) not honored
|
||||
```
|
||||
to:
|
||||
```markdown
|
||||
## #63 — [DONE 2026-05-16 · `<TASK1_SHA>`] Server-initiated auto-walk (MoveToObject) not honored
|
||||
```
|
||||
(Replace `<TASK1_SHA>` with the actual commit SHA from Task 1. Get it via `git log --oneline -5`.)
|
||||
|
||||
2. Change `Status: OPEN` to `Status: DONE`.
|
||||
|
||||
3. Append a "Resolution" paragraph after the existing "Acceptance":
|
||||
```markdown
|
||||
**Resolution (2026-05-16):** B.6 slice 2 (2026-05-14) shipped the inbound-MoveToObject auto-walk handling. The remaining "MoveToChain callback never fires on arrival" half was tracked to ApplyAutoWalkOverlay's synthesized Forward+Run input leaking to the wire as an outbound MoveToState packet (forwardCommand=RunForward), which ACE read as "user took manual control" and used to cancel its own MoveToChain. Fix in `<TASK1_SHA>` adds a single guard at `GameWindow.cs:6410`: outbound MoveToState only fires when `!_playerController.IsServerAutoWalking`. With ACE's chain running uninterrupted, the `TryUseItem` callback (Player_Use.cs:205) fires server-side on arrival; no client retry needed. Retired the `_pendingPostArrivalAction` retry workarounds from SendUse + SendPickUp far-range paths.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move issue #74 to "Recently closed"**
|
||||
|
||||
Find `## #74 — AP cadence is per-frame-while-moving, more chatty than retail`. Same shape: cut the block, paste in "Recently closed", change title to:
|
||||
|
||||
```markdown
|
||||
## #74 — [DONE 2026-05-16 · `<TASK3_SHA>`] AP cadence is per-frame-while-moving, more chatty than retail
|
||||
```
|
||||
|
||||
Change `Status: OPEN` to `Status: DONE`. Append:
|
||||
|
||||
```markdown
|
||||
**Resolution (2026-05-16):** With #63 closed (MoveToState no longer cancels ACE's MoveToChain), the per-frame-while-moving cadence workaround is unnecessary. Reverted to retail's two-branch ShouldSendPositionEvent gate per `acclient_2013_pseudo_c.txt:700233-700285` in `<TASK3_SHA>`. Effective rate during smooth motion drops from ~60 Hz to ~1 Hz; truly idle drops from 1 Hz to 0 Hz. Cell crossings + contact-plane changes still fire mid-interval. Matches retail bit-for-bit.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/ISSUES.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: close #63 (MoveToObject not honored) + #74 (AP chattier than retail)
|
||||
|
||||
Both retired by Phase B.6. #63 fixed in <TASK1_SHA> (MoveToState
|
||||
suppression during inbound auto-walk + retry workaround retirement).
|
||||
#74 fixed in <TASK3_SHA> (AP cadence reverted to retail's two-branch
|
||||
ShouldSendPositionEvent gate now that the workaround that needed
|
||||
per-frame APs is gone).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
(Replace `<TASK1_SHA>` and `<TASK3_SHA>` with the actual SHAs.)
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec section | Plan task |
|
||||
|---|---|
|
||||
| Wire-level changes: `IsServerAutoWalking` guard at `GameWindow.cs:6410` | Task 1 Step 1 ✅ |
|
||||
| Far-range Use retry deletion | Task 1 Step 2 ✅ |
|
||||
| Far-range PickUp retry deletion | Task 1 Step 3 ✅ |
|
||||
| Log string cleanup | Task 1 Steps 2+3 ✅ |
|
||||
| `_lastSentContactPlane` field + `NotePositionSent` signature | Task 3 Steps 1+2 ✅ |
|
||||
| Retail-narrow gate | Task 3 Step 3 ✅ |
|
||||
| MTS site contactPlane wiring | Task 3 Step 4 ✅ |
|
||||
| AP site contactPlane wiring | Task 3 Step 5 ✅ |
|
||||
| Testing plan (visual scenarios) | Task 1 Step 6 + Task 3 Step 8 ✅ |
|
||||
| Close `#63` + `#74` | Task 5 ✅ |
|
||||
| Out-of-scope `#75` (status messages) | Filed as deferred — not in this plan ✅ |
|
||||
|
||||
**Placeholder scan:** No "TBD" / "implement later" / vague phrases. Every step has actual code or actual commands.
|
||||
|
||||
**Type consistency:**
|
||||
- `IsServerAutoWalking` referenced Task 1 Step 1 — already exists in code (verified at PlayerMovementController.cs:273). ✅
|
||||
- `_lastSentContactPlane : System.Numerics.Plane` defined Task 3 Step 1, used Task 3 Steps 2+3. ✅
|
||||
- `NotePositionSent(Vector3, uint, Plane, float)` defined Task 3 Step 2, called Task 3 Steps 4+5. ✅
|
||||
- `_playerController.PhysicsBody` property defined Task 3 Step 4 (conditional add if missing), used Task 3 Steps 4+5. ✅
|
||||
- `_lastSentPos`, `_lastSentCellId`, `_lastSentTime`, `_lastSentInitialized` — pre-existing from Commit B. ✅
|
||||
|
||||
**Risk / rollback:**
|
||||
|
||||
- Task 1 commit: simple revert restores the workaround.
|
||||
- Task 3 commit: simple revert restores the per-frame cadence.
|
||||
- Task 5 commit: docs-only; trivial.
|
||||
|
||||
Each task is independently revertable. If Task 3 introduces remote-view jitter (scenario 2), revert Task 3 and re-evaluate (e.g., dial down `HeartbeatInterval` from 1 s to 0.5 s).
|
||||
|
||||
---
|
||||
|
||||
## Execution handoff
|
||||
|
||||
Plan saved to `docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md`.
|
||||
|
||||
Two options for the controller:
|
||||
|
||||
1. **Subagent-Driven (recommended)** — Dispatch fresh subagent per task. Two-stage review (spec compliance + code quality) between tasks. ~3 implementer + 6 reviewer dispatches.
|
||||
|
||||
2. **Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`. Two visual-verify checkpoints (between Task 1 & Task 3, between Task 3 & Task 5).
|
||||
|
||||
Given the small scope (~30 LOC of behavior change + docs) and the two mandatory user-driven visual checkpoints, inline execution may be simpler — the subagent overhead exceeds the implementation time for tasks this small.
|
||||
|
|
@ -18,8 +18,9 @@
|
|||
- `0x006b4770` `SendPositionEvent` — `transient_state & (CONTACT_TS | ON_WALKABLE_TS)` gate.
|
||||
- `0x00588a80` `ItemHolder::UseObject` — single fire-and-forget `Event_UseEvent`.
|
||||
- `0x00564900` `Handle_Item__UseDone` — server signals "use done" inbound.
|
||||
- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine (we use `Setup.SelectionSphere` as a simpler equivalent for Stage A).
|
||||
- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine. We approximate with a screen-space rect hit-test that uses the exact rect the target indicator already draws (guarantees click area = visible bracket area, zero corner dead zones).
|
||||
- `0x00518b80` `CPartArray::GetSelectionSphere` — scale formula.
|
||||
- `0x00452e20` `SmartBox::GetObjectBoundingBox` — projects `CSetup.SelectionSphere` to a screen-aligned rect. The indicator AND the new picker call into the same projection helper.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -40,10 +41,11 @@
|
|||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `src/AcDream.App/Input/PlayerMovementController.cs` | Replace `_heartbeatAccum` with diff-driven `HeartbeatDue` (position/cell change + 1s heartbeat + `IsGrounded` gate). Add `NotePositionSent(pos, cellId, now)` + `SimTimeSeconds` accessor. Delete `TinyMargin` from `ApplyAutoWalkOverlay`. Add `ApproxPositionEqual` helper. |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to use `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. |
|
||||
| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(...)` overload taking `Func<WorldEntity, (Vector3, float)?> sphereForEntity`. Keep existing per-radius-callback overload for now (no callers after B8). |
|
||||
| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. |
|
||||
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new sphere-resolver overload. |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to the new screen-rect overload using `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. |
|
||||
| `src/AcDream.Core/Selection/ScreenProjection.cs` (new) | Shared math: `TryProjectSphereToScreenRect(worldCenter, worldRadius, view, projection, viewport, out rectMin, out rectMax, out depth, minSidePixels)`. Factored out of `TargetIndicatorPanel.TryComputeScreenRectFromSphere` so the picker AND the indicator project identically. |
|
||||
| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(mouseX, mouseY, view, projection, viewport, candidates, skipGuid, sphereForEntity, inflatePixels=8f)` screen-rect-hit-test overload. Keep existing ray-sphere overload for now (no callers after B8). |
|
||||
| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. Replace private `TryComputeScreenRectFromSphere` with a call into `ScreenProjection.TryProjectSphereToScreenRect`. |
|
||||
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new rect-hit-test overload + tests for `ScreenProjection.TryProjectSphereToScreenRect`. |
|
||||
| `docs/ISSUES.md` | File 3 deferred follow-ups (Triangle apex/size UX; Stage B polygon refine; cdb-probe to verify `omega.z = π/2`). |
|
||||
|
||||
---
|
||||
|
|
@ -620,10 +622,14 @@ Replace with:
|
|||
// - When interval NOT elapsed: send only if position or cell
|
||||
// differs from last_sent (Frame::is_equal check at
|
||||
// acclient_2013_pseudo_c.txt:700248-700265).
|
||||
// - SendPositionEvent gates on transient_state &
|
||||
// (CONTACT_TS | ON_WALKABLE_TS) — i.e., grounded on a
|
||||
// walkable surface. Airborne suppresses AP entirely.
|
||||
// MoveToState carries jump/fall snapshots while airborne.
|
||||
// - SendPositionEvent (acclient_2013_pseudo_c.txt:700327)
|
||||
// gates on `((state & 1) != 0 && (state & 2) != 0)` —
|
||||
// Contact (CONTACT_TS bit 0) AND OnWalkable (ON_WALKABLE_TS
|
||||
// bit 1) both set. Two independent `& != 0` tests joined by
|
||||
// `&&`, NOT a single bitwise-OR mask test. Airborne (neither
|
||||
// bit) and wall-contact-without-walkable (Contact only)
|
||||
// both suppress AP. MoveToState carries jump/fall snapshots
|
||||
// while airborne.
|
||||
//
|
||||
// Effective rate: per-frame while moving on the ground, 1 Hz at-rest
|
||||
// heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model.
|
||||
|
|
@ -641,20 +647,17 @@ Replace with:
|
|||
|| _lastSentCellId != CellId
|
||||
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||
|
||||
// Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS
|
||||
// (acclient.h:3688). Our equivalent: PhysicsBody.IsGrounded.
|
||||
bool groundedOnWalkable = _body.IsGrounded;
|
||||
// Grounded-on-walkable. Retail's `Contact AND OnWalkable`
|
||||
// (acclient_2013_pseudo_c.txt:700327). PhysicsBody exposes the
|
||||
// two transient-state bits as InContact + OnWalkable; combine
|
||||
// with `&&` to match retail's two-`& != 0`-tests-joined-by-`&&`
|
||||
// pattern (NOT a single bitwise-OR mask test).
|
||||
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||
|
||||
HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed);
|
||||
```
|
||||
|
||||
If `PhysicsBody.IsGrounded` doesn't exist, search the codebase for the equivalent predicate:
|
||||
|
||||
```
|
||||
grep -n "IsGrounded\|OnGround\|HasContact\|GroundContact" src/AcDream.Core/Physics/PhysicsBody.cs
|
||||
```
|
||||
|
||||
Use whichever exists. If neither, derive from `_body.ContactPlane.Normal.Z > 0.5f` (humanoid walkable-normal threshold per retail FloorZ ≈ 0.66). Document the choice in a comment at the call site.
|
||||
`PhysicsBody.InContact` and `PhysicsBody.OnWalkable` are existing convenience properties that wrap `TransientState.HasFlag(TransientStateFlags.Contact)` / `.OnWalkable`. The same `Contact && OnWalkable` "grounded" pattern is used elsewhere in our physics layer (e.g., `MotionInterpreter.cs:808-809` for jump-start gating), so this matches the codebase convention.
|
||||
|
||||
- [ ] **Step 3: Add `ApproxPositionEqual` helper**
|
||||
|
||||
|
|
@ -1010,15 +1013,204 @@ Expected: 0 errors. If there are remaining "isRetryAfterArrival" references, fix
|
|||
|
||||
---
|
||||
|
||||
### Task B7: Add `WorldPicker.Pick` overload taking sphere-resolver
|
||||
### Task B7: Factor screen-rect projection into shared helper + add screen-rect picker overload
|
||||
|
||||
**Goal:** Retail's picker uses two-tier sphere-then-polygon selection (`Render::GfxObjUnderSelectionRay` 0x0054c740). Our Stage A approximates Stage 1 (sphere reject) — but the indicator draws a SCREEN-SPACE rect, not a 3D sphere. User feedback 2026-05-16: "the range we can click + the area of the object that's clickable should be faithful to retail" — meaning the click hit-area must match what the indicator visibly bounds. A pure world-space ray-sphere picker can't make that guarantee (rect corners are sphere dead zones).
|
||||
|
||||
**Approach:** Picker hit-tests against the SAME screen-space rect the indicator draws. The projection math is factored into a shared `ScreenProjection` helper so the indicator and the picker can't drift apart.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Selection/ScreenProjection.cs`
|
||||
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
|
||||
- Modify: `src/AcDream.App/UI/TargetIndicatorPanel.cs` (use shared helper; private copy deleted)
|
||||
- Test: `tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs` (new file)
|
||||
- Test: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (modify or create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
- [ ] **Step 1: Write failing tests for `ScreenProjection`**
|
||||
|
||||
Find or create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`. Add:
|
||||
Create `tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class ScreenProjectionTests
|
||||
{
|
||||
// Standard right-handed perspective + identity view. Sphere centered
|
||||
// at z=+10 in front of camera, radius 1, viewport 800x600.
|
||||
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f /*fovY 90°*/, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800, 600);
|
||||
return (view, proj, viewport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProject_SphereInFront_ReturnsSquareRect()
|
||||
{
|
||||
// System.Numerics CreatePerspectiveFieldOfView is right-handed
|
||||
// (looks down -Z). Place sphere at -10 along Z so it sits in
|
||||
// front of the camera.
|
||||
var (view, proj, viewport) = StdCam();
|
||||
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
|
||||
new Vector3(0, 0, -10), worldRadius: 1f,
|
||||
view, proj, viewport,
|
||||
out var rMin, out var rMax, out var depth,
|
||||
minSidePixels: 0f);
|
||||
|
||||
Assert.True(ok);
|
||||
// 90° FOV at depth 10 -> screen half-extent = 1 unit projects to
|
||||
// viewport.Y/2 pixels per world-unit at near plane = 1/tan(45°).
|
||||
// The rect should be a square (width == height).
|
||||
Assert.Equal(rMax.X - rMin.X, rMax.Y - rMin.Y, precision: 3);
|
||||
// Rect should be centered (approximately) on the screen center.
|
||||
Assert.InRange((rMin.X + rMax.X) * 0.5f, 399f, 401f);
|
||||
Assert.InRange((rMin.Y + rMax.Y) * 0.5f, 299f, 301f);
|
||||
Assert.True(depth > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProject_SphereBehindCamera_ReturnsFalse()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
|
||||
new Vector3(0, 0, +10) /* behind RH camera at origin */,
|
||||
worldRadius: 1f,
|
||||
view, proj, viewport,
|
||||
out _, out _, out _,
|
||||
minSidePixels: 0f);
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProject_FarSphereClampsToMinSide()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
|
||||
new Vector3(0, 0, -90) /* very far */, worldRadius: 0.01f /* tiny */,
|
||||
view, proj, viewport,
|
||||
out var rMin, out var rMax, out _,
|
||||
minSidePixels: 12f);
|
||||
Assert.True(ok);
|
||||
Assert.True(rMax.X - rMin.X >= 12f);
|
||||
Assert.True(rMax.Y - rMin.Y >= 12f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the test project — confirm test failure**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests" --no-build
|
||||
```
|
||||
Expected: build failure (`ScreenProjection` doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Create `src/AcDream.Core/Selection/ScreenProjection.cs`**
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Shared screen-space projection math for the target indicator and the
|
||||
/// world picker. Both call into <see cref="TryProjectSphereToScreenRect"/>
|
||||
/// so the click hit-area is guaranteed to match the visible indicator
|
||||
/// rect — "what you see is what you click".
|
||||
///
|
||||
/// <para>
|
||||
/// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at
|
||||
/// <c>0x00452e20</c>, which uses
|
||||
/// <c>Render::GetViewerBBox(selection_sphere, &corner1, &corner2)</c>
|
||||
/// to compute a camera-aligned bbox of the sphere and projects the two
|
||||
/// corner points. We use the mathematical equivalent (project center,
|
||||
/// compute screen radius analytically) — both produce identical pixel
|
||||
/// rects for a standard right-handed perspective.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ScreenProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Project a world-space sphere to a screen-space axis-aligned square
|
||||
/// rectangle.
|
||||
/// </summary>
|
||||
/// <param name="worldCenter">Sphere center in world space.</param>
|
||||
/// <param name="worldRadius">Sphere radius in world space.</param>
|
||||
/// <param name="view">View matrix (System.Numerics row-vector convention).</param>
|
||||
/// <param name="projection">Projection matrix. <c>M22 = cot(fovY/2)</c>
|
||||
/// for a standard right-handed perspective.</param>
|
||||
/// <param name="viewport">Viewport size in pixels (X = width, Y = height).</param>
|
||||
/// <param name="rectMin">Out: top-left corner of the rect in viewport pixels.</param>
|
||||
/// <param name="rectMax">Out: bottom-right corner of the rect in viewport pixels.</param>
|
||||
/// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere
|
||||
/// center — use this for nearest-first sorting when multiple rects overlap.</param>
|
||||
/// <param name="minSidePixels">Minimum side length of the rect. Distant
|
||||
/// entities clamp to this so they remain pickable / visible. 12 px
|
||||
/// matches the indicator's clamp floor.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the sphere is in front of the camera and the rect was
|
||||
/// produced; <c>false</c> if the center is behind the camera
|
||||
/// (<c>clip.W <= 0</c>) or the rect is more than a screen offset
|
||||
/// from the viewport (obviously off-screen).
|
||||
/// </returns>
|
||||
public static bool TryProjectSphereToScreenRect(
|
||||
Vector3 worldCenter, float worldRadius,
|
||||
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
|
||||
out Vector2 rectMin, out Vector2 rectMax, out float depth,
|
||||
float minSidePixels = 12f)
|
||||
{
|
||||
rectMin = default;
|
||||
rectMax = default;
|
||||
depth = 0f;
|
||||
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
|
||||
if (clip.W <= 0.001f) return false;
|
||||
|
||||
depth = clip.W;
|
||||
|
||||
float ndcX = clip.X / clip.W;
|
||||
float ndcY = clip.Y / clip.W;
|
||||
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
|
||||
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
|
||||
|
||||
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
|
||||
// the camera-space distance.
|
||||
float scaleY = projection.M22;
|
||||
if (scaleY <= 0f) return false;
|
||||
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
|
||||
|
||||
// Cull obviously-off-screen entities (more than a screen away).
|
||||
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
|
||||
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
|
||||
|
||||
// Floor at minSidePixels so distant entities still get a visible /
|
||||
// clickable rect. The picker must apply the same floor as the
|
||||
// indicator or distant clicks won't match the visible bracket.
|
||||
if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
|
||||
|
||||
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
|
||||
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run `ScreenProjection` tests — confirm pass**
|
||||
|
||||
```
|
||||
dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests" --no-build
|
||||
```
|
||||
Expected: 3/3 pass.
|
||||
|
||||
- [ ] **Step 5: Write failing tests for the new `WorldPicker.Pick` rect overload**
|
||||
|
||||
In `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (create if missing):
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
|
|
@ -1027,166 +1219,280 @@ using AcDream.Core.World;
|
|||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class WorldPickerSphereOverloadTests
|
||||
public sealed class WorldPickerRectOverloadTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pick_SphereResolver_ReturnsNearestHit()
|
||||
// Same right-handed perspective as ScreenProjectionTests.
|
||||
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
||||
{
|
||||
// Two entities along the +Y axis. Sphere-resolver gives each a
|
||||
// tight world-space sphere centered on the entity. A ray from
|
||||
// origin pointing along +Y should hit the closer entity first.
|
||||
var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f };
|
||||
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 15, 0), Scale = 1f };
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800, 600);
|
||||
return (view, proj, viewport);
|
||||
}
|
||||
|
||||
var origin = new Vector3(0, 0, 0);
|
||||
var dir = new Vector3(0, 1, 0);
|
||||
Vector3 SphereCenter(WorldEntity e) => e.Position + new Vector3(0, 0, 0.9f);
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
|
||||
|
||||
// Mouse at screen center; entity at world (0,0,-10) projects to
|
||||
// screen center. Rect contains screen center → hit.
|
||||
uint? picked = WorldPicker.Pick(
|
||||
origin, dir, new[] { e1, e2 },
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: e => ((Vector3, float)?)(SphereCenter(e), 1.0f));
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_SphereResolver_NullSkipsCandidates()
|
||||
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
|
||||
{
|
||||
// Resolver returning null should make the picker skip the
|
||||
// candidate (matches retail "no Setup → not pickable" behaviour).
|
||||
var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f };
|
||||
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f };
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
|
||||
|
||||
var origin = new Vector3(0, 0, 0);
|
||||
var dir = new Vector3(0, 1, 0);
|
||||
// Mouse far from entity rect → no hit.
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 50f, mouseY: 50f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
|
||||
Assert.Null(picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
// Two entities both at screen center but different depths.
|
||||
var near = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -8), Scale = 1f };
|
||||
var far = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -15), Scale = 1f };
|
||||
|
||||
uint? picked = WorldPicker.Pick(
|
||||
origin, dir, new[] { e1, e2 },
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { far, near } /* deliberately reversed */,
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: e => e.ServerGuid == 0x10001u
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_NullResolverSkipsCandidates()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
|
||||
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -20), Scale = 1f };
|
||||
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e1, e2 },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => x.ServerGuid == 0x10001u
|
||||
? ((Vector3, float)?)null
|
||||
: ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f));
|
||||
: ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_SphereResolver_RespectsSkipServerGuid()
|
||||
public void Pick_RectHitTest_RespectsSkipServerGuid()
|
||||
{
|
||||
var e1 = new WorldEntity { ServerGuid = 0x50000001u, Position = new Vector3(0, 5, 0), Scale = 1f };
|
||||
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f };
|
||||
|
||||
var origin = new Vector3(0, 0, 0);
|
||||
var dir = new Vector3(0, 1, 0);
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var player = new WorldEntity { ServerGuid = 0x5000000Au, Position = new Vector3(0, 0, -10), Scale = 1f };
|
||||
var npc = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -15), Scale = 1f };
|
||||
|
||||
uint? picked = WorldPicker.Pick(
|
||||
origin, dir, new[] { e1, e2 },
|
||||
skipServerGuid: 0x50000001u, // skip player
|
||||
sphereForEntity: e => ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f));
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { player, npc },
|
||||
skipServerGuid: 0x5000000Au,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_InflateExpandsClickableArea()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
|
||||
|
||||
// First: with inflate=0, a mouse 30 px outside the rect misses.
|
||||
uint? withoutInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(withoutInflate);
|
||||
|
||||
// Then: same mouse position, with a 250 px inflate, now hits.
|
||||
uint? withInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 250f);
|
||||
Assert.Equal(0x10001u, withInflate);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test constructor calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / object-initializer pattern. The tests assume property initializers are allowed; if not, switch to whatever constructor the type exposes.
|
||||
NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test object-initializer calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / property pattern.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
- [ ] **Step 6: Run `WorldPicker` rect tests — confirm failure**
|
||||
|
||||
Run:
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerRectOverloadTests" --no-build
|
||||
```
|
||||
Expected: build failure ("sphereForEntity parameter not found") or runtime failure.
|
||||
Expected: build failure (the new `Pick` overload doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Add the new `Pick` overload to `WorldPicker.cs`**
|
||||
- [ ] **Step 7: Add the new `Pick` overload to `WorldPicker.cs`**
|
||||
|
||||
Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing `Pick` overload — it stays for back-compat; its consumers are removed in Task B8):
|
||||
Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing ray-sphere overload — its caller is migrated in B8, but the API stays for back-compat):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// 2026-05-16. Retail-faithful picker overload. Caller supplies a
|
||||
/// per-entity world-space sphere via <paramref name="sphereForEntity"/>
|
||||
/// — typically <see cref="Setup.SelectionSphere"/> scaled by entity
|
||||
/// scale and rotated into world space (mirroring retail
|
||||
/// CPartArray::GetSelectionSphere at 0x00518b80). Resolver returning
|
||||
/// null skips the candidate (matches retail "no Setup → not pickable").
|
||||
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
|
||||
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
|
||||
/// projects to a screen-space rectangle through
|
||||
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
|
||||
/// rect is inflated by <paramref name="inflatePixels"/> on every side
|
||||
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
|
||||
/// hit-tested against the mouse pixel. Among rects that contain the
|
||||
/// mouse, the entity with the nearest camera-space depth wins.
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces the older <see cref="Pick(Vector3,Vector3,IEnumerable{WorldEntity},uint,float,Func{uint,float}?,Func{uint,float}?)"/>
|
||||
/// overload with the per-type-radius / vertical-offset heuristics.
|
||||
/// Those heuristics existed because we didn't have the dat-supplied
|
||||
/// SelectionSphere plumbed through. With this overload, the click
|
||||
/// geometry matches what the target indicator draws — what you see
|
||||
/// is what you click.
|
||||
/// Why screen-space instead of world-space ray-sphere: the indicator
|
||||
/// draws a screen-space RECT. A world-space sphere projects to a
|
||||
/// screen CIRCLE inscribed in that rect — leaving the four rect
|
||||
/// corners as click dead zones. Per user feedback 2026-05-16, the
|
||||
/// click area must match the visible indicator extent exactly. By
|
||||
/// sharing the <see cref="ScreenProjection"/> helper with
|
||||
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
|
||||
/// cannot drift.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Stage A of the picker port. Retail also does a polygon-accurate
|
||||
/// refine via <c>CPolygon::polygon_hits_ray</c> when the sphere
|
||||
/// hits (decomp 0x0054c889) — that's Stage B, deferred until visual
|
||||
/// testing surfaces a sphere-only miss (issue #71).
|
||||
/// Resolver returning <c>null</c> skips the candidate (matches retail
|
||||
/// "no Setup → not pickable" behavior). Entities with
|
||||
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
|
||||
/// guid are also skipped.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Stage A of the picker port. Stage B (polygon refine via
|
||||
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
|
||||
/// per issue #71 — only needed if visual testing surfaces a Stage A
|
||||
/// over-pick on entities whose visible mesh is well inside the
|
||||
/// indicator rect.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="inflatePixels">Pixel inflate on each side of the
|
||||
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
|
||||
/// so the click area extends to where the visible bracket corners
|
||||
/// sit — the user perceives the inflated rect as the clickable area.</param>
|
||||
public static uint? Pick(
|
||||
System.Numerics.Vector3 origin, System.Numerics.Vector3 direction,
|
||||
float mouseX, float mouseY,
|
||||
System.Numerics.Matrix4x4 view,
|
||||
System.Numerics.Matrix4x4 projection,
|
||||
System.Numerics.Vector2 viewport,
|
||||
IEnumerable<AcDream.Core.World.WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
Func<AcDream.Core.World.WorldEntity, (System.Numerics.Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||
float maxDistance = 50f)
|
||||
float inflatePixels = 8f)
|
||||
{
|
||||
if (direction.LengthSquared() < 1e-10f) return null;
|
||||
uint? bestGuid = null;
|
||||
float bestDepth = float.PositiveInfinity;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
var sphere = sphereForEntity(entity);
|
||||
if (sphere is null) continue;
|
||||
|
||||
var (center, radius) = sphere.Value;
|
||||
if (radius <= 0f) continue;
|
||||
|
||||
// Geometric ray-sphere (same math as the older overload).
|
||||
var oc = origin - center;
|
||||
float b = System.Numerics.Vector3.Dot(oc, direction);
|
||||
float c = System.Numerics.Vector3.Dot(oc, oc) - radius * radius;
|
||||
float d = b * b - c;
|
||||
if (d < 0f) continue;
|
||||
float sqrtD = MathF.Sqrt(d);
|
||||
float t = -b - sqrtD;
|
||||
if (t < 0f) t = -b + sqrtD; // ray origin inside sphere → use far exit
|
||||
if (t < 0f) continue; // both roots behind ray
|
||||
if (t >= maxDistance) continue;
|
||||
if (t < bestT)
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
center, radius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out var depth))
|
||||
continue;
|
||||
|
||||
// Inflate by inflatePixels on each side — extend hit area to
|
||||
// where the indicator brackets sit.
|
||||
float minX = rMin.X - inflatePixels;
|
||||
float minY = rMin.Y - inflatePixels;
|
||||
float maxX = rMax.X + inflatePixels;
|
||||
float maxY = rMax.Y + inflatePixels;
|
||||
|
||||
if (mouseX < minX || mouseX > maxX) continue;
|
||||
if (mouseY < minY || mouseY > maxY) continue;
|
||||
|
||||
if (depth < bestDepth)
|
||||
{
|
||||
bestT = t;
|
||||
bestGuid = entity.ServerGuid;
|
||||
bestDepth = depth;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
- [ ] **Step 8: Refactor `TargetIndicatorPanel` to use the shared helper**
|
||||
|
||||
Run:
|
||||
In `src/AcDream.App/UI/TargetIndicatorPanel.cs`:
|
||||
|
||||
1. Delete the private `TryComputeScreenRectFromSphere` method (lines 321-356 inclusive — keep `TryProjectToScreen` since the fallback branch still uses it).
|
||||
2. Change the call site at line 217 from `TryComputeScreenRectFromSphere(sphereCenter, sphereRadius, view, projection, viewport, out var rMin, out var rMax)` to:
|
||||
|
||||
```csharp
|
||||
&& AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
|
||||
sphereCenter, sphereRadius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out _,
|
||||
minSidePixels: 12f))
|
||||
```
|
||||
dotnet build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build
|
||||
|
||||
(The `out _` discards the depth — the indicator doesn't need it. `minSidePixels: 12f` preserves the existing clamp.)
|
||||
|
||||
- [ ] **Step 9: Build all touched projects**
|
||||
|
||||
```
|
||||
Expected: 3/3 pass.
|
||||
dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||
```
|
||||
Expected: 0 errors on both.
|
||||
|
||||
- [ ] **Step 10: Run all picker + projection tests**
|
||||
|
||||
```
|
||||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests|FullyQualifiedName~WorldPickerRectOverloadTests" --no-build
|
||||
```
|
||||
Expected: 9/9 pass (3 ScreenProjection + 6 WorldPicker rect-overload).
|
||||
|
||||
---
|
||||
|
||||
### Task B8: Switch `GameWindow` picker call to the sphere-resolver overload
|
||||
### Task B8: Switch `GameWindow` picker call to the screen-rect overload
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — find the `WorldPicker.Pick` call.
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — `PickAndStoreSelection` method around line 9006.
|
||||
|
||||
- [ ] **Step 1: Locate the existing call**
|
||||
|
||||
|
|
@ -1194,34 +1500,93 @@ Run:
|
|||
```
|
||||
grep -n "WorldPicker.Pick(" src/AcDream.App/Rendering/GameWindow.cs
|
||||
```
|
||||
Expected: one match at ~line 9018.
|
||||
|
||||
- [ ] **Step 2: Replace the call with the sphere-resolver overload**
|
||||
- [ ] **Step 2: Replace the ray-build + ray-pick chain with the screen-rect picker**
|
||||
|
||||
Replace the existing `WorldPicker.Pick(...)` invocation (which uses `radiusForGuid` + `verticalOffsetForGuid` callbacks) with:
|
||||
The existing block reads (paraphrased; lines 9011-9072):
|
||||
|
||||
```csharp
|
||||
var camera = _cameraController.Active;
|
||||
var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
|
||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||
viewportW: _window.Size.X, viewportH: _window.Size.Y,
|
||||
view: camera.View, projection: camera.Projection);
|
||||
|
||||
if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
|
||||
|
||||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||
origin, direction,
|
||||
_entitiesByServerGuid.Values,
|
||||
skipServerGuid: _playerServerGuid,
|
||||
sphereForEntity: e =>
|
||||
TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
|
||||
? ((System.Numerics.Vector3, float)?)(c, r)
|
||||
: ((System.Numerics.Vector3, float)?)null,
|
||||
maxDistance: 50f);
|
||||
maxDistance: 50f,
|
||||
radiusForGuid: g => { /* per-type heuristics ... */ },
|
||||
verticalOffsetForGuid: g => { /* per-type heuristics ... */ });
|
||||
```
|
||||
|
||||
Replace the entire block with:
|
||||
|
||||
```csharp
|
||||
// 2026-05-16 — retail-faithful screen-rect picker. The hit area
|
||||
// is the same screen-space rect the target indicator draws
|
||||
// (computed via the shared AcDream.Core.Selection.ScreenProjection
|
||||
// helper). Per user feedback 2026-05-16: clicking the indicator
|
||||
// brackets — including the rect corners — must select the entity.
|
||||
// The per-type radius/offset heuristics retired here (1.0/1.6/2.0
|
||||
// m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
|
||||
// existed to make a 3D ray-sphere picker approximate the visible
|
||||
// rect; the new picker doesn't need them.
|
||||
var camera = _cameraController.Active;
|
||||
var viewport = new System.Numerics.Vector2(_window.Size.X, _window.Size.Y);
|
||||
|
||||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||
view: camera.View, projection: camera.Projection,
|
||||
viewport: viewport,
|
||||
candidates: _entitiesByServerGuid.Values,
|
||||
skipServerGuid: _playerServerGuid,
|
||||
sphereForEntity: e =>
|
||||
{
|
||||
// Authoritative: Setup's SelectionSphere (matches the
|
||||
// indicator's input).
|
||||
if (TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r))
|
||||
return ((System.Numerics.Vector3, float)?)(c, r);
|
||||
|
||||
// Fallback for entities whose Setup didn't bake a
|
||||
// SelectionSphere (rare). Synthesize a 1.5 m × scale
|
||||
// sphere centered on body-mid — same intent as B9's
|
||||
// simplified EntityHeightFor fallback, so the picker
|
||||
// and indicator agree even on the fallback path.
|
||||
float scale = 1f;
|
||||
if (_lastSpawnByGuid.TryGetValue(e.ServerGuid, out var s) && s.ObjScale is float es && es > 0f)
|
||||
scale = es;
|
||||
float half = 0.75f * scale;
|
||||
var center = e.Position + new System.Numerics.Vector3(0, 0, half);
|
||||
return ((System.Numerics.Vector3, float)?)(center, half);
|
||||
},
|
||||
// Match the indicator's TriangleSize (8 px) so the click area
|
||||
// extends out to the bracket corners — what the user perceives
|
||||
// as "selectable extent."
|
||||
inflatePixels: 8f);
|
||||
```
|
||||
|
||||
The local `origin`/`direction` variables and the `if (direction.LengthSquared() < 1e-6f) return;` guard are no longer needed — delete them along with the `BuildRay` call.
|
||||
|
||||
- [ ] **Step 3: Delete the per-type `radiusForGuid` and `verticalOffsetForGuid` lambda blocks**
|
||||
|
||||
Both lambdas (around line ~9037 and ~9054) become dead with this change. Delete them.
|
||||
Both lambdas (the entire `radiusForGuid: g => { ... }` and `verticalOffsetForGuid: g => { ... }` blocks at ~9037 and ~9054) are gone with the rewrite in Step 2. Confirm there are no dangling references via:
|
||||
|
||||
```
|
||||
grep -n "radiusForGuid\|verticalOffsetForGuid" src/AcDream.App/Rendering/GameWindow.cs
|
||||
```
|
||||
Expected: 0 matches in `GameWindow.cs` after the edit.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run:
|
||||
```
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||
```
|
||||
Expected: 0 errors. The old `WorldPicker.Pick` overload with `radiusForGuid` callback still exists in `WorldPicker.cs` but has no callers — leave it for now; not blocking.
|
||||
Expected: 0 errors. The old `WorldPicker.Pick(origin, direction, ...)` ray-sphere overload still exists in `WorldPicker.cs` but has no callers — leave it for now; cleaning it up is a follow-up.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1332,6 +1697,8 @@ User runs the client and confirms:
|
|||
4. **Pickup item from across the room.** Walks, picks up. ONE `[B.5] pickup` line.
|
||||
5. **Click the Holtburg sign.** Indicator triangles match the sign size (unchanged from previous ship). Press R. Silent no-op (`SendUse ignored — not useable`).
|
||||
6. **Click rapidly between NPC and item.** No spurious "I clicked X earlier and now it's firing on Y" cross-contamination. The `_pendingPostArrivalAction` simplification should make this clean.
|
||||
7. **Click in the indicator's bracket corners.** Hover the mouse so it sits in the rect-corner region of the indicator brackets (where the four triangle marks sit) — NOT on the entity body. Click. The entity selects. Old behaviour: corners were sphere dead zones and the click missed; new behaviour: click area = visible bracket bounding rect, corner clicks land.
|
||||
8. **Click adjacent to an entity but outside the indicator rect.** Mouse just outside the bracket extent. No selection. Old behaviour: sphere over-pick let cursor land far from the visible rect and still select; new behaviour: rect edges are tight.
|
||||
|
||||
**If any of (1)-(6) regresses, the cadence fix in Task B2 likely needs tuning. Common cause: `IsGrounded` is too restrictive (suppressing AP on slopes); relax to `ContactPlane.Normal.Z > 0.3f` or similar.**
|
||||
|
||||
|
|
@ -1344,11 +1711,13 @@ When user approves:
|
|||
```bash
|
||||
git add src/AcDream.App/Input/PlayerMovementController.cs \
|
||||
src/AcDream.App/Rendering/GameWindow.cs \
|
||||
src/AcDream.Core/Selection/ScreenProjection.cs \
|
||||
src/AcDream.Core/Selection/WorldPicker.cs \
|
||||
src/AcDream.App/UI/TargetIndicatorPanel.cs \
|
||||
tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs \
|
||||
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(retail): per-tick AP cadence + sphere picker retires 4 workarounds
|
||||
fix(retail): per-tick AP cadence + screen-rect picker retires 4 workarounds
|
||||
|
||||
Single coherent commit. Audit findings from 2026-05-16:
|
||||
|
||||
|
|
@ -1390,20 +1759,29 @@ Single coherent commit. Audit findings from 2026-05-16:
|
|||
Renamed to OnAutoWalkArrivedSendDeferredAction to clarify
|
||||
it's a FIRST send, not a retry.
|
||||
|
||||
3. WorldPicker switched to a Setup.SelectionSphere overload.
|
||||
Retail's picker uses CGfxObj.drawing_sphere + polygon refine
|
||||
(acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay),
|
||||
which we approximate via Setup.SelectionSphere (same data
|
||||
path as the target indicator since f4f4143). Effect: click
|
||||
geometry matches the visible indicator — what you see is what
|
||||
you click. Retires the per-type radius (1.0/1.5/2.0 m) and
|
||||
vertical-offset (0.9/1.0/1.5 m) heuristic callbacks.
|
||||
3. WorldPicker switched to a screen-rect hit-test against the same
|
||||
rect the target indicator draws. Retail's picker uses
|
||||
CGfxObj.drawing_sphere + polygon refine
|
||||
(acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay
|
||||
feeding SmartBox::GetObjectBoundingBox at 0x00452e20). Stage A
|
||||
approximates the sphere-reject step by projecting Setup.SelectionSphere
|
||||
to screen and hit-testing the mouse pixel against that rect
|
||||
inflated by 8 px (the indicator's TriangleSize). The new
|
||||
AcDream.Core.Selection.ScreenProjection helper is shared between
|
||||
the picker and the indicator so their rects cannot drift.
|
||||
Effect: click area matches the visible indicator extent
|
||||
bit-for-bit — including the rect corners, which were dead
|
||||
zones under the old ray-sphere picker. Retires the per-type
|
||||
radius (1.0/1.6/2.0 m) and vertical-offset (0.2/0.9/1.0/1.5 m)
|
||||
heuristic callbacks. Old ray-sphere overload remains in
|
||||
WorldPicker for back-compat; not load-bearing.
|
||||
|
||||
4. EntityHeightFor fallback trimmed to a single 1.5 m default.
|
||||
IsTallSceneryGuid deleted entirely — both became dead code
|
||||
when the picker switched to SelectionSphere.
|
||||
when the picker switched to screen-rect against SelectionSphere.
|
||||
|
||||
Test suite: 290+ Core.Net unchanged, +3 WorldPickerSphereOverloadTests.
|
||||
Test suite: 290+ Core.Net unchanged, +3 ScreenProjectionTests, +6
|
||||
WorldPickerRectOverloadTests.
|
||||
|
||||
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
|
||||
|
||||
|
|
@ -1532,7 +1910,7 @@ Deferred follow-ups from the 2026-05-16 retail-faithfulness audit
|
|||
- Fix #5 (useability fallback probe): Task A4 (flag), A5 step 2 (log lines). ✅
|
||||
- Fix #2 (per-tick diff-driven AP): Tasks B1 + B2 + B3. ✅
|
||||
- Fix #6 (delete 4 workarounds): Task B4 (TinyMargin), B5 (handler + SendAutonomousPositionNow), B6 (isRetryAfterArrival). ✅
|
||||
- Fix #3 Stage A (sphere picker): Tasks B7 + B8. ✅
|
||||
- Fix #3 Stage A (screen-rect picker against indicator rect): Tasks B7 + B8. ✅
|
||||
- Fix #7 (trim EntityHeightFor): Task B9. ✅
|
||||
- Fix #8 (delete IsTallSceneryGuid): Task B10. ✅
|
||||
- Deferred issues (triangle/Stage B/cdb): Tasks DF1, DF2, DF3. ✅
|
||||
|
|
@ -1544,7 +1922,8 @@ Deferred follow-ups from the 2026-05-16 retail-faithfulness audit
|
|||
- `NotePositionSent(Vector3, uint, float)` defined B1, called B3. ✅
|
||||
- `SimTimeSeconds` accessor defined B1, read B3. ✅
|
||||
- `OnAutoWalkArrivedSendDeferredAction()` defined Task B6 Step 3, subscribed in Task B6 Step 4. ✅
|
||||
- `WorldPicker.Pick(...sphereForEntity...)` defined Task B7, called Task B8. ✅
|
||||
- `ScreenProjection.TryProjectSphereToScreenRect(...)` defined Task B7 Step 3, called Task B7 Step 7 (WorldPicker.Pick) and Step 8 (TargetIndicatorPanel). ✅
|
||||
- `WorldPicker.Pick(mouseX, mouseY, view, projection, viewport, candidates, skipServerGuid, sphereForEntity, inflatePixels)` defined Task B7 Step 7, called Task B8 Step 2. ✅
|
||||
- `TryGetEntitySelectionSphere` referenced in Task B8 — already exists in `GameWindow.cs` at line ~9605 per audit. ✅
|
||||
- `ApproxPositionEqual` defined Task B2 Step 3, called Task B2 Step 2. ✅
|
||||
|
||||
|
|
|
|||
1706
docs/superpowers/plans/2026-05-18-retail-chase-camera.md
Normal file
1706
docs/superpowers/plans/2026-05-18-retail-chase-camera.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,964 @@
|
|||
# Indoor Cell Rendering Fix — Phase 1 Diagnostics Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add five toggleable diagnostic probes that pinpoint where the EnvCell rendering chain breaks, so Phase 2's fix can target the actual failure point.
|
||||
|
||||
**Architecture:** Single `RenderingDiagnostics` static class in `AcDream.Core.Rendering` exposes five bool flags + a master toggle (env-var-initialized, runtime-settable). DebugVM mirrors them as live-toggle properties; DebugPanel exposes them as checkboxes. Probe call sites in `WbMeshAdapter` and `WbDrawDispatcher` emit one structured `[indoor-*]` line per event when the corresponding flag is on. The Holtburg Inn floor-missing bug is the test case — log output identifies which of six hypotheses (H1–H6 in the spec) the failure matches.
|
||||
|
||||
**Tech Stack:** C# .NET 10, xUnit (test framework), Silk.NET OpenGL (rendering), Chorizite.OpenGLSDLBackend (WB ObjectMeshManager).
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-indoor-cell-rendering-fix-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Status | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | NEW | Static class with five `bool` properties + master toggle. Env-var read at startup; runtime-settable. |
|
||||
| `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` | NEW | Verify default values and get/set behavior of the diagnostic flags. |
|
||||
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | MODIFY | Add five mirror properties that forward to `RenderingDiagnostics`. |
|
||||
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | MODIFY | Add an "Indoor rendering" subsection in `DrawDiagnostics` with six checkboxes. |
|
||||
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY | Emit `[indoor-upload] requested` on first `IncrementRefCount` for an EnvCell id; emit `[indoor-upload] completed` in `Tick()` when WB's staged drain produces that id's `ObjectMeshData`. |
|
||||
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MODIFY | Emit `[indoor-walk]` + `[indoor-cull]` in `WalkVisibleEntities` per cell entity; emit `[indoor-lookup]` and `[indoor-xform]` in `DrawAccumulated` per cell-entity render-data lookup + composed transform. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `RenderingDiagnostics` static class
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs`
|
||||
|
||||
- [ ] **Step 1: Write the file**
|
||||
|
||||
The class mirrors `AcDream.Core.Physics.PhysicsDiagnostics` exactly — same env-var-init pattern, same get/set, same XML comments style. Five individual probe flags + one `IndoorAll` master. The master setter cascades to all five.
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
|
||||
/// rendering pipeline. Initialized from env vars at process start;
|
||||
/// flippable at runtime via the DebugPanel mirror. Log call sites read
|
||||
/// these statics so a checkbox toggle takes effect on the next frame
|
||||
/// without relaunching.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
|
||||
/// pattern. The master <see cref="IndoorAll"/> toggle is the user's
|
||||
/// common case — flipping it cascades to all five probe flags.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RenderingDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
||||
/// <c>[indoor-walk]</c> line per visible cell entity per second:
|
||||
/// entity id, world position, parent cell id, landblock visible flag,
|
||||
/// AABB-visible flag, "in visible cells" flag, drew flag.
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_WALK=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorWalkEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-lookup]</c>
|
||||
/// line per visible cell entity per second: render-data hit/miss,
|
||||
/// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_LOOKUP=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorLookupEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbMeshAdapter</c> emits two lines per EnvCell id:
|
||||
/// <c>[indoor-upload] requested</c> on first IncrementRefCount and
|
||||
/// <c>[indoor-upload] completed</c> when WB's staged drain produces
|
||||
/// its <c>ObjectMeshData</c>. Missing "completed" lines indicate WB
|
||||
/// silently returned null (hypothesis H1).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_UPLOAD=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorUploadEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-xform]</c>
|
||||
/// line per visible cell entity per second: cell-geometry SetupPart's
|
||||
/// composed world matrix translation. Disambiguates transform
|
||||
/// double-apply (hypothesis H5).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_XFORM=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorXformEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
||||
/// <c>[indoor-cull]</c> line per cell entity that gets culled, with
|
||||
/// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
|
||||
/// cull bugs (hypothesis H3).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_CULL=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorCullEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Master toggle. Reading reflects the AND of all five flags
|
||||
/// (true only when every probe is on). Writing cascades — setting
|
||||
/// to <see langword="true"/> turns ALL five flags on; setting to
|
||||
/// <see langword="false"/> turns ALL five off.
|
||||
/// </summary>
|
||||
public static bool IndoorAll
|
||||
{
|
||||
get => ProbeIndoorWalkEnabled
|
||||
&& ProbeIndoorLookupEnabled
|
||||
&& ProbeIndoorUploadEnabled
|
||||
&& ProbeIndoorXformEnabled
|
||||
&& ProbeIndoorCullEnabled;
|
||||
set
|
||||
{
|
||||
ProbeIndoorWalkEnabled = value;
|
||||
ProbeIndoorLookupEnabled = value;
|
||||
ProbeIndoorUploadEnabled = value;
|
||||
ProbeIndoorXformEnabled = value;
|
||||
ProbeIndoorCullEnabled = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for probe call sites. Returns <see langword="true"/> when
|
||||
/// the low 16 bits of <paramref name="id"/> are ≥ 0x0100 — the AC
|
||||
/// convention for EnvCell (indoor) cells, as opposed to outdoor cells
|
||||
/// in the 8×8 landblock grid (0x0001–0x0040).
|
||||
/// </summary>
|
||||
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug`
|
||||
Expected: 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(diagnostics): RenderingDiagnostics static class for indoor probes
|
||||
|
||||
Five toggleable bool flags + master IndoorAll cascade, mirroring the
|
||||
L.2a PhysicsDiagnostics pattern. Env vars at startup, runtime-settable
|
||||
via DebugPanel mirrors (added next task). Probe call sites and DebugVM
|
||||
wiring follow in subsequent tasks.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Unit-test `RenderingDiagnostics`
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using AcDream.Core.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering;
|
||||
|
||||
public sealed class RenderingDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void IndoorAll_True_TurnsAllFlagsOn()
|
||||
{
|
||||
// Reset all flags off first to make the test deterministic
|
||||
// regardless of env-var state on the test runner.
|
||||
RenderingDiagnostics.ProbeIndoorWalkEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorLookupEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorUploadEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorXformEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorCullEnabled = false;
|
||||
|
||||
RenderingDiagnostics.IndoorAll = true;
|
||||
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled);
|
||||
Assert.True(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_False_TurnsAllFlagsOff()
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = true; // start from all-on
|
||||
RenderingDiagnostics.IndoorAll = false;
|
||||
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled);
|
||||
Assert.False(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_OneOff_ReadsAsFalse()
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = true;
|
||||
RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off
|
||||
Assert.False(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid
|
||||
[InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix
|
||||
[InlineData(0x00000100ul, true)] // indoor cell minimum
|
||||
[InlineData(0x00000105ul, true)] // typical Holtburg Inn interior
|
||||
[InlineData(0xA9B40105ul, true)] // indoor with landblock prefix
|
||||
[InlineData(0xA9B401FFul, true)] // indoor near top of range
|
||||
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests — expect failure on first build**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RenderingDiagnostics" -c Debug --nologo`
|
||||
|
||||
Expected: Build green (Task 1 already implemented the class). All 7 tests pass (1 cascade-on + 1 cascade-off + 1 partial-off + 4 IsEnvCellId rows).
|
||||
|
||||
If any test fails, the implementation in Task 1 has a bug — go back and fix.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(diagnostics): RenderingDiagnostics cascade + IsEnvCellId rows
|
||||
|
||||
Covers the master IndoorAll cascade (both directions) and the IsEnvCellId
|
||||
helper's 0x0100 boundary check across outdoor cells, indoor cells, and
|
||||
landblock-prefixed forms.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Mirror `RenderingDiagnostics` into `DebugVM`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
|
||||
|
||||
- [ ] **Step 1: Read DebugVM and find the existing `ProbeBuilding` mirror block**
|
||||
|
||||
Find the `ProbeBuilding` property (around line 270) — that's an existing live-mirror to `PhysicsDiagnostics.ProbeBuildingEnabled`. New mirrors go immediately AFTER `ProbeAutoWalk` (next property in the file), in a new clearly-commented block.
|
||||
|
||||
- [ ] **Step 2: Add `using AcDream.Core.Rendering;` at the top of `DebugVM.cs`**
|
||||
|
||||
If the using statement is already present, skip. Otherwise insert alphabetically after `using AcDream.Core.Physics;`.
|
||||
|
||||
- [ ] **Step 3: Append the five mirror properties to the file**
|
||||
|
||||
Find the closing brace of the last existing property block (after `ProbeAutoWalk` or the last `Probe*` property). Insert this block before the class's closing brace:
|
||||
|
||||
```csharp
|
||||
// ── Indoor rendering diagnostics (2026-05-19) ───────────────────
|
||||
// Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles
|
||||
// take effect on the next render frame without relaunching.
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorWalkEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_WALK</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorWalk
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorWalkEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorLookupEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_LOOKUP</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorLookup
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorLookupEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorUploadEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_UPLOAD</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorUpload
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorUploadEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorXformEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_XFORM</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorXform
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorXformEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorXformEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorCullEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_CULL</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorCull
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorCullEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
|
||||
/// five indoor probes together.
|
||||
/// </summary>
|
||||
public bool ProbeIndoorAll
|
||||
{
|
||||
get => RenderingDiagnostics.IndoorAll;
|
||||
set => RenderingDiagnostics.IndoorAll = value;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug`
|
||||
Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; new properties compile.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(debugvm): mirror RenderingDiagnostics indoor probes
|
||||
|
||||
Live-toggle wrappers for the five indoor-rendering probe flags plus the
|
||||
ProbeIndoorAll master cascade. Pattern matches existing ProbeResolve /
|
||||
ProbeCell / ProbeBuilding / ProbeAutoWalk mirrors so a checkbox flip in
|
||||
the DebugPanel takes effect on the next frame.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Expose probes in `DebugPanel` Diagnostics group
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`
|
||||
|
||||
- [ ] **Step 1: Find `DrawDiagnostics(IPanelRenderer r)` method**
|
||||
|
||||
Open the file. Find the method at approximately line 226. The existing pattern reads probe values into locals at the top of the method, then conditionally re-assigns through checkboxes. The new indoor probes follow the same shape, appended after the last existing probe checkbox.
|
||||
|
||||
- [ ] **Step 2: Read the locals + checkboxes at the bottom of the existing block**
|
||||
|
||||
Find the line that says `if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;` or similar last existing probe checkbox in `DrawDiagnostics`. New checkboxes go immediately AFTER this line, before the method's closing brace.
|
||||
|
||||
- [ ] **Step 3: Insert the new checkboxes**
|
||||
|
||||
Before the closing brace of `DrawDiagnostics`, insert:
|
||||
|
||||
```csharp
|
||||
|
||||
// ── Indoor rendering diagnostics (2026-05-19) ───────────────
|
||||
// Pinpoint where the EnvCell rendering chain breaks for
|
||||
// hypothesis-driven Phase 2 fix. Spec:
|
||||
// docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
|
||||
r.Separator();
|
||||
r.Text("Indoor rendering (envCell):");
|
||||
|
||||
bool probeIndoorAll = _vm.ProbeIndoorAll;
|
||||
bool probeIndoorWalk = _vm.ProbeIndoorWalk;
|
||||
bool probeIndoorLookup = _vm.ProbeIndoorLookup;
|
||||
bool probeIndoorUpload = _vm.ProbeIndoorUpload;
|
||||
bool probeIndoorXform = _vm.ProbeIndoorXform;
|
||||
bool probeIndoorCull = _vm.ProbeIndoorCull;
|
||||
|
||||
if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll;
|
||||
if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk;
|
||||
if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup;
|
||||
if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload;
|
||||
if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform;
|
||||
if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull;
|
||||
```
|
||||
|
||||
Note: `r.Separator()` and `r.Text(string)` are the existing `IPanelRenderer` API methods used elsewhere in the file. If they don't exist, drop those two lines (the checkboxes still work standalone).
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug`
|
||||
Expected: 0 errors.
|
||||
|
||||
If `r.Separator()` / `r.Text()` aren't on `IPanelRenderer`, the build will fail. Remove those two lines and re-build.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(debugpanel): "Indoor rendering" probe checkboxes
|
||||
|
||||
Six checkboxes (ALL master + five individual probes) in the existing
|
||||
DrawDiagnostics block. Toggling flips the corresponding
|
||||
RenderingDiagnostics.Probe* flag live via DebugVM forwarding.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Instrument `WbMeshAdapter` with `[indoor-upload]` probes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
|
||||
|
||||
The upload probe has TWO emission points:
|
||||
1. `IncrementRefCount` — emits `requested` on the first call for an EnvCell id (gated by the existing `_metadataPopulated.Add(id)` first-call check).
|
||||
2. `Tick()` — emits `completed` when WB's `StagedMeshData` drain produces an `ObjectMeshData` whose `ObjectId` is in our pending-EnvCell set.
|
||||
|
||||
- [ ] **Step 1: Add the pending-EnvCell tracking field + `using` statement**
|
||||
|
||||
Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`).
|
||||
|
||||
Find the field declarations near the top of the class (around line 34 — `_metadataPopulated`). Add immediately after:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EnvCell ids we've requested via PrepareMeshDataAsync but not yet
|
||||
/// seen completion for in Tick(). Used by the [indoor-upload] probe
|
||||
/// to log requested + completed pairs. Cleared per completion;
|
||||
/// missing completions after a few seconds indicate WB silently
|
||||
/// returned null (hypothesis H1 in the design spec).
|
||||
/// </summary>
|
||||
private readonly HashSet<ulong> _pendingEnvCellRequests = new();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Emit `[indoor-upload] requested` in `IncrementRefCount`**
|
||||
|
||||
Find the `IncrementRefCount(ulong id)` method (around line 116). Inside the `if (_metadataPopulated.Add(id))` block, immediately AFTER the `_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);` line, add:
|
||||
|
||||
```csharp
|
||||
// [indoor-upload] requested probe — only for EnvCell ids.
|
||||
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
|
||||
{
|
||||
_pendingEnvCellRequests.Add(id);
|
||||
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Emit `[indoor-upload] completed` in `Tick`**
|
||||
|
||||
Find the `Tick()` method (around line 167). Replace the existing drain loop:
|
||||
|
||||
```csharp
|
||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
||||
{
|
||||
_meshManager.UploadMeshData(meshData);
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
||||
{
|
||||
// [indoor-upload] completed probe — check BEFORE upload so we
|
||||
// see what WB actually produced (vertex counts, parts) before
|
||||
// any post-upload mutation.
|
||||
bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled
|
||||
&& _pendingEnvCellRequests.Remove(meshData.ObjectId);
|
||||
|
||||
var renderData = _meshManager.UploadMeshData(meshData);
|
||||
|
||||
if (isPendingEnvCell)
|
||||
{
|
||||
int parts = meshData.SetupParts?.Count ?? 0;
|
||||
bool hasGeom = meshData.EnvCellGeometry is not null;
|
||||
int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0;
|
||||
bool uploadOk = renderData is not null;
|
||||
Console.WriteLine(
|
||||
$"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " +
|
||||
$"isSetup={meshData.IsSetup} parts={parts} " +
|
||||
$"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " +
|
||||
$"uploadOk={uploadOk}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(wb): [indoor-upload] probe for EnvCell mesh requests + completions
|
||||
|
||||
Instruments WbMeshAdapter at two sites:
|
||||
- IncrementRefCount: on first call for an EnvCell id (low 16 bits ≥
|
||||
0x0100), tag the id in _pendingEnvCellRequests and log
|
||||
[indoor-upload] requested.
|
||||
- Tick: when WB's StagedMeshData drains an ObjectMeshData whose
|
||||
ObjectId matches a pending EnvCell, log [indoor-upload] completed
|
||||
with parts count, EnvCellGeometry vertex count, and upload result.
|
||||
|
||||
Missing "completed" lines after "requested" identify hypothesis H1
|
||||
(WB silently returns null from PrepareEnvCellMeshData).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Instrument `WbDrawDispatcher` walk + cull probes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||||
|
||||
The `WalkVisibleEntities` method (around line 280) does landblock visibility, per-entity AABB cull, and the `visibleCellIds` filter. Cell entities (entities whose `MeshRefs[0].GfxObjId` low-16-bits ≥ 0x0100) need probes at three decision sites: passed-all, culled-by-aabb, culled-by-visibleCellIds.
|
||||
|
||||
To rate-limit, maintain a per-cellId last-log frame counter as a class-level field.
|
||||
|
||||
- [ ] **Step 1: Add the rate-limit tracking field + `using` statement**
|
||||
|
||||
Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`).
|
||||
|
||||
Find the class field declarations. Add:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Per-cell-entity last-log frame number for rate-limiting the
|
||||
/// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
|
||||
/// probes. Defaults to 30 frames at 30Hz = 1 sec.
|
||||
/// </summary>
|
||||
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
|
||||
private int _indoorProbeFrameCounter;
|
||||
private const int IndoorProbeRateLimitFrames = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
|
||||
/// frames per cellId. Caller must already have checked that an indoor
|
||||
/// probe flag is enabled.
|
||||
/// </summary>
|
||||
private bool ShouldEmitIndoorProbe(ulong cellId)
|
||||
{
|
||||
if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
|
||||
|| _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
|
||||
{
|
||||
_lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bump the frame counter at the top of `Draw(...)`**
|
||||
|
||||
Find the `Draw` method (around line 339). At its very top, after the existing `_shader.Use();` line, add:
|
||||
|
||||
```csharp
|
||||
_indoorProbeFrameCounter++;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the per-entity filter block in `WalkVisibleEntities`**
|
||||
|
||||
Find the per-entity loop in `WalkVisibleEntities` (around lines 313-335). The current shape (simplified):
|
||||
|
||||
```csharp
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||||
{
|
||||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
|
||||
continue;
|
||||
}
|
||||
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
scratch.Add((entity, i, entry.LandblockId));
|
||||
}
|
||||
```
|
||||
|
||||
Replace the entire `foreach (var entity in entry.Entities)` body with this instrumented version:
|
||||
|
||||
```csharp
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
|
||||
// is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
|
||||
// result reused for all four probe checks below.
|
||||
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
|
||||
bool isCellEntity = RenderingDiagnostics.IsEnvCellId(cellProbeId);
|
||||
|
||||
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||
&& visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||
if (!cellInVis)
|
||||
{
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& ShouldEmitIndoorProbe(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
|
||||
$"reason=visibleCellIds-miss " +
|
||||
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
bool aabbVisible = true;
|
||||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||||
{
|
||||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
|
||||
}
|
||||
|
||||
if (!aabbVisible)
|
||||
{
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& ShouldEmitIndoorProbe(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
|
||||
$"reason=frustum " +
|
||||
$"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
|
||||
$"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Passed all filters — emit walk probe.
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
|
||||
&& ShouldEmitIndoorProbe(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
|
||||
$"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
|
||||
$"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
|
||||
$"meshRef0=0x{cellProbeId:X8} " +
|
||||
$"meshRefCount={entity.MeshRefs.Count} " +
|
||||
$"landblockVisible=true aabbVisible=true cellInVis=true");
|
||||
}
|
||||
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
scratch.Add((entity, i, entry.LandblockId));
|
||||
}
|
||||
```
|
||||
|
||||
Important: `ShouldEmitIndoorProbe(cellProbeId)` is intentionally called only once per probe-decision-site per cellId, so each cellId emits at most ONE line per frame across all four probe sites (whichever fires first).
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; the new field + helper compile; the instrumented loop builds cleanly.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(dispatcher): [indoor-walk] + [indoor-cull] probes
|
||||
|
||||
Instruments WalkVisibleEntities to identify whether cell entities (first
|
||||
MeshRef.GfxObjId low-16-bits ≥ 0x0100) pass all visibility filters or
|
||||
get culled. Three emission paths:
|
||||
|
||||
- [indoor-cull] reason=visibleCellIds-miss — when the ParentCellId
|
||||
filter rejects the entity.
|
||||
- [indoor-cull] reason=frustum — when AABB frustum cull rejects.
|
||||
- [indoor-walk] — when the entity passes all filters and reaches the
|
||||
draw list.
|
||||
|
||||
Rate-limited to once per cellId per ~1 sec (30 frames at 30 Hz) via
|
||||
_lastIndoorProbeFrame dictionary. Bumped from Draw()'s top.
|
||||
|
||||
Disambiguates hypothesis H3 (cull bug — cell entity dropped before
|
||||
draw).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Instrument `WbDrawDispatcher` lookup + xform probes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||||
|
||||
These probes fire deeper in the per-MeshRef draw loop, where the render-data lookup happens and the `IsSetup` branch composes per-part transforms. The dispatcher's per-MeshRef body is around line 590-627.
|
||||
|
||||
- [ ] **Step 1: Find the per-MeshRef body and the IsSetup branch**
|
||||
|
||||
Open the file. Find the line `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` (or similar TryGetRenderData lookup inside the per-MeshRef draw loop). The relevant block is the if/else at line 607 (the `IsSetup` branch).
|
||||
|
||||
- [ ] **Step 2: Add the `[indoor-lookup]` probe at the lookup site**
|
||||
|
||||
Find the line that fetches the renderData (likely `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` or equivalent). Immediately AFTER that lookup and BEFORE the existing null/miss handling at line 595 (`if (diag) _meshesMissing++; continue;`), insert:
|
||||
|
||||
```csharp
|
||||
// [indoor-lookup] probe — emit once per cell entity per sec.
|
||||
ulong _lookupCellId = (ulong)gfxObjId;
|
||||
if (RenderingDiagnostics.IsEnvCellId(_lookupCellId)
|
||||
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
|
||||
&& ShouldEmitIndoorProbe(_lookupCellId))
|
||||
{
|
||||
bool hit = renderData is not null;
|
||||
bool isSetup = hit && renderData!.IsSetup;
|
||||
int partCount = isSetup ? renderData!.SetupParts.Count : 0;
|
||||
|
||||
int partsHit = 0, partsMiss = 0;
|
||||
if (isSetup)
|
||||
{
|
||||
foreach (var (partId, _) in renderData!.SetupParts)
|
||||
{
|
||||
if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
|
||||
else partsMiss++;
|
||||
}
|
||||
}
|
||||
|
||||
bool hasEnvCellGeom = isSetup
|
||||
&& renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[indoor-lookup] cellId=0x{_lookupCellId:X8} " +
|
||||
$"hit={hit} isSetup={isSetup} partCount={partCount} " +
|
||||
$"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
|
||||
}
|
||||
```
|
||||
|
||||
Note: this probe emits BEFORE the null-renderData early-`continue`, so a null lookup still emits `hit=false`. That's intentional — it tells us if the lookup itself failed (hypothesis H1 fallout).
|
||||
|
||||
- [ ] **Step 3: Add the `[indoor-xform]` probe inside the IsSetup branch**
|
||||
|
||||
Find the `if (renderData.IsSetup && renderData.SetupParts.Count > 0)` block (line 607 in current code). Inside the `foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)` loop, AFTER the `var model = ComposePartWorldMatrix(...)` line, insert:
|
||||
|
||||
```csharp
|
||||
// [indoor-xform] probe — only for the cell's synthetic
|
||||
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
|
||||
// line 1247). One line per cell per sec.
|
||||
if ((partGfxObjId & 0x1_0000_0000UL) != 0
|
||||
&& RenderingDiagnostics.ProbeIndoorXformEnabled
|
||||
&& ShouldEmitIndoorProbe(partGfxObjId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
|
||||
$"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
|
||||
$"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
|
||||
$"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
|
||||
$"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Test (existing tests, sanity)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Rendering" --no-build --nologo`
|
||||
Expected: All Rendering tests (including new RenderingDiagnosticsTests) pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(dispatcher): [indoor-lookup] + [indoor-xform] probes
|
||||
|
||||
Instruments the per-MeshRef draw loop in WbDrawDispatcher:
|
||||
|
||||
- [indoor-lookup]: per cell entity, dumps render-data hit/miss,
|
||||
IsSetup, parts count, and a partsHit/partsMiss tally over the
|
||||
SetupParts. Disambiguates hypothesis H2 (WB produces empty
|
||||
ObjectRenderData with zero parts) and H6 (dispatcher fails to
|
||||
traverse Setup).
|
||||
|
||||
- [indoor-xform]: only fires for the cell's synthetic geometry part
|
||||
(the SetupPart whose GfxObjId has bit 32 set, per WB's
|
||||
PrepareEnvCellMeshData cellGeomId convention). Logs the three
|
||||
composed transform translations: entityWorld, meshRef.PartTransform,
|
||||
partTransform, and the final composed matrix translation. Disambiguates
|
||||
hypothesis H5 (transform double-apply — composedT lands at 2 ×
|
||||
cellOrigin).
|
||||
|
||||
Rate-limited via existing _lastIndoorProbeFrame map.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Build + visual capture procedure
|
||||
|
||||
**Files:** none modified. Build verification + runtime data capture.
|
||||
|
||||
- [ ] **Step 1: Full solution build**
|
||||
|
||||
Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10`
|
||||
Expected: 0 errors, 0 warnings. All projects compile.
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
Run: `dotnet test AcDream.slnx -c Debug --nologo --no-build 2>&1 | tail -15`
|
||||
Expected: New RenderingDiagnostics tests pass. Pre-existing failures in `DispatcherToMovementIntegrationTests`, `BSPStepUpTests`, and `MotionInterpreterTests` (8 total) remain — those are unrelated to this work. No NEW failures.
|
||||
|
||||
- [ ] **Step 3: Gracefully close any prior AcDream.App instance**
|
||||
|
||||
```powershell
|
||||
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$proc | ForEach-Object { $_.CloseMainWindow() | Out-Null }
|
||||
$proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Launch with all indoor probes enabled**
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
$env:ACDREAM_PROBE_INDOOR_ALL = "1"
|
||||
$logPath = "launch.log"
|
||||
Remove-Item $logPath -ErrorAction SilentlyContinue
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath $logPath
|
||||
```
|
||||
|
||||
Run this in the background (the launching tool supports `run_in_background: true`).
|
||||
|
||||
- [ ] **Step 5: User reproduces the bug**
|
||||
|
||||
In the running client:
|
||||
- Wait until in-world at Holtburg (8-12 s after launch).
|
||||
- Walk to Holtburg Inn (north of spawn — Fispur's Foodstuffs is visible).
|
||||
- Stand at the doorway. Then step inside. Look at the floor.
|
||||
- Walk around the inn interior.
|
||||
- Close the client window (graceful close — close button, NOT taskkill).
|
||||
|
||||
- [ ] **Step 6: Grep the log for probe output**
|
||||
|
||||
```bash
|
||||
grep -E "\[indoor-" launch.log | head -100
|
||||
```
|
||||
|
||||
Expected: a mix of `[indoor-upload] requested`, `[indoor-upload] completed`, `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` lines for the Holtburg Inn cell IDs (0xA9B40100-ish range).
|
||||
|
||||
- [ ] **Step 7: Identify which hypothesis matches**
|
||||
|
||||
Compare the captured log against the hypothesis table in the spec (§3 of `2026-05-19-indoor-cell-rendering-fix-design.md`):
|
||||
|
||||
| Hypothesis | Probe pattern in log |
|
||||
|---|---|
|
||||
| H1 — WB silently returns null | `[indoor-upload] requested` lines exist but NO matching `completed` lines for cell ids |
|
||||
| H2 — Empty batches | `[indoor-upload] completed ... cellGeomVerts=0` |
|
||||
| H3 — Cull bug | `[indoor-cull]` lines for cell entity ids with `reason=visibleCellIds-miss` |
|
||||
| H4 — Double-spawn | `[indoor-lookup] partCount=N` where N includes static object IDs that ALSO appear in the entity walk — cross-check against `[indoor-walk]` lines |
|
||||
| H5 — Transform double-apply | `[indoor-xform] composedT` translation roughly 2× the cell's known world origin |
|
||||
| H6 — MeshRefs structure | `[indoor-lookup] hit=true isSetup=true partCount>0 partsHit=0` (all parts missing) |
|
||||
|
||||
- [ ] **Step 8: Document the captured data + matched hypothesis**
|
||||
|
||||
Create a short investigation note at `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md` summarizing:
|
||||
- The exact `[indoor-*]` log lines captured (or a representative subset).
|
||||
- The matched hypothesis number.
|
||||
- A one-line proposed fix sketch.
|
||||
|
||||
This file will be referenced by Phase 2's spec.
|
||||
|
||||
- [ ] **Step 9: Commit the capture note**
|
||||
|
||||
```bash
|
||||
git add docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(research): Phase 1 indoor probe capture — identifies hypothesis HX
|
||||
|
||||
[Replace HX with the matched hypothesis number, and summarize the
|
||||
captured log evidence in 1-2 sentences.]
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Hand off to Phase 2 design**
|
||||
|
||||
The captured data is now the input to Phase 2's design. Either:
|
||||
- Amend `docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md` with a Phase 2 section, OR
|
||||
- Write a new spec `docs/superpowers/specs/YYYY-MM-DD-indoor-cell-rendering-phase2-fix-design.md` targeting the identified hypothesis.
|
||||
|
||||
The plan for Phase 2 follows the standard brainstorming → writing-plans → executing-plans flow.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] All eight tasks complete + committed.
|
||||
- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged).
|
||||
- [ ] Probe captured at Holtburg Inn produces enough log evidence to identify which of H1-H6 is the root cause.
|
||||
- [ ] Capture note written and committed.
|
||||
- [ ] Phase 2 design follow-up spec started.
|
||||
1846
docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Normal file
1846
docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
Normal file
File diff suppressed because it is too large
Load diff
1249
docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md
Normal file
1249
docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,550 @@
|
|||
# Indoor Cell Rendering Fix — Phase 2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Surface WB's silent `PrepareEnvCellMeshData` failures via an exception-capturing continuation in `WbMeshAdapter`, identify the root cause for the 26 missing-completion cells, then implement the targeted fix that lands the indoor floor rendering.
|
||||
|
||||
**Architecture:** `WbMeshAdapter.IncrementRefCount` captures the `Task<ObjectMeshData?>` returned by WB's `PrepareMeshDataAsync` and attaches a `ContinueWith` that logs faulted-task exceptions + clean-null results for EnvCell IDs only. Gated by the existing `ProbeIndoorUploadEnabled` flag — zero cost when off. Component 3 (the actual fix) is data-driven: the captured exception type + message determines the surgical code change.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, WorldBuilder's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager`. xUnit for any unit tests.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md).
|
||||
**Phase 1 capture:** [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../../research/2026-05-19-indoor-cell-rendering-probe-capture.md).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Status | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY (Task 1) | Capture `prepTask` from `PrepareMeshDataAsync`. Attach a `ContinueWith` for EnvCell IDs that emits `[indoor-upload] FAILED` on faulted tasks and `[indoor-upload] NULL_RESULT` on clean-null returns. |
|
||||
| `launch.log` (and the user's walk-through) | NEW (Task 2) | Captured probe output. Drives Component 3's fix shape. Not committed. |
|
||||
| `docs/research/2026-05-19-indoor-cell-rendering-cause.md` | NEW (Task 3) | One-page report documenting the captured exception type(s) + the chosen fix shape. Becomes Phase 2's "design closure" doc. |
|
||||
| TBD-by-data (Component 3) | MODIFY (Task 4) | Fix shape depends on captured cause. Likely candidates: `WbMeshAdapter.PopulateMetadata`, `CellMesh.Build`, a guard at the dat-access call site, or a small WB fork patch. |
|
||||
| `docs/research/2026-05-19-indoor-cell-rendering-verification.md` | NEW (Task 5) | Post-fix verification record: previously-missing cells now emit `[indoor-upload] completed`, visual confirmation. |
|
||||
| `docs/plans/2026-04-11-roadmap.md` | MODIFY (Task 6) | Roadmap update: Phase 2 shipped, link to spec + research notes. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add exception-surfacing continuation in `WbMeshAdapter`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
|
||||
|
||||
- [ ] **Step 1: Add `using System.Linq;` and `using System.Threading.Tasks;` if missing**
|
||||
|
||||
Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Verify both `using System.Linq;` and `using System.Threading.Tasks;` are present at the top. Add them if not.
|
||||
|
||||
- [ ] **Step 2: Replace the fire-and-forget call with a captured task + continuation**
|
||||
|
||||
Find the `IncrementRefCount` method (around line 116). The current block looks like:
|
||||
|
||||
```csharp
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
if (_isUninitialized || _meshManager is null) return;
|
||||
_meshManager.IncrementRefCount(id);
|
||||
|
||||
if (_metadataPopulated.Add(id))
|
||||
{
|
||||
PopulateMetadata(id);
|
||||
|
||||
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
||||
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
|
||||
// so the background workers actually decode the GfxObj. The result
|
||||
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
|
||||
// which Tick() drains onto the GPU. Until that completes,
|
||||
// TryGetRenderData(id) returns null and the dispatcher silently
|
||||
// skips the entity — standard streaming flicker.
|
||||
//
|
||||
// isSetup: false — acdream's MeshRefs already carry expanded
|
||||
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
||||
// unused.
|
||||
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
|
||||
// [indoor-upload] requested probe — only for EnvCell ids.
|
||||
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
|
||||
{
|
||||
_pendingEnvCellRequests.Add(id);
|
||||
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace the `_metadataPopulated.Add(id)` block body with this exact content (note: the `_ = _meshManager.PrepareMeshDataAsync(...)` line becomes `var prepTask = ...` — capture the task instead of discarding it):
|
||||
|
||||
```csharp
|
||||
PopulateMetadata(id);
|
||||
|
||||
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
||||
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
|
||||
// so the background workers actually decode the GfxObj. The result
|
||||
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
|
||||
// which Tick() drains onto the GPU. Until that completes,
|
||||
// TryGetRenderData(id) returns null and the dispatcher silently
|
||||
// skips the entity — standard streaming flicker.
|
||||
//
|
||||
// isSetup: false — acdream's MeshRefs already carry expanded
|
||||
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
||||
// unused.
|
||||
var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
|
||||
// [indoor-upload] requested probe — only for EnvCell ids.
|
||||
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
|
||||
{
|
||||
_pendingEnvCellRequests.Add(id);
|
||||
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
|
||||
|
||||
// Phase 2 — surface what WB's catch block silently swallows.
|
||||
// ObjectMeshManager.PrepareMeshData has try/catch at line 589
|
||||
// that calls _logger.LogError on exceptions and returns null.
|
||||
// We construct ObjectMeshManager with NullLogger so the log
|
||||
// goes nowhere. This continuation captures the same data
|
||||
// (scoped to EnvCell ids only). Runs on ThreadPool; non-
|
||||
// blocking. Zero cost when probe is off.
|
||||
ulong cellId = id;
|
||||
_ = prepTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted && t.Exception is not null)
|
||||
{
|
||||
var ex = t.Exception.InnerException ?? t.Exception;
|
||||
var stack = (ex.StackTrace ?? "").Split('\n')
|
||||
.Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
|
||||
Console.WriteLine(
|
||||
$"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
|
||||
$"exception={ex.GetType().Name}: {ex.Message} " +
|
||||
$"stack=[{string.Join(" | ", stack)}]");
|
||||
}
|
||||
else if (t.IsCompletedSuccessfully && t.Result is null)
|
||||
{
|
||||
Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}");
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||||
Expected: 0 errors, 0 warnings (any new warnings about discarded tasks are fixed by the `_ = prepTask.ContinueWith(...)` assignment).
|
||||
|
||||
- [ ] **Step 4: Run tests (sanity)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Rendering" -c Debug --nologo --no-build`
|
||||
Expected: All 130 Rendering tests still pass (the change doesn't touch any tested code path — `WbMeshAdapter.IncrementRefCount` isn't covered by unit tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(wb): surface WB-swallowed exceptions for EnvCell upload failures
|
||||
|
||||
Phase 1 confirmed 26/123 Holtburg cells silently fail in WB's
|
||||
PrepareEnvCellMeshData / PrepareMeshData. WB's catch block at
|
||||
ObjectMeshManager.cs:589 calls _logger.LogError(ex, ...) — but we
|
||||
construct ObjectMeshManager with NullLogger, so the log is dropped.
|
||||
|
||||
Capture the Task from PrepareMeshDataAsync (previously fire-and-forget)
|
||||
and attach a ContinueWith that, for EnvCell ids only when the probe
|
||||
is on, logs:
|
||||
|
||||
[indoor-upload] FAILED cellId=0x... exception=<Type>: <Message>
|
||||
stack=[<top 3 frames>]
|
||||
[indoor-upload] NULL_RESULT cellId=0x...
|
||||
|
||||
Runs on ThreadPool — non-blocking. Zero cost when ProbeIndoorUploadEnabled
|
||||
is off. AggregateException is unwrapped to InnerException for readability.
|
||||
Stack truncated to top 3 frames.
|
||||
|
||||
Next: capture procedure, identify cause, target the fix.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Capture procedure — run client, identify cause
|
||||
|
||||
This task is operator-driven, not subagent-driven. The user (not a subagent) walks the client. Subagent role is limited to launching + analyzing the log.
|
||||
|
||||
**Files:**
|
||||
- New: `launch.log` (transient — not committed)
|
||||
|
||||
- [ ] **Step 1: Full solution build (sanity)**
|
||||
|
||||
Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10`
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 2: Gracefully close any prior `AcDream.App` instance**
|
||||
|
||||
```powershell
|
||||
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$proc | ForEach-Object { $_.CloseMainWindow() | Out-Null }
|
||||
$proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`**
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
$env:ACDREAM_PROBE_INDOOR_UPLOAD = "1"
|
||||
$logPath = "launch.log"
|
||||
Remove-Item $logPath -ErrorAction SilentlyContinue
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath $logPath
|
||||
```
|
||||
|
||||
Run in background via `run_in_background: true`.
|
||||
|
||||
- [ ] **Step 4: User walks Holtburg**
|
||||
|
||||
User waits for the client to reach in-world (~8-12 s), then:
|
||||
- Walks into Holtburg Inn (where the floor was missing in Phase 1).
|
||||
- Walks into 2-3 other nearby buildings to capture varied failure causes.
|
||||
- Closes the client window with the close button (graceful — NOT taskkill).
|
||||
|
||||
- [ ] **Step 5: Analyze the log**
|
||||
|
||||
```powershell
|
||||
$lines = Get-Content launch.log | Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' }
|
||||
Write-Host "Total failure lines: $($lines.Count)"
|
||||
Write-Host ""
|
||||
Write-Host "=== Distinct exception types (FAILED) ==="
|
||||
$lines | Where-Object { $_ -match '\[indoor-upload\] FAILED' } |
|
||||
ForEach-Object { if ($_ -match 'exception=(\w+):') { $matches[1] } } |
|
||||
Group-Object | Sort-Object Count -Descending | Format-Table -AutoSize
|
||||
|
||||
Write-Host "=== Distinct NULL_RESULT count ==="
|
||||
($lines | Where-Object { $_ -match 'NULL_RESULT' }).Count
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Sample FAILED lines ==="
|
||||
$lines | Where-Object { $_ -match '\[indoor-upload\] FAILED' } | Select-Object -First 10
|
||||
Write-Host ""
|
||||
Write-Host "=== Sample NULL_RESULT lines ==="
|
||||
$lines | Where-Object { $_ -match '\[indoor-upload\] NULL_RESULT' } | Select-Object -First 5
|
||||
```
|
||||
|
||||
Verify the previously-failing cells (from Phase 1: `0xA9B40100`, `0xA9B40111`, `0xA9B40112`, etc.) now appear in either FAILED or NULL_RESULT.
|
||||
|
||||
If they DON'T appear:
|
||||
- Confirm the probe flag is on (check `$env:ACDREAM_PROBE_INDOOR_UPLOAD` reads `"1"`).
|
||||
- Confirm the user actually walked into the failing cells.
|
||||
- Possible BUG: the continuation isn't firing — check Task 1's edits for typos.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Write the cause report
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/research/2026-05-19-indoor-cell-rendering-cause.md`
|
||||
|
||||
- [ ] **Step 1: Write the report based on Task 2's output**
|
||||
|
||||
Create the file with this structure (replace bracketed sections with captured data):
|
||||
|
||||
```markdown
|
||||
# Indoor Cell Rendering — Phase 2 Cause Report
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
|
||||
**Capture method:** Task 1's `ContinueWith` surfaced WB's swallowed exceptions for EnvCell IDs.
|
||||
|
||||
## Cause(s)
|
||||
|
||||
[Replace this section with the captured findings. Example shape:]
|
||||
|
||||
Two distinct failure modes captured at Holtburg:
|
||||
|
||||
1. **`KeyNotFoundException` — N cells affected** — Exception thrown from `PrepareCellStructMeshData` line XXX when trying to look up surface `0x08001234`. Affected cells: `0xA9B40100`, `0xA9B40111`, ...
|
||||
|
||||
2. **`NULL_RESULT` — M cells affected** — WB's `ResolveId` returned empty for `EnvironmentId 0xD000XXXX`, causing `PrepareEnvCellMeshData` to skip the cellGeometry branch and produce an empty result. Affected cells: ...
|
||||
|
||||
[OR if only one cause is observed:]
|
||||
|
||||
Single failure mode: [exception type] thrown in [location] for all 26 cells. Root cause: [analysis].
|
||||
|
||||
## Sample log lines
|
||||
|
||||
```
|
||||
[paste 5-10 actual captured FAILED / NULL_RESULT lines here]
|
||||
```
|
||||
|
||||
## Proposed fix
|
||||
|
||||
[Concrete code change for each distinct cause. For example:]
|
||||
|
||||
- For `KeyNotFoundException` on surface lookup: add a null-guard in `WbMeshAdapter.PopulateMetadata` AND skip the failing surface in our acdream-side processing.
|
||||
- For `NULL_RESULT` from missing `EnvironmentId`: log + skip with a sentinel render-data so the dispatcher gracefully draws nothing instead of failing silently.
|
||||
|
||||
Each fix is a single-file change. Task 4 of this plan implements them.
|
||||
|
||||
## Verification approach
|
||||
|
||||
After Task 4's fix:
|
||||
- Re-launch with the same probe flag.
|
||||
- Confirm previously-failing cells now emit `[indoor-upload] completed` lines.
|
||||
- Visual: floor renders in Holtburg Inn.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/research/2026-05-19-indoor-cell-rendering-cause.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(research): Phase 2 cause report — <one-line summary of finding>
|
||||
|
||||
Captured at Holtburg with the ContinueWith-based exception surfacer
|
||||
from Task 1. <Describe finding in 2-3 sentences: which exception types
|
||||
fired, for how many cells, the root cause.>
|
||||
|
||||
Fix shape decided: <one sentence>. Implemented in next commit.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Apply the targeted fix
|
||||
|
||||
**The fix shape is unknown until Task 2 captures.** This task's code is data-driven. The plan below lists the four most likely fix shapes; the implementer picks the matching one(s) and implements them.
|
||||
|
||||
### 4a — If the cause is `KeyNotFoundException` / missing dat record
|
||||
|
||||
Most likely path: WB's `PrepareCellStructMeshData` calls `_dats.Portal.TryGet<Surface>(surfaceId, out var surface)`, gets `false`, then crashes when later code assumes non-null.
|
||||
|
||||
**Files:**
|
||||
- Modify: TBD by exception stack — likely a WB fork patch OR a guard at our acdream call site.
|
||||
|
||||
- [ ] **Step 1: Open the throwing file based on the exception stack trace**
|
||||
|
||||
The probe line will show:
|
||||
```
|
||||
stack=[at PrepareCellStructMeshData in ObjectMeshManager.cs:line | at PrepareEnvCellMeshData in ObjectMeshManager.cs:line | ...]
|
||||
```
|
||||
|
||||
Open that file at that line. Confirm the missing-dat-record assumption.
|
||||
|
||||
- [ ] **Step 2: Patch shape (WB fork, if in WB)**
|
||||
|
||||
In `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`, add a null-guard at the throwing line:
|
||||
|
||||
```csharp
|
||||
// Pre-Phase-2: WB assumed every surface in envCell.Surfaces was
|
||||
// resolvable. Some Holtburg cells reference surfaces that aren't in the
|
||||
// loaded portal dat, causing a NullRef in the throwing line below.
|
||||
// Guard: skip the surface if it doesn't resolve.
|
||||
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface))
|
||||
{
|
||||
continue; // or: surface = _fallbackSurface; whichever fits
|
||||
}
|
||||
```
|
||||
|
||||
(Exact code depends on the stack. The implementer reads the actual throwing line and adapts.)
|
||||
|
||||
- [ ] **Step 3: Build, capture, verify**
|
||||
|
||||
```bash
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||
```
|
||||
|
||||
Then re-run Task 2's launch + capture. Confirm:
|
||||
- Previously-failing cells now have `[indoor-upload] completed` lines.
|
||||
- No new `[indoor-upload] FAILED` lines for those cells.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs
|
||||
# OR whatever file was patched
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(wb): null-guard for missing surface in PrepareCellStructMeshData
|
||||
|
||||
Phase 2 capture found <N> Holtburg cells silently failing with
|
||||
<ExceptionType> thrown at <file>:<line> when WB tried to look up
|
||||
surface 0x... that isn't resolvable in the loaded portal dat.
|
||||
|
||||
Patch: <one-sentence description of the guard>.
|
||||
|
||||
Visual-verified: floor now renders in Holtburg Inn.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### 4b — If the cause is `NULL_RESULT` (clean null return from WB)
|
||||
|
||||
WB's `PrepareMeshData` returns null without throwing. Examined paths in the WB source:
|
||||
- Line 568: `_dats.Portal.TryGet<Environment>(envId, ...)` fails → returns null.
|
||||
- Line 583: `type == DBObjType.Unknown` (ResolveId didn't classify the record) → returns null.
|
||||
|
||||
**Files:**
|
||||
- Modify: probably WbMeshAdapter to detect and log, then either accept the cell as "no geometry" gracefully OR investigate the dat issue.
|
||||
|
||||
- [ ] **Step 1: Read which path triggered**
|
||||
|
||||
Look at the `NULL_RESULT` cells' EnvironmentId values. If the EnvironmentId looks corrupt or out of range, the dat is the issue. If it looks valid, WB's `ResolveId` is broken for that record.
|
||||
|
||||
- [ ] **Step 2: Add a guard at our acdream call site OR patch WB**
|
||||
|
||||
Depending on the finding:
|
||||
- **If dat is genuinely missing data**: skip the cell with a warning. Don't try to render its mesh. Log once via memory.
|
||||
- **If WB's ResolveId mis-classifies**: patch WB or work around by pre-checking with our own `_dats.Get<EnvCell>(envCellId)` before calling `IncrementRefCount`.
|
||||
|
||||
- [ ] **Step 3: Build, capture, verify, commit** (same pattern as 4a Step 3-4).
|
||||
|
||||
### 4c — If the cause is a `NullReferenceException` in our code path
|
||||
|
||||
Less likely but possible — if `PopulateMetadata` or `CellMesh.Build` crashes when invoked from a worker thread.
|
||||
|
||||
**Files:**
|
||||
- Modify: the specific acdream file the stack trace points to.
|
||||
|
||||
- [ ] **Step 1: Read the throwing line**
|
||||
- [ ] **Step 2: Add the appropriate null-guard**
|
||||
- [ ] **Step 3: Build, capture, verify, commit.**
|
||||
|
||||
### 4d — If the cause is something else entirely
|
||||
|
||||
If the captured exception type doesn't match 4a-4c, **STOP and re-design**. The fix shape needs the implementer's judgment + possibly a fresh brainstorm session. Don't paper over the cause with a generic try/catch.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Verification + visualization
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/research/2026-05-19-indoor-cell-rendering-verification.md`
|
||||
|
||||
- [ ] **Step 1: Re-launch with the probe and re-walk Holtburg**
|
||||
|
||||
Same as Task 2 Steps 2-4, but expectation flipped: `[indoor-upload] FAILED` / `NULL_RESULT` lines for previously-failing cells should NOT appear; `[indoor-upload] completed` lines should appear instead.
|
||||
|
||||
- [ ] **Step 2: Visual verification by user**
|
||||
|
||||
User walks into Holtburg Inn AND the other buildings whose cells were previously missing. Expected: floors visible, no missing geometry.
|
||||
|
||||
- [ ] **Step 3: Write the verification report**
|
||||
|
||||
Create the file documenting:
|
||||
|
||||
```markdown
|
||||
# Indoor Cell Rendering — Phase 2 Verification
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Outcome:** Floor renders in Holtburg Inn.
|
||||
|
||||
## Probe re-capture
|
||||
|
||||
After Task 4's fix:
|
||||
- Previously-failing cells: <list — e.g. `0xA9B40100`, `0xA9B40111`, ...>
|
||||
- Now emit `[indoor-upload] completed cellId=0x... isSetup=True hasEnvCellGeom=True cellGeomVerts=<N> uploadOk=True`
|
||||
- No new `[indoor-upload] FAILED` or `NULL_RESULT` lines for these cells.
|
||||
|
||||
## Visual confirmation
|
||||
|
||||
User walked into:
|
||||
- Holtburg Inn — floor visible. ✓
|
||||
- <other buildings tested> — floor visible. ✓
|
||||
|
||||
## Regressions checked
|
||||
|
||||
- Outdoor terrain still renders correctly. ✓
|
||||
- NPCs, mobs, scenery still render. ✓
|
||||
- No new build warnings, no new test failures.
|
||||
|
||||
## Closes
|
||||
|
||||
This concludes Phase 2 of the indoor cell rendering fix.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/research/2026-05-19-indoor-cell-rendering-verification.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(research): Phase 2 verification — floor renders in Holtburg Inn
|
||||
|
||||
Post-fix re-capture confirms previously-failing cells now emit
|
||||
[indoor-upload] completed. Visual verification by user confirms
|
||||
floors visible in Holtburg Inn and <other tested buildings>.
|
||||
|
||||
Phase 2 complete.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Roadmap update
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-04-11-roadmap.md`
|
||||
|
||||
- [ ] **Step 1: Read the roadmap's "shipped" section**
|
||||
|
||||
Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (likely near the top, in a "shipped" table or chronological list).
|
||||
|
||||
- [ ] **Step 2: Add an entry for Phase 2 indoor cell rendering fix**
|
||||
|
||||
Add an entry matching the existing pattern of shipped-row entries. Example shape:
|
||||
|
||||
```markdown
|
||||
| <next row number> | 2026-05-19 | Indoor cell rendering — Phase 1 (diagnostics) + Phase 2 (fix) | Surfaced + fixed WB's silent failure for 26/123 Holtburg cells. Spec at [phase 1](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md) + [phase 2](../superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md). Cause: <one-line>. Fix: <one-line>. Visual-verified at Holtburg Inn. |
|
||||
```
|
||||
|
||||
(Read the actual existing row format and match it.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-04-11-roadmap.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(roadmap): Phase 2 indoor cell rendering fix shipped
|
||||
|
||||
Phase 1 diagnostics + Phase 2 fix landed today. Indoor floor rendering
|
||||
restored for Holtburg cells previously missing due to WB silent
|
||||
failure. Spec, plan, and verification documents committed.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Task 1 commits: `WbMeshAdapter.IncrementRefCount` attaches the continuation. `dotnet build` clean.
|
||||
- [ ] Task 2 capture: `[indoor-upload] FAILED` or `NULL_RESULT` lines fire for previously-failing cells. Distinct cause(s) identified.
|
||||
- [ ] Task 3 cause report: documented in `docs/research/2026-05-19-indoor-cell-rendering-cause.md`.
|
||||
- [ ] Task 4 fix: applied + committed. Build clean. Tests clean (no new failures; pre-existing 8 physics/input failures unchanged).
|
||||
- [ ] Task 5 verification: post-fix probe re-capture confirms `[indoor-upload] completed` for previously-failing cells. User visually confirms floor renders in Holtburg Inn.
|
||||
- [ ] Task 6 roadmap update: shipped row added.
|
||||
|
||||
---
|
||||
|
||||
## Subagent dispatch notes
|
||||
|
||||
- **Task 1** is mechanical (well-specified code edit) — dispatch to Sonnet.
|
||||
- **Task 2** is operator-driven — the controller (parent) drives the launch + capture, not a subagent. The user MUST walk the client.
|
||||
- **Task 3** is analytical (interpret captured data) — controller writes inline, or dispatch a Sonnet subagent with the captured log as context.
|
||||
- **Task 4** is judgment-intensive (fix shape depends on data) — controller writes inline. If complex, a fresh brainstorm may be needed.
|
||||
- **Task 5** is similar to Task 2 (user-driven walk + analysis).
|
||||
- **Task 6** is mechanical — dispatch to Sonnet OR controller writes inline.
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
# Phase B.6 — Suppress outbound `MoveToState` during inbound MoveToObject auto-walk
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Closes:** [#63](../../ISSUES.md) (server-initiated auto-walk not honored — the half about ACE's `MoveToChain` callback never firing), [#74](../../ISSUES.md) (AP cadence chattier than retail)
|
||||
**Milestone:** M1 — Walkable + clickable world
|
||||
|
||||
## Goal
|
||||
|
||||
Make ACE's server-side `MoveToChain` callback fire the Use action on arrival, eliminating the client-side retry workaround that today's `feat(retail): Commit B` (`b5da17d`) restored as an issue-#63 mitigation.
|
||||
|
||||
## Background
|
||||
|
||||
When the player double-clicks an NPC across the room in our current build:
|
||||
|
||||
1. We send `Use(guid)` wire packet immediately at click.
|
||||
2. ACE: player not in `WithinUseRadius` → server-side `CreateMoveToChain(item, (success) => TryUseItem(item, success))` ([`Player_Use.cs:205`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs)).
|
||||
3. ACE broadcasts `UpdateMotion` with `MovementType=6` (MoveToObject) targeting our player.
|
||||
4. Our client honors it via `BeginServerAutoWalk(...)` at [`GameWindow.cs:3389-3414`](../../../src/AcDream.App/Rendering/GameWindow.cs).
|
||||
5. The auto-walk overlay at [`PlayerMovementController.ApplyAutoWalkOverlay`](../../../src/AcDream.App/Input/PlayerMovementController.cs) synthesizes `input.Forward = true, input.Run = true` (line 611-619) so the rest of `Update` runs as if the user pressed W.
|
||||
6. **Bug:** the rest of `Update` computes `MotionStateChanged = true` and the outbound wire layer at [`GameWindow.cs:6410-6445`](../../../src/AcDream.App/Rendering/GameWindow.cs) builds and sends a `MoveToState` packet with `forwardCommand=RunForward, holdKey=Run`.
|
||||
7. ACE reads that as **user took manual control** and cancels its own `MoveToChain` — the `TryUseItem` callback never fires.
|
||||
8. We've worked around this by sending `Use` a second time on local-arrival (`_pendingPostArrivalAction` in [`GameWindow.SendUse` far-range path](../../../src/AcDream.App/Rendering/GameWindow.cs)). ACE accepts the second Use because we're now in range. **This is what we're retiring.**
|
||||
|
||||
The retail client and holtburger's Rust client don't have this bug: neither synthesizes player-input MoveToState during inbound MoveToObject. Their auto-walk is body-level only; ACE's chain runs uninterrupted; the callback fires on arrival.
|
||||
|
||||
### Retail anchor
|
||||
|
||||
- ACE `Player_Use.cs:205` — `CreateMoveToChain(item, (success) => TryUseItem(item, success))`.
|
||||
- ACE `Player_Move.cs:150` — chain polls and fires `callback(true)` when within use radius.
|
||||
- Holtburger [`simulation.rs:178-206`](../../../references/holtburger/crates/holtburger-core/src/client/simulation.rs) — `MoveToObject` handler returns `Ok(Vec::new())`, no outbound packets in response. No retry mechanism anywhere in its codebase.
|
||||
|
||||
## Design
|
||||
|
||||
Single-flag suppression of the outbound `MoveToState` send while a server-initiated auto-walk is active. The body keeps walking locally (synth still drives the animation cycle + per-frame position update), `AutonomousPosition` keeps flowing (so ACE's `WithinUseRadius` poll sees us approach), but the misleading "user is RunForward" packet is silenced.
|
||||
|
||||
### Wire-level changes
|
||||
|
||||
**File: [`src/AcDream.App/Input/PlayerMovementController.cs`](../../../src/AcDream.App/Input/PlayerMovementController.cs)**
|
||||
|
||||
No new field needed — [`IsServerAutoWalking`](../../../src/AcDream.App/Input/PlayerMovementController.cs:273) already exists, added by B.6 slice 2 (2026-05-14) for this purpose. Backs onto private `_autoWalkActive` (line 254). Set true by `BeginServerAutoWalk`, false by `EndServerAutoWalk`. User-input cancellation at line 478-482 already clears the flag, so manual takeover transitions correctly to "user is in control, send MoveToState normally."
|
||||
|
||||
**File: [`src/AcDream.App/Rendering/GameWindow.cs`](../../../src/AcDream.App/Rendering/GameWindow.cs)**
|
||||
|
||||
Guard the `MoveToState.Build` block at lines 6410-6445 with a second condition:
|
||||
|
||||
```csharp
|
||||
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||
{
|
||||
// ... existing MoveToState build + SendGameAction ...
|
||||
}
|
||||
```
|
||||
|
||||
No other path needs adjustment; the AutonomousPosition path remains unchanged (heartbeat-driven, gated on `Contact && OnWalkable`).
|
||||
|
||||
Single-clause add; `IsServerAutoWalking` already documented at line 267-273 as the predicate for "the next Update synthesizes Forward+Run."
|
||||
|
||||
### Workaround retirement
|
||||
|
||||
After the wire fix is verified, the following Commit B safeguards are deleted:
|
||||
|
||||
1. **Far-range Use retry.** In [`GameWindow.SendUse`](../../../src/AcDream.App/Rendering/GameWindow.cs) (~line 9201-9204), remove the `_pendingPostArrivalAction = (guid, false);` assignment from the far-range path and rewrite the comment block to say "ACE auto-fires Use via MoveToChain callback; client sends Use exactly once."
|
||||
|
||||
2. **Far-range PickUp retry.** Same shape in [`GameWindow.SendPickUp`](../../../src/AcDream.App/Rendering/GameWindow.cs) (~line 9265).
|
||||
|
||||
3. **Log strings.** Drop `(queued for arrival re-send pending #63)` from the same paths.
|
||||
|
||||
4. **AP cadence revert.** Replace the per-frame `positionChanged` gate in [`PlayerMovementController.cs:1261-1275`](../../../src/AcDream.App/Input/PlayerMovementController.cs:1261) with retail's narrow gate per `acclient_2013_pseudo_c.txt:700233-700285`:
|
||||
|
||||
```csharp
|
||||
// During sub-interval: send only on cell-or-contact-plane change.
|
||||
// After interval elapses: send only if position/frame changed.
|
||||
// Retail-faithful per acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent.
|
||||
bool intervalElapsed = !_lastSentInitialized
|
||||
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||
|
||||
bool cellChanged = _lastSentCellId != CellId;
|
||||
bool planeChanged = !_lastSentContactPlane.Equals(_body.ContactPlane);
|
||||
bool frameChanged = !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||
|
||||
bool sendThisFrame = intervalElapsed
|
||||
? (cellChanged || frameChanged)
|
||||
: (cellChanged || planeChanged);
|
||||
|
||||
HeartbeatDue = _body.InContact && _body.OnWalkable && sendThisFrame;
|
||||
```
|
||||
|
||||
`_lastSentContactPlane` requires adding a `System.Numerics.Plane _lastSentContactPlane;` field next to the other `_lastSent*` fields. `System.Numerics.Plane` is a struct with `IEquatable<Plane>` so direct `!_lastSentContactPlane.Equals(_body.ContactPlane)` works. Extend `NotePositionSent` to accept a `Plane` and stamp it alongside position/cell/time. The outbound layer at `GameWindow.cs:6410-6445` (MoveToState) and `~line 6310` (AutonomousPosition) both gain a `contactPlane: _body.ContactPlane` argument.
|
||||
|
||||
### Issues closed
|
||||
|
||||
- **#63** — server-initiated auto-walk not honored. (Both halves: B.6 slice 2 shipped the inbound handling on 2026-05-14; this commit fixes the MoveToChain callback-cancellation gap.)
|
||||
- **#74** — AP cadence chattier than retail. (Direct revert to retail's narrow gate enabled by the workaround retirement.)
|
||||
|
||||
## Out of scope (file as new issues)
|
||||
|
||||
- **Retail status messages** ("Approaching {target}" / "Using the {target}" on screen). Client-side UX polish; not wire-critical. Mentioned by user during brainstorm 2026-05-16 as nice-to-have to match retail feel. File as `#75 — Retail-faithful pending-action status messages on screen`.
|
||||
|
||||
## Testing plan
|
||||
|
||||
### Unit tests
|
||||
|
||||
No new unit tests required. The behavioral change is wire-level integration; existing Core.Net 294 baseline must hold. Existing 1073 Core tests baseline must hold (8 pre-existing physics failures unchanged).
|
||||
|
||||
### Visual verification (user-driven)
|
||||
|
||||
1. **Far-range Use NPC.** Double-click a Pathwarden / Royal Guard ~8-15 m away. Expected log shape:
|
||||
```
|
||||
[B.4b] use guid=0x... seq=X
|
||||
[autowalk-mt] mt=0x06 isMoveTo=True ...
|
||||
[autowalk-begin] dest=...
|
||||
[autowalk-end] reason=arrived
|
||||
```
|
||||
**Expected: NO `[B.4b] use-deferred` line.** Dialogue fires from ACE's callback. One Use packet on the wire, not two.
|
||||
|
||||
2. **Far-range PickUp item.** F-key a ground item ~5-10 m away. Same shape — one PickUp, no retry.
|
||||
|
||||
3. **Close-range Use NPC behind player.** Within 3 m of an NPC facing away, press R. Body turns 180°, then dialogue fires. The close-range deferred path is unchanged by this work; should still produce:
|
||||
```
|
||||
[B.4b] use deferred (close-range, turn-first)
|
||||
[autowalk-end] reason=arrived
|
||||
[B.4b] use-deferred
|
||||
```
|
||||
(`use-deferred` is correct here — close-range deferral is retail-faithful turn-first, not a workaround.)
|
||||
|
||||
4. **Open inn door from across the room.** Walks, opens. ONE `[B.4b] use` line, no retry, no double-open.
|
||||
|
||||
5. **User takes manual control mid-auto-walk.** Click far NPC → during the walk, press W. Auto-walk cancels via `user-input`, normal MoveToState resumes firing. The action does NOT fire on arrival (user cancelled).
|
||||
|
||||
6. **Rapid click between two targets.** Click NPC, before arrival click a second NPC. ACE re-broadcasts MoveToObject with the new target. Our overlay re-targets (existing `BeginServerAutoWalk` overwrite). Action fires on arrival at the SECOND target. No stale `_pendingPostArrivalAction` from the first click (close-range path keeps its handling; far-range no longer queues anything).
|
||||
|
||||
### Pre-conditions
|
||||
|
||||
- ACE running on `127.0.0.1:9000`.
|
||||
- `+Acdream` character at Holtburg (test character).
|
||||
- `ACDREAM_PROBE_AUTOWALK=1` for the trace lines.
|
||||
- Build green: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- All six visual-verify scenarios pass.
|
||||
- No `use-deferred` log line for FAR-range Use (only for close-range turn-first defer, which is correct).
|
||||
- Core.Net tests pass (294/294).
|
||||
- Core tests baseline holds (1073/1081).
|
||||
- Issues #63 and #74 close with this commit's SHA.
|
||||
|
||||
## Risk + rollback
|
||||
|
||||
**Risk:** If ACE's `MoveToChain` somehow STILL doesn't fire the callback (e.g., a separate ACE bug we haven't identified), Use action breaks for far-range targets. The user would observe "walked to NPC, no dialogue."
|
||||
|
||||
**Rollback:** trivial git revert. The fix is one guard clause + workaround deletions. Reverting restores the working-but-chatty Commit B state at `b5da17d`.
|
||||
|
||||
**Detection during visual verify:** if scenario 1 fails (no dialogue after arrival), revert before merging.
|
||||
|
||||
## Plan handoff
|
||||
|
||||
This design is the input for `superpowers:writing-plans`, which produces the task-by-task plan with code blocks ready to paste.
|
||||
478
docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
Normal file
478
docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
# Retail-faithful chase camera with dev-tools toggle
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Author:** Claude (with @erikn)
|
||||
**Phase:** ad-hoc rendering polish (not on the M2 critical path)
|
||||
**Status:** brainstormed → ready for plan
|
||||
|
||||
## Motivation
|
||||
|
||||
`src/AcDream.App/Rendering/ChaseCamera.cs` is a rigid follow-cam: each
|
||||
frame `Position` is recomputed as a pure function of
|
||||
`playerPosition + yaw + Pitch + Distance + EyeHeight`. The character
|
||||
is welded to the camera, which makes movement feel mechanical (no lag,
|
||||
no overshoot, no slope-awareness). A `_trackedZ` hack pins camera Z
|
||||
during jumps as a workaround for the visual feel that retail's camera
|
||||
gets naturally from low-stiffness damping.
|
||||
|
||||
The retail 2013 client uses a two-class chase camera
|
||||
(`CameraManager` + `CameraSet`, decomp at
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt:95505` and
|
||||
`:97643`) that:
|
||||
|
||||
1. **Exponentially damps both translation and rotation toward a target
|
||||
pose each frame.** Default stiffness 0.45 → ~7.5 % of the gap closed
|
||||
per 60 Hz frame → ~150 ms half-life. This is the dominant "alive"
|
||||
feel.
|
||||
2. **Aligns the camera basis to the player's recent velocity vector**
|
||||
(5-frame moving average) instead of pure world-up. The camera tilts
|
||||
with the terrain when running up/down hills.
|
||||
3. **Low-passes mouse-look deltas** within a 0.25 s window so
|
||||
high-DPI mice don't make the camera jitter.
|
||||
4. **Integrates held-key offset adjustments** (Closer/Farther/Raise/
|
||||
Lower) at a settable rate per frame so zoom/elevation transitions
|
||||
are smooth.
|
||||
5. **Fades the player mesh** linearly from opaque at 0.45 m to fully
|
||||
transparent at 0.20 m when the camera approaches the pivot.
|
||||
6. **Orbits independently of player yaw** — Rotate inputs spin
|
||||
`viewer_offset` around the pivot's Z-axis, the character's heading
|
||||
isn't touched.
|
||||
|
||||
This spec ports all six behaviors as a new `RetailChaseCamera` class,
|
||||
controlled by a dev-tools toggle so the user can A/B against the
|
||||
existing legacy camera before we make retail the default.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ ChaseCamera ────────┐
|
||||
│ (existing, legacy) │
|
||||
└──────────────────────┘
|
||||
▲
|
||||
│ ICamera
|
||||
│
|
||||
CameraDiagnostics ─── flag ─► CameraController ──► renderer (View matrix)
|
||||
(static) │
|
||||
│ ICamera
|
||||
▼
|
||||
┌─ RetailChaseCamera ──┐
|
||||
│ (new) │
|
||||
└──────────────────────┘
|
||||
▲
|
||||
│ per-frame inputs
|
||||
│
|
||||
GameWindow (updates both, picks active)
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
**`AcDream.Core.Rendering.CameraDiagnostics`** (new static class)
|
||||
Owns the runtime-tunable knobs. Mirrors the `PhysicsDiagnostics` pattern
|
||||
(diagnostic owner classes per CLAUDE.md §"Code Structure Rules" rule 5).
|
||||
Initial values from env vars at process start; runtime-settable via
|
||||
property setters that the DebugPanel writes to.
|
||||
|
||||
```csharp
|
||||
public static class CameraDiagnostics
|
||||
{
|
||||
public static bool UseRetailChaseCamera { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") == "1";
|
||||
|
||||
public static bool AlignToSlope { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0";
|
||||
|
||||
public static float TranslationStiffness { get; set; } = 0.45f;
|
||||
public static float RotationStiffness { get; set; } = 0.45f;
|
||||
public static float MouseLowPassWindowSec { get; set; } = 0.25f;
|
||||
public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
|
||||
}
|
||||
```
|
||||
|
||||
Lives in `AcDream.Core` (math-relevant tunables don't need the GL/
|
||||
window dependency). Defaults match retail's `CameraManager` constructor
|
||||
(decomp lines 95957–95988).
|
||||
|
||||
**`AcDream.App.Rendering.RetailChaseCamera : ICamera`** (new)
|
||||
The retail-faithful camera. Owns per-frame state:
|
||||
|
||||
- `_velocityRing[5]` — 5-frame velocity history for the slope-align
|
||||
moving average. Matches retail's `old_velocities[5]`.
|
||||
- `_velocityCount` — ring fill level (0..5). Until full, average uses
|
||||
the actual count.
|
||||
- `_dampedEye` (Vector3) — current damped camera world position.
|
||||
- `_dampedForward` (Vector3, unit-length) — current damped look
|
||||
direction.
|
||||
- `_initialised` — first-frame snap flag.
|
||||
- `_lastMouseDeltaX`, `_lastMouseDeltaY`, `_lastFilterTimeSec` — mouse
|
||||
low-pass state.
|
||||
|
||||
User-tunable properties (independent of `CameraDiagnostics` — those are
|
||||
global, these are per-camera-instance settings the controller can
|
||||
clamp):
|
||||
|
||||
- `Distance` (default 2.61, clamp [2, 40]) — length of `viewer_offset`.
|
||||
- `Pitch` (default 0.291 rad ≈ 16.7°, clamp [-0.7, 1.4]) — angle of
|
||||
`viewer_offset` above the heading-frame XY plane.
|
||||
- `YawOffset` (default 0) — orbit offset added on top of player yaw
|
||||
when slope-align is off OR when running stationary.
|
||||
- `PivotHeight` (default 1.5 m) — height of look-at anchor above the
|
||||
player's feet.
|
||||
- `Aspect`, `FovY` (from `ICamera`).
|
||||
|
||||
**`AcDream.App.Rendering.CameraController`** (existing, extended)
|
||||
Carries both `Chase` and `RetailChase`. `Active` reads the flag and
|
||||
returns whichever is selected. `EnterChaseMode(legacy, retail)` takes
|
||||
both at once and tracks them in parallel.
|
||||
|
||||
### Data flow
|
||||
|
||||
1. **Process start** → `CameraDiagnostics` static initialisers read env
|
||||
vars; defaults applied if env unset.
|
||||
2. **PlayerMode entry** (existing `EnterPlayerModeIfPossible` at
|
||||
`GameWindow.cs:9776`) — construct both cameras, hand to
|
||||
`CameraController.EnterChaseMode(legacy, retail)`.
|
||||
3. **Per-frame** (existing tick at `GameWindow.cs:6390`) — pass the
|
||||
same `playerPosition`, `playerYaw`, `playerVelocity`, `isOnGround`,
|
||||
`dt` to *both* cameras' `Update()`. Inactive camera stays warm so
|
||||
toggle swaps are instant.
|
||||
4. **View matrix** — renderer pulls `cameraController.Active.View`.
|
||||
5. **Translucency** — `RetailChaseCamera.PlayerTranslucency` is read
|
||||
after `Update()` and applied to the player entity (mechanism TBD
|
||||
during impl — see "Open implementation questions" below).
|
||||
6. **DebugPanel** — new "Chase camera" CollapsingHeader writes to
|
||||
`CameraDiagnostics` via `DebugVM` mirror properties; changes take
|
||||
effect on the next frame.
|
||||
|
||||
## Math (the retail-faithful update loop)
|
||||
|
||||
Per-frame inputs:
|
||||
|
||||
- `playerPos` (Vector3, world coords; Z up)
|
||||
- `playerYaw` (radians; 0 = +X, π/2 = +Y)
|
||||
- `playerVelocity` (Vector3, world space; Z component nonzero on
|
||||
slopes)
|
||||
- `isOnGround` (bool; currently unused but accepted for parity with
|
||||
the legacy camera signature — may be used for an extra-damping branch
|
||||
in a future iteration)
|
||||
- `dt` (seconds since last frame)
|
||||
|
||||
**Step 1 — velocity history.** FIFO-push `playerVelocity` into
|
||||
`_velocityRing`. Bump `_velocityCount` to min(count+1, 5).
|
||||
|
||||
**Step 2 — averaged velocity.**
|
||||
|
||||
```
|
||||
avgVel = sum(_velocityRing[0.._velocityCount]) / _velocityCount
|
||||
```
|
||||
|
||||
**Step 3 — heading vector.**
|
||||
|
||||
```
|
||||
if CameraDiagnostics.AlignToSlope and ‖avgVel‖² > 1e-4:
|
||||
heading = normalize(avgVel) # tilts with terrain
|
||||
else:
|
||||
yaw = playerYaw + YawOffset
|
||||
heading = (cos(yaw), sin(yaw), 0) # flat fallback
|
||||
```
|
||||
|
||||
Matches retail's `target_status & ALIGN_WITH_PLANE` branch (decomp
|
||||
:95644-95795) with the contact-plane fallback collapsed into the
|
||||
flat-fallback path (we don't have `contact_plane.N` exposed yet; the
|
||||
flat fallback is visually indistinguishable in the stationary case).
|
||||
|
||||
**Step 4 — orthonormal basis** (heading-frame, slope-tilted):
|
||||
|
||||
```
|
||||
forward = heading
|
||||
if |forward.Z| > 0.99: # heading is near-vertical (rare; airborne edge case)
|
||||
right = normalize(cross(forward, (1,0,0))) # use world +X as a tilt reference
|
||||
else:
|
||||
right = normalize(cross(forward, (0,0,1))) # standard
|
||||
up = cross(right, forward) # already unit (forward + right orthonormal)
|
||||
```
|
||||
|
||||
**Step 5 — target pose.**
|
||||
|
||||
```
|
||||
pivotWorld = playerPos + (0, 0, PivotHeight)
|
||||
viewer_offset = (0, -Distance*cos(Pitch), Distance*sin(Pitch)) # (right, forward, up) local
|
||||
targetEye = pivotWorld
|
||||
+ right * viewer_offset.X
|
||||
+ forward * viewer_offset.Y
|
||||
+ up * viewer_offset.Z
|
||||
targetForward = normalize(pivotWorld - targetEye) # camera looks at pivot
|
||||
```
|
||||
|
||||
The `viewer_offset` parameterization matches retail's default `(0,
|
||||
-2.5·scale, 0.75·scale)` when `Distance ≈ 2.61` and `Pitch ≈ 16.7°`
|
||||
(0.291 rad).
|
||||
|
||||
**Step 6 — exponential damping** (two independent decay rates):
|
||||
|
||||
```
|
||||
tAlpha = clamp(CameraDiagnostics.TranslationStiffness * dt * 10, 0, 1)
|
||||
rAlpha = clamp(CameraDiagnostics.RotationStiffness * dt * 10, 0, 1)
|
||||
|
||||
if not _initialised:
|
||||
_dampedEye = targetEye
|
||||
_dampedForward = targetForward
|
||||
_initialised = true
|
||||
else:
|
||||
_dampedEye = lerp(_dampedEye, targetEye, tAlpha)
|
||||
_dampedForward = normalize(lerp(_dampedForward, targetForward, rAlpha))
|
||||
```
|
||||
|
||||
The normalized lerp of the forward unit vector is the standard
|
||||
small-step equivalent of quaternion slerp; at `rAlpha ≤ ~0.1` per frame
|
||||
(the working range) the difference from `Quaternion.Slerp` is below
|
||||
floating-point noise. This preserves retail's *independent*
|
||||
translation/rotation rates without quaternion handedness pitfalls.
|
||||
|
||||
**Step 7 — view matrix.**
|
||||
|
||||
```
|
||||
Position = _dampedEye
|
||||
View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, (0,0,1))
|
||||
```
|
||||
|
||||
The `up` reference passed to `CreateLookAt` is the world up, not the
|
||||
heading-frame up. This produces a camera that always has the horizon
|
||||
horizontal on screen — matches retail (the camera basis tilts pitch,
|
||||
but the screen's "up" stays world-up).
|
||||
|
||||
**Step 8 — mouse low-pass filter** (separate entry point;
|
||||
`FilterMouseDelta(rawX, rawY, weight) → (outX, outY)`):
|
||||
|
||||
```
|
||||
nowSec = stopwatch elapsed seconds
|
||||
if nowSec - _lastFilterTimeSec < CameraDiagnostics.MouseLowPassWindowSec:
|
||||
avgX = (_lastMouseDeltaX + rawX) * 0.5
|
||||
avgY = (_lastMouseDeltaY + rawY) * 0.5
|
||||
else:
|
||||
avgX = rawX
|
||||
avgY = rawY
|
||||
|
||||
outX = rawX * (1 - weight) + avgX * weight # weight typically 0.5
|
||||
outY = rawY * (1 - weight) + avgY * weight
|
||||
|
||||
_lastMouseDeltaX = outX
|
||||
_lastMouseDeltaY = outY
|
||||
_lastFilterTimeSec = nowSec
|
||||
```
|
||||
|
||||
Matches retail's `CameraSet::FilterMouseInput` (decomp :96250-96279).
|
||||
GameWindow's mouse-move handler calls this before feeding `dy` to
|
||||
`AdjustPitch` / `dx` to `YawOffset`.
|
||||
|
||||
**Step 9 — auto-fade translucency.**
|
||||
|
||||
```
|
||||
d = distance(_dampedEye, pivotWorld)
|
||||
if d >= 0.45: PlayerTranslucency = 0.0
|
||||
elif d > 0.20: PlayerTranslucency = 1 - (0.20 - d) / (0.20 - 0.45)
|
||||
else: PlayerTranslucency = 1.0
|
||||
```
|
||||
|
||||
Matches retail's `CameraSet::UpdateCamera` distance check
|
||||
(decomp :97703-97725). Reading this property at 0 produces a fully
|
||||
opaque player; at 1 fully invisible. GameWindow applies it to the
|
||||
player entity's render path (see implementation question Q1).
|
||||
|
||||
## Continuous-key offset integration
|
||||
|
||||
Retail integrates `viewer_offset += FlagsToVector(held_keys) * dt`
|
||||
each frame for Closer/Farther/Raise/Lower as held keys. Since
|
||||
acdream's existing input scheme is mouse-wheel = zoom (`AdjustDistance`)
|
||||
and RMB-orbit-mouse-Y = pitch (`AdjustPitch`), we don't have keyboard
|
||||
bindings for these.
|
||||
|
||||
This spec adds four `InputAction`s, all default-unbound (no retail
|
||||
keymap entries for them yet — users wire them in Settings if wanted):
|
||||
|
||||
- `CameraZoomIn` — `Distance -= adjSpeed * dt`
|
||||
- `CameraZoomOut` — `Distance += adjSpeed * dt`
|
||||
- `CameraRaise` — `Pitch += adjSpeed * dt * 0.02` (smaller multiplier; pitch in radians)
|
||||
- `CameraLower` — `Pitch -= adjSpeed * dt * 0.02`
|
||||
|
||||
The wheel-driven `AdjustDistance(step)` and the RMB-mouse-Y-driven
|
||||
`AdjustPitch(delta)` keep working unchanged. The damping in step 6
|
||||
makes the discrete step from a wheel scroll feel smooth automatically.
|
||||
|
||||
## DebugPanel UI
|
||||
|
||||
New CollapsingHeader **"Chase camera"** in `DebugPanel.cs`, sitting
|
||||
between "Player Info" and "Performance", defaults open. Renders six
|
||||
controls bound to `DebugVM` mirror properties:
|
||||
|
||||
```
|
||||
[ ] Use retail chase camera [env: ACDREAM_RETAIL_CHASE]
|
||||
[x] Align to slope [env: ACDREAM_CAMERA_ALIGN_SLOPE]
|
||||
Translation stiffness [====|====] 0.45 (slider 0.05 .. 1.0, step 0.01)
|
||||
Rotation stiffness [====|====] 0.45 (slider 0.05 .. 1.0, step 0.01)
|
||||
Mouse low-pass window [==|======] 0.25 s (slider 0.0 .. 0.5, step 0.01)
|
||||
Adjustment speed [=====|===] 40.0 (slider 10 .. 80, step 1)
|
||||
```
|
||||
|
||||
`DebugVM` gets six new properties, each forwarding to a
|
||||
`CameraDiagnostics` static, matching the `ProbeResolveEnabled` mirror
|
||||
pattern.
|
||||
|
||||
`IPanelRenderer` may need a `SliderFloat(label, ref value, min, max)`
|
||||
method if not already present. The ImGui backend wraps
|
||||
`ImGui.SliderFloat`; tests use a stub renderer.
|
||||
|
||||
## Files touched
|
||||
|
||||
**New:**
|
||||
- `src/AcDream.Core/Rendering/CameraDiagnostics.cs` — static tunable owner.
|
||||
- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — the camera class.
|
||||
- `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs` — env-var parse + setter passthrough.
|
||||
- `tests/AcDream.Core.Tests/Rendering/RetailChaseCameraTests.cs` — math + damping + low-pass + fade tests.
|
||||
|
||||
**Modified:**
|
||||
- `src/AcDream.App/Rendering/CameraController.cs` — carry both cameras, swap on flag.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — construct both at chase-entry; update both per frame; route mouse low-pass; apply `PlayerTranslucency`. ~30 LOC of new code spread across existing chase-camera call sites, no new feature body.
|
||||
- `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` — six new mirror properties.
|
||||
- `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` — new CollapsingHeader.
|
||||
- `src/AcDream.UI.Abstractions/Input/InputAction.cs` (or wherever the enum lives) — four new actions.
|
||||
|
||||
(Possibly: `src/AcDream.UI.Abstractions/Panels/IPanelRenderer.cs` if
|
||||
`SliderFloat` doesn't exist there yet.)
|
||||
|
||||
If a new namespace `AcDream.Core.Rendering` doesn't exist, it's created
|
||||
fresh. `AcDream.Core` does not currently reference any GL/window/Silk
|
||||
types, so adding `CameraDiagnostics` (pure floats + bools) does not
|
||||
violate the layering rule (CLAUDE.md §"Code Structure Rules" rule 2).
|
||||
|
||||
## Tests
|
||||
|
||||
Five test groups in `RetailChaseCameraTests`:
|
||||
|
||||
**1. Heading-source fallbacks** — verify the slope-align toggle and
|
||||
the small-velocity fallback:
|
||||
- `StationaryAlignToSlope_HeadingMatchesPlayerYaw` — zero velocity →
|
||||
heading vector ≈ `(cos yaw, sin yaw, 0)`.
|
||||
- `MovingHorizontal_HeadingMatchesVelocity` — sustained `(1, 0, 0)`
|
||||
velocity over 5 frames → heading ≈ `(1, 0, 0)`.
|
||||
- `MovingUphill_HeadingHasPositiveZ` — velocity `(1, 0, 0.5)` over 5
|
||||
frames → `heading.Z > 0`.
|
||||
- `SlopeAlignDisabled_IgnoresVelocity` — heading always falls back to
|
||||
flat yaw vector regardless of velocity.
|
||||
|
||||
**2. 5-frame averaging** — verify the ring buffer:
|
||||
- `VelocityRing_AveragesLastN` — feed `[(1,0,0), (1,0,0), (2,0,0),
|
||||
(2,0,0), (3,0,0)]`, expect avg `(1.8, 0, 0)`.
|
||||
- `VelocityRing_FifoEvictsOldest` — feed 6 entries, ring still holds 5
|
||||
with the first evicted.
|
||||
- `VelocityRing_PartialFillUsesActualCount` — feed 2 entries, avg
|
||||
divides by 2 not 5.
|
||||
|
||||
**3. Damping** — verify alpha formula + first-frame snap + lerp:
|
||||
- `DampingAlpha_AtRetailDefault_ProducesSevenAndAHalfPercent` —
|
||||
stiffness=0.45, dt=1/60 → alpha ≈ 0.075.
|
||||
- `DampingAlpha_LargeDtClampsToOne` — stiffness=0.45, dt=1 → alpha = 1.
|
||||
- `FirstUpdate_SnapsToTarget` — initial Update with no prior state
|
||||
sets `_dampedEye = targetEye` exactly (no damping from a stale (0,0,0)).
|
||||
- `SecondUpdate_LerpsTowardTarget` — initial pose A, target pose B,
|
||||
alpha=0.5 → position = A + 0.5·(B-A).
|
||||
|
||||
**4. Mouse low-pass** — verify averaging window:
|
||||
- `MouseDelta_WithinWindow_BlendedWithPrevious` — feed delta=10 at
|
||||
t=0, delta=20 at t=0.1 (window=0.25) → second output averages with
|
||||
the first.
|
||||
- `MouseDelta_BeyondWindow_PassesThrough` — feed delta=10 at t=0,
|
||||
delta=20 at t=0.5 → second output is unmodified.
|
||||
- `MouseDelta_WeightZero_OutputsRaw` — weight=0 → output = raw
|
||||
regardless of history.
|
||||
- `MouseDelta_WeightOne_OutputsAveraged` — weight=1 within window →
|
||||
output = average.
|
||||
|
||||
**5. Auto-fade** — verify the linear ramp constants:
|
||||
- `Translucency_DistanceFar_IsZero` — d ≥ 0.45 → t = 0.
|
||||
- `Translucency_DistanceMid_RampsLinearly` — d=0.325 (midpoint) →
|
||||
t = 0.5.
|
||||
- `Translucency_DistanceNear_IsOne` — d ≤ 0.20 → t = 1.
|
||||
- `Translucency_AtThreshold_IsExact` — d = 0.45 → t = 0; d = 0.20 → t = 1.
|
||||
|
||||
Plus a small `CameraDiagnosticsTests` covering env-var parsing
|
||||
(`UseRetailChaseCamera=1` reads as true, default false; `AlignToSlope=0`
|
||||
reads as false, default true).
|
||||
|
||||
No visual / integration test; visual feel is the manual acceptance
|
||||
test (see below).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `dotnet build` green.
|
||||
2. `dotnet test` green, with new tests covering the math above.
|
||||
3. With `ACDREAM_RETAIL_CHASE=1` set (or the dev-tools toggle flipped
|
||||
on), running the client + walking in Holtburg produces noticeably
|
||||
smoother camera motion than the legacy camera: visible lag when
|
||||
turning, visible coast-and-settle when stopping, visible
|
||||
tilt-with-terrain on hill crests.
|
||||
4. With the toggle OFF, behavior is identical to before this change
|
||||
(legacy `ChaseCamera` untouched).
|
||||
5. Toggling the switch at runtime swaps cameras without snapping or
|
||||
crashing; the newly-active camera takes a few frames to ease into
|
||||
place from the warm state of the inactive camera.
|
||||
6. Jumping with retail mode on produces the "see yourself rise above
|
||||
the camera" feedback *without* the `_trackedZ` hack — the existing
|
||||
hack stays in `ChaseCamera` (legacy) untouched.
|
||||
|
||||
## Open implementation questions
|
||||
|
||||
The plan resolves these before coding:
|
||||
|
||||
**Q1: Where does `PlayerTranslucency` apply?**
|
||||
The retail call is `CPhysicsObj::SetTranslucencyHierarchical(player, t)`.
|
||||
Need to find the acdream equivalent — likely a property on `WorldEntity`
|
||||
or its mesh-batch metadata. If absent, the plan adds a minimal
|
||||
`Translucency` property and threads it through the render path. If
|
||||
the change to `WorldEntity` is more than ~20 LOC, the auto-fade feature
|
||||
ships in a follow-up commit rather than blocking the main toggle.
|
||||
|
||||
**Q2: Where does the player's world velocity come from?**
|
||||
`PlayerMovementController` has `BodyVelocity` (Vector3) at line 155.
|
||||
The chase-camera update call site at `GameWindow.cs:6390` already
|
||||
holds a reference to `_playerController`; threading `playerController.
|
||||
BodyVelocity` into the `RetailChaseCamera.Update` call is one extra
|
||||
argument. Confirm during impl.
|
||||
|
||||
**Q3: `IPanelRenderer.SliderFloat`?**
|
||||
Quick check during impl — if not present, add it to
|
||||
`IPanelRenderer` + `ImGuiPanelRenderer` (one-line each).
|
||||
|
||||
## Out of scope / future work
|
||||
|
||||
- **First-person ("InHead") mode toggle.** Retail's `SetInHead`
|
||||
collapses `viewer_offset` to `(0, 0.18, 0)`. We don't have a first-
|
||||
person mode yet; out of scope.
|
||||
- **Look-down mode (`ToggleLookDown`)** — retail's "look down at floor"
|
||||
mode for inventory drag/drop. Out of scope.
|
||||
- **Map mode (`ToggleMapMode`)** — retail's "top-down map view." Out
|
||||
of scope.
|
||||
- **Camera-vs-world collision.** Retail's per-frame update doesn't
|
||||
raycast world geometry (see investigation report 2026-05-18 in chat).
|
||||
The auto-fade handles "camera passes through player"; we don't
|
||||
attempt "camera collides with wall" — same as retail.
|
||||
- **Making retail the default.** Default stays off in this spec; flip
|
||||
in a follow-up commit after visual verification.
|
||||
- **Deleting legacy `ChaseCamera`.** Stays around for A/B comparison
|
||||
until retail mode is proven and made default; then a single
|
||||
cleanup commit deletes it + the `_trackedZ` hack.
|
||||
|
||||
## References
|
||||
|
||||
- **Retail decomp:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||
lines 95505 (`CameraManager::UpdateCamera`), 97643
|
||||
(`CameraSet::UpdateCamera`), 95957 (`CameraManager` constructor),
|
||||
97916 (`CameraSet::SetDefaultOffsets`), 96250
|
||||
(`CameraSet::FilterMouseInput`), 97103 (`CameraSet::Rotate`), 97350
|
||||
(`CameraSet::Closer`).
|
||||
- **Retail symbols:** `docs/research/named-retail/symbols.json` —
|
||||
search for `CameraManager` / `CameraSet`.
|
||||
- **Investigation report:** chat transcript 2026-05-18 (the brainstorm
|
||||
preceding this spec).
|
||||
- **Existing legacy:** `src/AcDream.App/Rendering/ChaseCamera.cs`.
|
||||
- **Diagnostic owner pattern:**
|
||||
`src/AcDream.Core/Physics/PhysicsDiagnostics.cs`.
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
# Indoor Cell Rendering Fix — Design
|
||||
|
||||
**Status:** Brainstormed 2026-05-19. Pivoted mid-brainstorm — see §1.5 for
|
||||
the corrected root-cause analysis. Awaiting user review.
|
||||
**Scope:** Diagnose + fix the actual break in the EnvCell rendering chain.
|
||||
**Out of scope this phase:** Cell collision symptoms (no wall collision
|
||||
exiting, weird open-air collisions). Filed as a follow-up phase pending
|
||||
user repro data.
|
||||
|
||||
---
|
||||
|
||||
## 1. Symptom
|
||||
|
||||
Walking into Holtburg Inn: the exterior building stab renders (walls visible
|
||||
from inside), but the interior cell's own room mesh — floor, inner walls,
|
||||
ceiling — is missing. The user can walk through the empty interior with no
|
||||
floor visible underfoot.
|
||||
|
||||
## 1.5 What the root cause is NOT (corrected mid-brainstorm)
|
||||
|
||||
Initial hypothesis: N.5 retirement (commit
|
||||
[`dcae2b6`](../../../#) 2026-05-08) deleted the legacy cell-mesh drain path
|
||||
with the assumption "WB handles EnvCell geometry through its own pipeline,"
|
||||
and that assumption was wrong.
|
||||
|
||||
**Closer inspection during brainstorm proved that assumption is correct.**
|
||||
WB's `ObjectMeshManager.PrepareMeshData(id, isSetup)` at
|
||||
[`ObjectMeshManager.cs:557`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:557)
|
||||
dispatches on the **dat record type** (not on the `isSetup` parameter).
|
||||
When the id resolves to a `DBObjType.EnvCell`, it routes to
|
||||
`PrepareEnvCellMeshData(id, envCell, ct)` at line 1186, which produces an
|
||||
`ObjectMeshData` with `IsSetup=true`, `SetupParts` = [static objects +
|
||||
cellGeometry], `EnvCellGeometry` = the floor/wall/ceiling room mesh.
|
||||
|
||||
The dispatcher correctly handles `IsSetup=true` at
|
||||
[`WbDrawDispatcher.cs:607-621`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:607) —
|
||||
it iterates `SetupParts`, looks up each part's render data, composes
|
||||
transforms, and draws each.
|
||||
|
||||
`DefaultDatReaderWriter` loads region cell dats during construction
|
||||
([`DefaultDatReaderWriter.cs:66-89`](../../../references/WorldBuilder/WorldBuilder.Shared/Services/DefaultDatReaderWriter.cs:66))
|
||||
so `ResolveId(envCellId)` will find the cell record.
|
||||
|
||||
`LandblockSpawnAdapter.OnLandblockLoaded` iterates `landblock.Entities` and
|
||||
calls `_adapter.IncrementRefCount(meshRef.GfxObjId)` for each
|
||||
([`LandblockSpawnAdapter.cs:75-80`](../../../src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:75)).
|
||||
Cell entities have `ServerGuid == 0` (atlas-tier), so they pass the filter
|
||||
at line 73. Their `MeshRef.GfxObjId == envCellId` reaches `IncrementRefCount`.
|
||||
|
||||
**The chain looks structurally intact.** Floors SHOULD render today. They
|
||||
don't. Therefore the failure is subtler than "we never invoke the load."
|
||||
|
||||
## 2. Real failure point — to be determined by diagnostics
|
||||
|
||||
Six untested hypotheses, in rough order of probability:
|
||||
|
||||
1. **WB silently fails to build the `ObjectMeshData`.** `PrepareEnvCellMeshData`
|
||||
returns null when the Environment dat can't resolve, or when
|
||||
`PrepareCellStructMeshData` returns null (texture issues, surface
|
||||
resolution failure). WB doesn't log; the failure is invisible.
|
||||
|
||||
2. **`SetupParts.cellGeomId` is uploaded but its texture batches are empty.**
|
||||
`UploadGfxObjMeshData` returning null at line 675 is treated as a
|
||||
non-fatal substitution — the render data has no draw batches, dispatcher
|
||||
silently draws nothing.
|
||||
|
||||
3. **Cell entity is culled before reaching the dispatcher.** `visibleCellIds`
|
||||
filter at `WbDrawDispatcher.cs:317-319` rejects entities whose
|
||||
`ParentCellId` isn't in the visible set. If the cell entity's
|
||||
`ParentCellId == envCellId` but the visibility BFS doesn't include the
|
||||
player's current cell (because `FindCameraCell` returns null when camera
|
||||
is in third-person above the building, etc.), the cell entity is
|
||||
skipped.
|
||||
|
||||
4. **Double-spawn conflict between WB's static-object SetupParts and
|
||||
acdream's per-stab entity hydration.** `PrepareEnvCellMeshData` iterates
|
||||
`envCell.StaticObjects` and adds each as a SetupPart. Meanwhile acdream
|
||||
already hydrates the same static objects as separate `WorldEntity`
|
||||
instances at [`GameWindow.cs:5390-5439`](../../../src/AcDream.App/Rendering/GameWindow.cs:5390).
|
||||
WB might be holding extra ref counts on those GfxObj IDs that block
|
||||
eviction or cause cache thrash. Unlikely to cause "missing floor" but
|
||||
worth ruling out.
|
||||
|
||||
5. **Transform composition bug.** `ComposePartWorldMatrix(entityWorld,
|
||||
meshRef.PartTransform, partTransform)` — if our cell entity's
|
||||
`meshRef.PartTransform == cellTransform` and WB's `partTransform`
|
||||
already bakes the cell origin, the floor lands at `2 × cellOrigin`,
|
||||
far below or beside the actual cell. The user would describe this
|
||||
as "missing" because the floor is now outside the visible frustum.
|
||||
|
||||
6. **The cell entity's `MeshRefs` only has one entry, but WB expects
|
||||
multiple.** The dispatcher iterates `entity.MeshRefs`, but each MeshRef
|
||||
gets its own `TryGetRenderData(meshRef.GfxObjId)` call. For cell
|
||||
entities we have `MeshRefs = { MeshRef(envCellId, cellTransform) }`.
|
||||
When the lookup returns an `IsSetup=true` render data, the dispatcher
|
||||
does the right thing (line 607-621) — iterates SetupParts. So this
|
||||
should work; ruling out.
|
||||
|
||||
## 3. Solution
|
||||
|
||||
### Phase 1 — Diagnostics (this phase's work)
|
||||
|
||||
Five probes, each individually toggleable via env-var + DebugPanel
|
||||
checkbox. The probes live in a new
|
||||
`AcDream.Core.Rendering.RenderingDiagnostics` static class (mirroring
|
||||
the `AcDream.Core.Physics.PhysicsDiagnostics` pattern shipped in L.2a)
|
||||
so they're discoverable from one place and survive across the
|
||||
Core / App seam.
|
||||
|
||||
Each probe is **rate-limited**: by default, one line per (envCellId,
|
||||
frame-modulo-30) — i.e., once per second per cell at 30 Hz — to avoid
|
||||
log spam. When `ACDREAM_PROBE_INDOOR_VERBOSE=1` is also set, the
|
||||
rate-limit drops and every frame logs.
|
||||
|
||||
| Env var (and DebugPanel mirror) | Probe | Code location | Line format |
|
||||
|---|---|---|---|
|
||||
| `ACDREAM_PROBE_INDOOR_WALK` | Cell-entity dispatcher walk | `WbDrawDispatcher.WalkVisibleEntities` (rate-limited per cellId) | `[indoor-walk] cellEnt=0xID pos=(x,y,z) parentCell=0xID landblockVisible=B aabbVisible=B cellInVis=B drawn=B` |
|
||||
| `ACDREAM_PROBE_INDOOR_LOOKUP` | Render-data lookup for cell entities | `WbDrawDispatcher.DrawAccumulated` per cell entity | `[indoor-lookup] cellId=0xID hit=B isSetup=B partCount=N hasEnvCellGeom=B partsHit=N partsMiss=N` |
|
||||
| `ACDREAM_PROBE_INDOOR_UPLOAD` | WB upload result for envCellId | `WbMeshAdapter.IncrementRefCount` (on first call per id) + a callback hooked into `_meshManager.Tick()` for completion | `[indoor-upload] cellId=0xID requested=true completed=B partsCount=N cellGeomVerts=N error="..."` |
|
||||
| `ACDREAM_PROBE_INDOOR_XFORM` | Composed world transform for cell-geometry SetupPart | `WbDrawDispatcher` inside the `IsSetup` branch at line 607-621, for partGfxObjId matching `(envCellId | 0x1_00000000UL)` | `[indoor-xform] cellId=0xID cellOrigin=(x,y,z) entityWorld=(...) partTransform=(...) composed=(x,y,z y-axis,z-axis) detExpected≈1 detActual=F` |
|
||||
| `ACDREAM_PROBE_INDOOR_CULL` | Visibility / cull decision per cell entity | `WbDrawDispatcher.WalkVisibleEntities` (the two filter sites at lines 304-305 and 317-319) | `[indoor-cull] cellEnt=0xID reason="visibleCellIds-miss" or "frustum" or "served" details="..."` |
|
||||
|
||||
The five probes can be enabled independently or together. The user's
|
||||
common case is `ACDREAM_PROBE_INDOOR_ALL=1` which sets all five at
|
||||
once.
|
||||
|
||||
#### Implementation outline
|
||||
|
||||
1. **New file** `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` —
|
||||
five static `bool` properties, each backed by an env-var read at
|
||||
startup, each runtime-settable from the DebugPanel.
|
||||
2. **DebugPanel section** — new "Indoor rendering diagnostics" block
|
||||
in the existing DebugPanel "Diagnostics" group, with one checkbox
|
||||
per probe + a master "all" toggle.
|
||||
3. **WbDrawDispatcher edits** — instrument the walk and the IsSetup
|
||||
draw branch. The walk probe needs to know whether the entity passed
|
||||
the cell-visibility filter; the cull probe needs the same data.
|
||||
Cleanest: emit BOTH lines in one place when either probe is on.
|
||||
4. **WbMeshAdapter edits** — `IncrementRefCount` logs an `[indoor-upload]
|
||||
requested=true` line when the id is recognized as an EnvCell
|
||||
(high-bit check `(id & 0xFFFF) >= 0x0100`). On Tick(), when a
|
||||
completion drains for an envCellId, log the result line with the
|
||||
actual ObjectMeshData/ObjectRenderData fields.
|
||||
5. **No GameWindow changes** beyond passing the diagnostics class
|
||||
into the dispatcher (if not already accessible).
|
||||
|
||||
#### Capture procedure
|
||||
|
||||
1. Build with the probe instrumentation. `dotnet build` green.
|
||||
2. Launch with `ACDREAM_PROBE_INDOOR_ALL=1`. Walk to Holtburg Inn,
|
||||
stand at the doorway, then step inside, then walk around the room.
|
||||
3. Stop the client, grep `launch.log` for `[indoor-*]` lines.
|
||||
4. The captured log identifies WHICH hypothesis matches:
|
||||
- **H1 (null upload)** → `[indoor-upload] completed=false`
|
||||
- **H2 (empty batches)** → `[indoor-upload] cellGeomVerts=0`
|
||||
- **H3 (cull bug)** → `[indoor-cull] reason="visibleCellIds-miss"`
|
||||
- **H4 (double-spawn)** → `[indoor-lookup] partCount` includes
|
||||
static-object IDs that ALSO appear in `landblock.Entities`
|
||||
- **H5 (transform double-apply)** → `[indoor-xform] composed`
|
||||
world position lands at `2 × cellOrigin` instead of `cellOrigin`
|
||||
- **H6 (MeshRefs structure)** → ruled out; probe data would still
|
||||
surface it as `hit=true isSetup=true partCount=N` followed by
|
||||
all `partsHit=0`
|
||||
|
||||
### Phase 2 — Fix the specific break (next phase)
|
||||
|
||||
Once the probe identifies the failure point, implement the surgical
|
||||
fix. Likely shapes per hypothesis:
|
||||
|
||||
| Hypothesis | Fix shape |
|
||||
|---|---|
|
||||
| H1 — WB returns null | Add WB logging or pre-check the dat resolution path in WbMeshAdapter |
|
||||
| H2 — Empty batches | Investigate WB texture pipeline; possibly a missing texture in the cell's surface list |
|
||||
| H3 — Cull bug | Fix `ParentCellId` assignment OR loosen the visibility filter for cell entities |
|
||||
| H4 — Double-spawn | Stop WB from spawning static-object parts in EnvCell setups (filter them in PrepareEnvCellMeshData, or skip acdream's per-stab hydration when WB handles the cell) |
|
||||
| H5 — Transform double-apply | Replace `MeshRef.PartTransform = cellTransform` with `entity.Position+Rotation = cellPosition` |
|
||||
| H6 — MeshRefs structure | Already ruled out in §2 |
|
||||
|
||||
Phase 2's actual code change is small and well-targeted once Phase 1
|
||||
gives us a definite answer.
|
||||
|
||||
## 4. Why NOT build a separate cell renderer
|
||||
|
||||
The original brainstorm proposed adapting `_pendingCellMeshes` data into
|
||||
WB via a new `UploadCellMesh` adapter method. **That solution is wrong** —
|
||||
it would duplicate work WB already does, fragment the rendering pipeline,
|
||||
and bypass WB's existing GPU memory management. Worse, it would hide
|
||||
whatever the actual bug is, not fix it.
|
||||
|
||||
## 5. Edge cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Visible during diagnostic capture | Probe is heavy (per-frame, per-entity). Bounded by short walk; runtime-toggle off when done. |
|
||||
| Probe spam in production | Default OFF, mirrored to DebugPanel. Same pattern as L.2a `ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL`. |
|
||||
| Concurrent landblock stream | Probe records per frame across all loaded cells — useful for cross-cell comparison ("does cell X load but cell Y not?"). |
|
||||
|
||||
## 6. Testing strategy
|
||||
|
||||
**Unit tests:** none in Phase 1. The probe is diagnostic, not behavioral.
|
||||
|
||||
**Visual verification (user-driven, end-to-end):**
|
||||
|
||||
- Add probe, launch client, walk into Holtburg Inn.
|
||||
- Read probe output to identify which hypothesis matches.
|
||||
- Brief Phase 2 in a new design (or amend this one) once the failure
|
||||
point is known.
|
||||
|
||||
**Phase 2 unit tests:** depend on the fix shape. If H5 (transform
|
||||
double-apply), tests verify the world matrix composition. If H3 (cull
|
||||
bug), tests verify visibility BFS for indoor entities.
|
||||
|
||||
## 7. What's NOT in this phase
|
||||
|
||||
- Cell collision symptoms — investigated separately.
|
||||
- Particle/fire emitter integration — already shipped.
|
||||
- Light registration — already shipped.
|
||||
- Stab-leak-through-walls — deferred.
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
**Phase 1 (this phase):**
|
||||
|
||||
- [ ] `AcDream.Core.Rendering.RenderingDiagnostics` static class created
|
||||
with five `bool` properties + master `IndoorAll` toggle, each backed
|
||||
by an env-var read at startup and runtime-settable.
|
||||
- [ ] DebugPanel "Diagnostics" group has a new "Indoor rendering"
|
||||
subsection with six checkboxes (five probes + master).
|
||||
- [ ] `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`,
|
||||
`[indoor-xform]`, `[indoor-cull]` lines when the respective probe
|
||||
is on. Rate-limited to ~1/sec per cell unless verbose mode active.
|
||||
- [ ] `WbMeshAdapter` emits `[indoor-upload]` lines for EnvCell IDs:
|
||||
one `requested` line on first `IncrementRefCount`, one `completed`
|
||||
line when WB's Tick drains the result (success or failure).
|
||||
- [ ] `dotnet build` clean. `dotnet test` clean (the diagnostics-only
|
||||
change should not affect any test).
|
||||
- [ ] Probe captured at Holtburg Inn confirms which hypothesis matches.
|
||||
Capture procedure documented in §3 above.
|
||||
- [ ] Phase 2 design (amended spec or new spec) documents the surgical
|
||||
fix matched to the identified hypothesis.
|
||||
|
||||
**Phase 2 (next phase, driven by Phase 1 output):**
|
||||
|
||||
- [ ] `dotnet build` clean, `dotnet test` clean.
|
||||
- [ ] Visual verification: walking into Holtburg Inn renders interior
|
||||
floor + walls correctly.
|
||||
- [ ] Roadmap updated.
|
||||
- [ ] Probes left in place for future regressions but defaulted off.
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
# Indoor Portal-Based Cell Tracking — Design
|
||||
|
||||
**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
|
||||
**Scope:** Port retail's portal-graph cell traversal to replace Phase D's AABB containment shortcut. Closes ISSUES.md #87 and the remaining wall-collision parts of #84 and #85 (indoor walking — walls don't block, walking through doors doesn't update CellId).
|
||||
**Predecessor:** Cluster A (`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`) shipped 2026-05-19. Phase D's AABB containment was a deliberate shortcut that the capture log proved insufficient for normal indoor walking.
|
||||
**Retail pseudocode reference:** `docs/research/acclient_indoor_transitions_pseudocode.md` (2026-04-13) — the entire algorithm is already documented from ACE source cross-referenced against the retail header. This spec is the porting plan, not a re-derivation.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we know
|
||||
|
||||
The 2026-04-13 research doc enumerates:
|
||||
|
||||
- **`CObjCell::find_cell_list`** — the top-level driver, called every movement tick. Builds the list of cells a sphere overlaps + identifies the new "current cell" via point-in-cell.
|
||||
- **`CEnvCell::find_transit_cells` (sphere variant)** — walks portal neighbors of an indoor cell. Adds neighbor cells whose `sphere_intersects_cell` returns `Inside` or `Crossing`.
|
||||
- **`CEnvCell::check_building_transit`** — the outdoor→indoor entry path, invoked from `BuildingObj::find_building_transit_cells`.
|
||||
- **`CLandCell::add_all_outside_cells`** — outdoor neighbor expansion on the 24m landcell grid.
|
||||
- **`CCellStruct::point_in_cell`** → tail-calls `BSPTREE::point_inside_cell_bsp(cell_bsp, localPoint)`. The `cell_bsp` is a third BSP per cell, separate from `physics_bsp` and `drawing_bsp`.
|
||||
|
||||
acdream already has:
|
||||
|
||||
- **`BSPQuery.PointInsideCellBsp(node, point)`** at [src/AcDream.Core/Physics/BSPQuery.cs:940](src/AcDream.Core/Physics/BSPQuery.cs:940) — the canonical retail port of `point_inside_cell_bsp`. Currently wired but unused.
|
||||
- **`LoadedCell.Portals`** (in `AcDream.App.Rendering`) — populated from `envCell.CellPortals` for the visibility renderer. Used for portal-BFS visibility, not collision.
|
||||
- **`PhysicsDataCache.CacheCellStruct`** caches `CellStruct.PhysicsBSP` (collision BSP) + `PhysicsPolygons` + `VertexArray`. Does NOT currently cache `CellStruct.CellBSP` or portal data.
|
||||
|
||||
Capture evidence (`launch-cluster-a-cache-diag3.log`, `launch-cluster-a-verify.log`):
|
||||
|
||||
- Holtburg interior cells DO have full physics geometry (e.g. `0xA9B40143` has 14 polys all resolved, AABB `(-11.60, -1.60, 0.00) → (-6.20, 7.60, 2.80)`).
|
||||
- Phase D's AABB containment fires for ~6 frames per session (mid-jump apex). The threshold/doorway cells with thin Z AABB (e.g. `0xA9B40146` with AABB Z `[-0.20, 0.00]`) never capture a standing player.
|
||||
- Result: indoor cell-BSP collision branch fires intermittently; walls don't consistently block.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goal
|
||||
|
||||
Port retail's portal-graph cell traversal so:
|
||||
|
||||
1. The player's CellId tracks indoor cells correctly when walking inside a building.
|
||||
2. Walking through a doorway (portal) promotes/demotes CellId correctly.
|
||||
3. Walking into a building from outside (through a `BuildingObj` portal) promotes CellId to the right interior cell.
|
||||
4. The indoor cell-BSP collision branch fires every frame the player is in an indoor cell, so walls block consistently.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Visibility-side portal traversal (`CellVisibility` / `LoadedCell.Portals`) — kept as-is. This phase is collision-side only.
|
||||
- Two-sphere parts/AABB variant of `find_transit_cells` (used for creatures and large objects) — port only the player's single-sphere case for now.
|
||||
- `VisibleCells` cleanup filter — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skip; the BSP-based point-in-cell already picks one winner.
|
||||
- Multi-step sub-tick portal crossings within a single movement step — retail handles fast movement that crosses multiple portals; we'll port the basic single-crossing case and revisit if regressions surface.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
Movement tick (per substep)
|
||||
│
|
||||
▼
|
||||
PhysicsEngine.ResolveCellId(worldPos, currentCellId)
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════════════╗
|
||||
║ CellTransit.FindCellList ║
|
||||
║ ║
|
||||
║ current is indoor (low >= 0x0100)? ║
|
||||
║ yes ─► seed cellArray with current EnvCell ║
|
||||
║ no ─► add_all_outside_cells (LandCell) ║
|
||||
║ + check_building_transit hits ║
|
||||
║ ║
|
||||
║ for each cell in cellArray (BFS-like): ║
|
||||
║ cell.find_transit_cells(sphere) ──► add ║
|
||||
║ neighbours via portal-graph walk ║
|
||||
║ ║
|
||||
║ for each cell in cellArray: ║
|
||||
║ if PointInsideCellBsp(cell.CellBSP, lpos): ║
|
||||
║ ─► newCurrentCell = cell, break ║
|
||||
╚═══════════════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
sp.CheckCellId = newCurrentCell.Id (full prefix)
|
||||
│
|
||||
▼
|
||||
[indoor-bsp] probe fires correctly for indoor cells
|
||||
Cell-BSP collision branch in FindEnvCollisions runs
|
||||
```
|
||||
|
||||
The hot path runs once per `FindEnvCollisions` call. Portal-graph traversal walks the local neighborhood (current cell + 1-2 hops). Typical work per tick: ~5-10 BSP point tests, each O(BSP depth) ≈ O(log N). Cheaper than the current AABB scan over all loaded cells.
|
||||
|
||||
---
|
||||
|
||||
## 4. Components
|
||||
|
||||
### 4.1 Data types (extend / add)
|
||||
|
||||
**`CellPhysics`** (extended — same record/class as today):
|
||||
|
||||
| Field | Status | Source |
|
||||
|---|---|---|
|
||||
| `BSP` | existing | `cellStruct.PhysicsBSP` (collision) |
|
||||
| `PhysicsPolygons` | existing | `cellStruct.PhysicsPolygons` |
|
||||
| `Vertices` | existing | `cellStruct.VertexArray` |
|
||||
| `WorldTransform` | existing | passed in from `GameWindow` |
|
||||
| `InverseWorldTransform` | existing | computed |
|
||||
| `Resolved` | existing | from `ResolvePolygons` |
|
||||
| `LocalAabbMin` / `LocalAabbMax` | **delete** | Phase D AABB shortcut |
|
||||
| **`CellBSP`** | **add** | `cellStruct.CellBSP` (third BSP for point-in-cell) |
|
||||
| **`Portals`** | **add** | `IReadOnlyList<PortalInfo>` from `envCell.CellPortals` |
|
||||
| **`VisibleCellIds`** | **add (optional, deferred)** | `envCell.VisibleCells` keys — for future cleanup filter; populated but unused in this phase |
|
||||
| **`PortalPolygons`** | **add** | `cellStruct.Polygons` resolved by id (separate from `PhysicsPolygons`; portals reference visible polys) |
|
||||
|
||||
**`PortalInfo`** (new readonly struct in `AcDream.Core.Physics`):
|
||||
|
||||
```csharp
|
||||
public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)
|
||||
{
|
||||
/// <summary>Bit 2 of Flags. See research doc §"PortalSide flag semantics".</summary>
|
||||
public bool PortalSide => (Flags & 2) == 0;
|
||||
}
|
||||
```
|
||||
|
||||
**`BuildingPhysics`** (new sealed class in `AcDream.Core.Physics`):
|
||||
|
||||
```csharp
|
||||
public sealed class BuildingPhysics
|
||||
{
|
||||
public required Matrix4x4 WorldTransform;
|
||||
public required Matrix4x4 InverseWorldTransform;
|
||||
public required IReadOnlyList<BldPortalInfo> Portals;
|
||||
}
|
||||
|
||||
public readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch);
|
||||
```
|
||||
|
||||
One `BuildingPhysics` per outdoor landcell that contains a building stab. Used for outdoor→indoor entry.
|
||||
|
||||
### 4.2 Caching (extend `PhysicsDataCache`)
|
||||
|
||||
**`CacheCellStruct(envCellId, cellStruct, worldTransform)` — extended:**
|
||||
|
||||
After the existing `Resolved = ResolvePolygons(...)` step, also populate the new fields:
|
||||
|
||||
- `CellBSP = cellStruct.CellBSP` (verify field name during plan-writing; the DAT type may use `CellBSP`, `CellBsp`, or similar)
|
||||
- `Portals = envCell.CellPortals.Select(cp => new PortalInfo(cp.OtherCellId, cp.PolygonId, cp.Flags)).ToList()`. **Decision:** change `CacheCellStruct`'s signature to `CacheCellStruct(uint envCellId, EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)` so portal data and other `EnvCell`-side fields are available in a single atomic call. One call site (`GameWindow.cs:5384`); change is mechanical.
|
||||
- `VisibleCellIds = new HashSet<uint>(envCell.VisibleCells.Keys)` — populated but unused in this phase.
|
||||
- `PortalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray)` — same shape as `Resolved` but built from the visible polygon table (since portal `PolygonId` indexes `Polygons`, not `PhysicsPolygons` — confirmed in `GameWindow.cs:5685`).
|
||||
|
||||
**`CacheBuilding(landcellId, portals, buildingWorldTransform)` — new:**
|
||||
|
||||
Invoked from `GameWindow.BuildInteriorEntitiesForStreaming` for each landcell that contains a building stab. The DAT data shape (BldPortals from `LandBlockInfo.Buildings`) needs verification during plan-writing.
|
||||
|
||||
**Deleted methods:**
|
||||
|
||||
- `PhysicsDataCache.TryFindContainingCell` — Phase D's AABB containment scan.
|
||||
- The AABB-compute block inside `CacheCellStruct`.
|
||||
|
||||
### 4.3 `CellTransit` (new static class)
|
||||
|
||||
New file: `src/AcDream.Core/Physics/CellTransit.cs`. Pure-static, owns three public functions:
|
||||
|
||||
```csharp
|
||||
public static class CellTransit
|
||||
{
|
||||
/// <summary>
|
||||
/// Top-level driver. Ported from retail CObjCell::find_cell_list (sphere variant).
|
||||
/// Returns the cell id whose CellBSP contains the sphere center, or the original
|
||||
/// fallback cell id if no cell matches.
|
||||
/// </summary>
|
||||
public static uint FindCellList(
|
||||
PhysicsDataCache cache,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
uint currentCellId,
|
||||
out CellSet candidateSet);
|
||||
|
||||
/// <summary>
|
||||
/// Indoor portal-neighbour expansion. Ported from CEnvCell::find_transit_cells
|
||||
/// (sphere variant). For each portal of `currentCell`, tests whether the sphere
|
||||
/// could overlap the neighbour cell and adds it to `candidateSet`.
|
||||
/// </summary>
|
||||
public static void FindTransitCellsSphere(
|
||||
PhysicsDataCache cache,
|
||||
CellPhysics currentCell,
|
||||
uint currentCellId,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
ref CellSet candidateSet);
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor→indoor entry. Ported from BuildingObj::find_building_transit_cells +
|
||||
/// CEnvCell::check_building_transit. For each BldPortal of `buildingPhysics`,
|
||||
/// resolves the destination EnvCell and tests whether the sphere is inside it
|
||||
/// via PointInsideCellBsp.
|
||||
/// </summary>
|
||||
public static void CheckBuildingTransit(
|
||||
PhysicsDataCache cache,
|
||||
BuildingPhysics buildingPhysics,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
ref CellSet candidateSet);
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor neighbour expansion. Ported from CLandCell::add_all_outside_cells.
|
||||
/// Computes the player's 2D position within the 24×24m landcell and adds
|
||||
/// neighbour landcells whose boundary the sphere crosses.
|
||||
/// </summary>
|
||||
public static void AddAllOutsideCells(
|
||||
PhysicsDataCache cache,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
uint currentCellId,
|
||||
ref CellSet candidateSet);
|
||||
}
|
||||
```
|
||||
|
||||
`CellSet` is a small helper — either `HashSet<uint>` or a thin wrapper allocating a stackalloc-backed list. Pick during plan-writing based on allocation profile.
|
||||
|
||||
### 4.4 `PhysicsEngine.ResolveCellId` (rename + rewrite)
|
||||
|
||||
Replaces `PhysicsEngine.ResolveOutdoorCellId`. New name + signature extended with a `sphereRadius` argument (needed by `FindTransitCellsSphere` for the sphere-vs-portal-plane test). Body becomes:
|
||||
|
||||
```csharp
|
||||
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
|
||||
{
|
||||
if (fallbackCellId == 0) return 0;
|
||||
if (DataCache is null) return fallbackCellId;
|
||||
|
||||
uint newCellId = CellTransit.FindCellList(
|
||||
DataCache,
|
||||
worldPos,
|
||||
sphereRadius,
|
||||
currentCellId: fallbackCellId,
|
||||
out _);
|
||||
|
||||
return newCellId != 0 ? newCellId : fallbackCellId;
|
||||
}
|
||||
```
|
||||
|
||||
The caller (`Transition.FindEnvCollisions` at TransitionTypes.cs:1181) has `sp.GlobalSphere[0].Radius` available and passes it through. The other two `PhysicsEngine` call sites (`Resolve`, `ResolveWithTransition`) need to plumb the sphere radius from their respective callers; the existing physics types carry it.
|
||||
|
||||
Three existing call sites of `ResolveOutdoorCellId` get renamed AND updated to pass the sphere radius:
|
||||
|
||||
- `PhysicsEngine.ResolveWithTransition` (line ~729)
|
||||
- `PhysicsEngine.Resolve` (line ~287)
|
||||
- `Transition.FindEnvCollisions` (TransitionTypes.cs:1181)
|
||||
|
||||
### 4.5 Bootstrap on teleport
|
||||
|
||||
When the player teleports to a new cell (server-provided cell id from the network), the existing teleport path stores the cell id and triggers `ResolveCellId` on the next physics tick. Two cases:
|
||||
|
||||
- **Server-provided cell id is loaded** in our cache → `FindCellList` starts from that cell, walks the portal graph, point-in-cell determines the actual current cell. Works correctly.
|
||||
- **Server-provided cell id is NOT yet loaded** → `FindCellList` falls through to `AddAllOutsideCells` (treats as outdoor). The next tick after streaming loads the cell, the portal-graph walk picks it up.
|
||||
|
||||
Acceptance for teleport: player teleporting to an indoor cell (e.g. Holtburg cottage interior) gets the correct CellId on the first or second tick after spawn. Documented as a known edge case if the streaming takes more than one tick.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow
|
||||
|
||||
### Landblock load (one-time per landblock)
|
||||
|
||||
```
|
||||
GameWindow.BuildInteriorEntitiesForStreaming(landblockId, lbInfo)
|
||||
│
|
||||
▼
|
||||
For each EnvCell:
|
||||
envCell = _dats.Get<EnvCell>(envCellId)
|
||||
cellStruct = environment.Cells[envCell.CellStructure]
|
||||
cellTransform = R(envCell.Position.Orientation) * T(envCell.Position.Origin + lbOffset + Z-bump)
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform)
|
||||
│ populates: BSP, CellBSP, PhysicsPolygons, Vertices, WorldTransform,
|
||||
│ InverseWorldTransform, Resolved, Portals, PortalPolygons,
|
||||
│ VisibleCellIds
|
||||
|
||||
For each landcell containing a building (LandBlockInfo.Buildings):
|
||||
_physicsDataCache.CacheBuilding(landcellId, building.Portals, buildingTransform)
|
||||
│ populates: BldPortals list + buildingWorldTransform
|
||||
```
|
||||
|
||||
### Movement tick (per substep)
|
||||
|
||||
```
|
||||
PhysicsEngine.ResolveWithTransition starts
|
||||
│
|
||||
▼
|
||||
Transition.FindEnvCollisions:
|
||||
sp.CheckCellId = ... (current cell estimate)
|
||||
sphereRadius = sp.GlobalSphere[0].Radius
|
||||
newCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId)
|
||||
if newCellId != sp.CheckCellId:
|
||||
sp.SetCheckPos(sp.CheckPos, newCellId)
|
||||
│
|
||||
▼
|
||||
Cell-BSP branch fires if sp.CheckCellId & 0xFFFF >= 0x0100
|
||||
├── BSPQuery.FindCollisions(cellPhysics.BSP, ...) ← walls collide here
|
||||
└── [indoor-bsp] probe emits a log line
|
||||
│
|
||||
▼
|
||||
Outdoor terrain collision (unchanged)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Commit shape (preview)
|
||||
|
||||
1. **`feat(physics): wire CellBSP + Portals + PortalPolygons into CellPhysics`** — extend `CellPhysics` shape; update `CacheCellStruct` signature to accept `envCell` (for portal data); deletes `LocalAabbMin/Max` fields and the AABB compute. Tests verify a synthetic `EnvCell` with portals + CellBSP populates the new fields correctly.
|
||||
2. **`feat(physics): port find_transit_cells sphere variant for indoor portals`** — new `CellTransit.FindTransitCellsSphere`. Tests use a synthetic two-cell portal pair to verify a sphere crossing the portal poly adds the neighbour cell.
|
||||
3. **`feat(physics): port BuildingPhysics + check_building_transit for outdoor→indoor`** — `CacheBuilding` + `CellTransit.CheckBuildingTransit`. GameWindow wiring at landblock load. Tests verify a sphere overlapping a building portal triggers indoor-cell add.
|
||||
4. **`feat(physics): port add_all_outside_cells for landcell neighbours`** — `CellTransit.AddAllOutsideCells`. Tests cover the 24×24m grid boundary cases.
|
||||
5. **`feat(physics): port find_cell_list driver, wire into ResolveCellId, delete AABB containment`** — top-level driver; rename `ResolveOutdoorCellId` → `ResolveCellId` and update 3 call sites; delete `PhysicsDataCache.TryFindContainingCell`. Rewrites the 4 Phase D tests (`ResolveOutdoorCellIdIndoorContainmentTests`) to use the portal traversal mechanism.
|
||||
6. **Capture session (user-driven)** — walk the Holtburg cottage with `ACDREAM_PROBE_INDOOR_BSP=1` + `ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_CELL_CACHE=1`. Verify all four acceptance criteria below.
|
||||
7. **`docs(phase): Indoor portal cell tracking shipped`** — closes #87 and the remaining wall-collision parts of #84 + #85; updates ISSUES.md, roadmap, CLAUDE.md; writes shipped-handoff doc.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files touched
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — `CellPhysics` shape extended; `CacheCellStruct` signature change; new `CacheBuilding`; deleted `TryFindContainingCell` + AABB compute.
|
||||
- `src/AcDream.Core/Physics/PhysicsEngine.cs` — rename `ResolveOutdoorCellId` → `ResolveCellId`; body rewritten to call `CellTransit.FindCellList`; 3 call sites in this file updated.
|
||||
- `src/AcDream.Core/Physics/TransitionTypes.cs` — call site update at line 1181.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — pass `envCell` into the extended `CacheCellStruct`; wire `CacheBuilding` at landblock load.
|
||||
|
||||
**New:**
|
||||
|
||||
- `src/AcDream.Core/Physics/CellTransit.cs` — the new static class with `FindCellList`, `FindTransitCellsSphere`, `CheckBuildingTransit`, `AddAllOutsideCells`.
|
||||
- `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs` — indoor portal traversal.
|
||||
- `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs` — outdoor→indoor entry.
|
||||
- `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs` — outdoor neighbours.
|
||||
- `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs` — integration tests.
|
||||
|
||||
**Rewritten:**
|
||||
|
||||
- `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` — renamed and ported to test the portal-based replacement.
|
||||
|
||||
**Closed in ISSUES.md:**
|
||||
|
||||
- #87 (indoor cell tracking via AABB containment) — fully closed by this phase.
|
||||
- #85 (pass through walls outside→in) — closed; the outdoor→indoor entry path through `BuildingObj` handles this.
|
||||
- #84 (blocked by air indoors) — the wall-pass-through portion that remained after Phase D is closed here.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
- **Cell loaded without `CellBSP`** — `PointInsideCellBsp(null, pt)` per its current contract returns `true`, which over-matches. Add an explicit `cellPhysics.CellBSP?.Root == null` skip in `FindTransitCellsSphere` and in `FindCellList`'s containment loop. The cell is treated as "not findable" until its BSP loads.
|
||||
- **Portal references an unloaded `OtherCellId`** — retail handles this with a "load hint" path that adds a null-cell entry for the streamer. We skip the add and continue; the next physics tick after streaming loads the cell picks it up. Document the one-tick latency as a known edge case.
|
||||
- **Player teleports to a cell ID with no cached `CellPhysics`** — fall back to `AddAllOutsideCells` (treat as outdoor) for that tick; the next tick after streaming loads the cell, portal traversal takes over.
|
||||
- **No try/catch swallows.** If the BSP traversal hits a malformed tree, the underlying `BSPQuery` asserts (Debug) or returns `false` (Release).
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
### Unit tests (per commit)
|
||||
|
||||
- **`CellPhysicsCellBspWiringTests`** — `CacheCellStruct` populates `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`.
|
||||
- **`CellTransitFindTransitCellsSphereTests`** — synthetic two-cell portal pair:
|
||||
- Sphere overlapping portal poly → adds neighbour.
|
||||
- Sphere far from portal → doesn't add neighbour.
|
||||
- Sphere on wrong side of portal (per `PortalSide`) → doesn't add neighbour.
|
||||
- Sphere crossing exit portal (`OtherCellId == 0xFFFF`) → sets `checkOutside = true`.
|
||||
- **`CellTransitCheckBuildingTransitTests`** — outdoor sphere overlapping building portal plane + inside destination cell's CellBSP → adds the indoor cell.
|
||||
- **`CellTransitAddAllOutsideCellsTests`** — sphere at boundary X+Y, +X−Y, −X+Y, −X−Y of a 24m cell → 1, 2, or 4 cells in the result set.
|
||||
- **`CellTransitFindCellListTests`** — integration:
|
||||
- Indoor seed → returns matching indoor cell after portal walk.
|
||||
- Outdoor seed → returns matching landcell.
|
||||
- Outdoor seed near building portal → returns indoor cell via `check_building_transit`.
|
||||
- Indoor seed crossing exit portal → returns outdoor landcell.
|
||||
|
||||
### Rewritten tests
|
||||
|
||||
- The four `ResolveOutdoorCellIdIndoorContainmentTests` (Phase D) — same scenarios, but using the portal-traversal mechanism rather than synthetic AABB-only cells. Some may merge with `CellTransitFindCellListTests`.
|
||||
|
||||
### Live test (user-driven)
|
||||
|
||||
Same launch incantation as Phase E:
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
|
||||
$env:ACDREAM_PROBE_RESOLVE = "1"
|
||||
$env:ACDREAM_PROBE_CELL = "1"
|
||||
$env:ACDREAM_PROBE_CELL_CACHE = "1"
|
||||
$env:ACDREAM_DEVTOOLS = "1"
|
||||
```
|
||||
|
||||
Walk the Holtburg cottage end-to-end. Verify all four acceptance criteria below.
|
||||
|
||||
---
|
||||
|
||||
## 10. Acceptance
|
||||
|
||||
1. **Indoor walking** — Player walks inside the Holtburg cottage freely; walls block from inside (current bug fixed); furniture still collides (no regression from per-object collision).
|
||||
2. **Outdoor→indoor** — Player walks toward the cottage door from outside; CellId promotes to an indoor cell when crossing the doorway; walls beyond the door block.
|
||||
3. **Indoor→outdoor** — Player walks back out through the door; CellId demotes to the outdoor landcell; outdoor terrain collision resumes; ACE doesn't report cell-state desync.
|
||||
4. **Indoor→indoor** — Player walks from one room to another through an interior doorway; CellId transitions correctly between EnvCells; no momentary "stuck on portal plane" issues.
|
||||
5. **`[indoor-bsp]` probe fires consistently** during indoor walking — not just during jumps (the Phase D failure mode).
|
||||
6. **`dotnet build` + `dotnet test`** green with the new test suite. Pre-existing baseline of 8 failures unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (deferred / explicit non-goals)
|
||||
|
||||
- **Parts/AABB variant of `find_transit_cells`** — used for creatures and large objects with multi-part bounding boxes. Only the player's single-sphere case is in scope here; the AABB variant ports as a follow-up if remote-entity cell tracking proves broken.
|
||||
- **`VisibleCells` cleanup filter** — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skipped; the BSP point-in-cell already picks one winner. Data is populated for future use.
|
||||
- **Multi-portal crossings within a single movement step** — retail's resolver handles fast movement crossing multiple portals via the per-substep loop. We rely on the per-substep loop being fine-grained enough; if a regression surfaces, address as a follow-up.
|
||||
- **Unification with `LoadedCell.Portals` in `AcDream.App.Rendering`** — two parallel portal stores remain (Core for collision, App for visibility). Future cleanup could unify them, but not in this phase.
|
||||
- **`CellTransit` for moving entities other than the player** — the function works for any sphere, but only the player's resolve path is wired this phase. Remote-entity cell tracking remains as-is.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks
|
||||
|
||||
1. **DAT field name mismatch.** The pseudocode doc references `CellStruct.CellBSP` but DatReaderWriter may name it differently (e.g. `cell_bsp`, `CellBsp`, `CellTree`). Verify at plan-writing time by reading DatReaderWriter's `CellStruct.cs` (NuGet source). If the field is missing entirely, file a sub-phase to extend DatReaderWriter — but this is unlikely given the dat format includes the BSP.
|
||||
2. **`BuildingObj.Portals` structure differs from indoor portals.** Retail's `BldPortal` has more fields (`OtherPortalId`, `ExactMatch`). The DAT representation lives under `LandBlockInfo.Buildings[...]`; verify the field shape at plan-writing time.
|
||||
3. **Sphere radius plumbing.** `FindTransitCellsSphere` needs the player's sphere radius to test against the portal plane. The caller (`Transition.FindEnvCollisions`) has access via `sp.GlobalSphere[0].Radius`; plumb it through `ResolveCellId`'s signature in the same commit that wires the call.
|
||||
4. **Rename cost.** Renaming `ResolveOutdoorCellId` → `ResolveCellId` cascades through 4 call sites + test names + commit messages. Bundling the rename with the wiring commit keeps the change atomic; spreading it across commits creates a transient state where the function name doesn't match its behavior.
|
||||
5. **Phase D test rewrites.** The 4 Phase D tests assert AABB-containment behavior that no longer exists. Rewriting them to use the portal-traversal mechanism requires synthetic test fixtures with portals + CellBSP — more setup boilerplate. Acceptable cost; integration coverage improves.
|
||||
|
||||
---
|
||||
|
||||
## 13. Phase name + roadmap placement
|
||||
|
||||
**Proposed name:** "Indoor portal-based cell tracking" (sometimes abbreviated "Indoor walking Phase 2" since it follows Cluster A / Indoor walking Phase 1).
|
||||
|
||||
**Roadmap placement:** add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Sits in front of any remaining indoor-rendering polish (issues #78, #79-#82) since indoor walking is the gating issue.
|
||||
|
||||
**Milestone:** still parallel to M2 (Kill a drudge). Completing indoor walking unblocks demos that involve buildings (e.g. talking to interior NPCs, picking up items from inside shops).
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
# Indoor Walkable-Plane BSP Port — Design
|
||||
|
||||
**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
|
||||
**Scope:** Replace `TryFindIndoorWalkablePlane`'s linear first-match scan with a thin wrapper over the existing retail-faithful BSP walkable-finder (`BSPQuery.FindWalkableInternal`). Restores closest-walkable-poly-along-up-vector semantics for indoor cells with multiple floors at different Z (cellars, 2nd floors, balconies).
|
||||
**Predecessor:** Indoor walking Phase 2 — Portal-based cell tracking ([`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](2026-05-19-indoor-portal-cell-tracking-design.md)) shipped 2026-05-19. Phase 2's commit 6 (`eb0f772`) introduced `TryFindIndoorWalkablePlane` as a stop-gap walkable-plane synthesis when the indoor BSP returns OK; it uses a linear first-match XY scan that ignores Z, which collapses to wrong-floor selection on any multi-Z indoor geometry.
|
||||
**Retail oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
|
||||
- `BSPLEAF::find_walkable` at 326793 — the leaf-level walkable test.
|
||||
- `BSPNODE::find_walkable` at 326211 — the BSP-traversal internal-node version.
|
||||
- `CPolygon::walkable_hits_sphere` at 323006 — `N·up > walkableAllowance` AND XY-overlap test.
|
||||
- `CPolygon::adjust_sphere_to_plane` at 322032 — slides sphere along the movement vector to rest on the polygon's plane; `walk_interp` is ratcheted down to the earliest hit.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we know
|
||||
|
||||
**The retail walkable-finder is already ported.** [`BSPQuery.FindWalkableInternal`](../../../src/AcDream.Core/Physics/BSPQuery.cs:647) implements the full retail algorithm — BSP traversal, leaf-level polygon scan, `WalkableHitsSphere` + `AdjustSphereToPlane`. It is used by:
|
||||
- `BSPQuery.StepSphereDown` (Path 3) at [BSPQuery.cs:1107](../../../src/AcDream.Core/Physics/BSPQuery.cs:1107) — the step-down branch invoked from `DoStepDown` → `TransitionalInsert(5)`.
|
||||
- `BSPQuery.FindCollisions` Path 4 (Collide) at [BSPQuery.cs:1492](../../../src/AcDream.Core/Physics/BSPQuery.cs:1492) — landing on a surface after free-fall.
|
||||
|
||||
**The new helper that doesn't use it.** `Transition.TryFindIndoorWalkablePlane` at [TransitionTypes.cs:1192](../../../src/AcDream.Core/Physics/TransitionTypes.cs:1192) was added in Phase 2 commit 6 (`eb0f772`) to synthesize an indoor walkable plane in the case where the indoor BSP returns OK (no wall collision). Its body iterates `cellPhysics.Resolved` in dictionary order, returns the first polygon with `Normal.Z >= 0.6664` whose XY contains the foot. There is no Z-proximity test.
|
||||
|
||||
**Visual evidence (user-reported, 2026-05-19, post-Phase-2-merge):**
|
||||
- Walking UP stairs in houses works (step_up → DoStepDown routes through Path 3 = `FindWalkableInternal`, which already picks the closest walkable).
|
||||
- Walking DOWN into cellars is broken (player can't descend; standing on upper floor with cellar floor beneath → linear scan picks upper floor → ValidateWalkable can't drop player below it).
|
||||
- Walking on 2nd floor is broken (linear scan picks 1st-floor poly → player snaps to 1st floor or is reported airborne above 2nd floor).
|
||||
- "Invisible obstacles at certain spots" — suspected cascade effect of wrong-Z ContactPlane (resolver flags body as airborne / submerged → next-frame collision misroutes). Not a separate root cause hypothesis.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goal
|
||||
|
||||
Route indoor walkable-plane synthesis through `BSPQuery.FindWalkableInternal` so the synthesized ContactPlane is the polygon the player is actually standing on (closest walkable surface below the foot, along the local up vector), not the first walkable polygon in dictionary order.
|
||||
|
||||
**Expected to fix:**
|
||||
- Cellar descent (player can step off the upper floor and descend through the stairwell onto the cellar floor).
|
||||
- 2nd-floor walking (player stays on the upper floor without snapping back to ground level).
|
||||
|
||||
**Possibly fixes (cascade):**
|
||||
- "Invisible obstacles at certain spots" — if this is downstream of wrong-Z ContactPlane causing the resolver to misclassify the body's grounded/airborne state. If after the fix these persist, that's a separate phase.
|
||||
|
||||
**Possibly fixes (related):**
|
||||
- ISSUES.md #88 (indoor static objects vibrate). If the per-frame ContactPlane Z flickers between two overlapping floors, dependent state may re-fire (`EntityScriptActivator` OnCreate/OnRemove, per-part transforms). Not the primary target; user re-verifies #88 after the fix lands.
|
||||
|
||||
**Out of scope:**
|
||||
- Outdoor terrain walkable selection (`PhysicsEngine.SampleTerrainWalkable`) — unchanged.
|
||||
- BSP collision dispatcher (`BSPQuery.FindCollisions` Paths 1–6) — unchanged.
|
||||
- Step-up / step-down flow (`DoStepUp` / `DoStepDown`) — unchanged. Stairs going UP already work; we don't risk regressing that path.
|
||||
- Cell transit / portal traversal (Phase 2 `CellTransit`) — unchanged.
|
||||
- Building-shell outdoor→indoor entry (`CheckBuildingTransit`) — unchanged.
|
||||
- Issue #89 (`SphereIntersectsCellBsp` retail-faithful port) — unchanged; separate phase.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
**One change, two callers.**
|
||||
|
||||
1. **New public entry point in `BSPQuery`** — a thin wrapper over the existing private `FindWalkableInternal`.
|
||||
|
||||
2. **Refactor of `Transition.TryFindIndoorWalkablePlane`** — replace the linear scan body with a call to the new entry point. Public signature unchanged.
|
||||
|
||||
3. **Removal of `Transition.PointInPolygonXY`** — only callsite was the linear-scan body; becomes dead code.
|
||||
|
||||
```
|
||||
Movement tick → resolver substep
|
||||
│
|
||||
▼
|
||||
Transition.FindEnvCollisions (TransitionTypes.cs:1262) [UNCHANGED]
|
||||
│
|
||||
├─ Transform foot sphere to cell-local space [UNCHANGED]
|
||||
├─ BSPQuery.FindCollisions(cellPhysics.BSP, ...) [UNCHANGED]
|
||||
│ └─ Wall collision: returns Slid / Adjusted / Collided
|
||||
│ → early-return; never reaches walkable synthesis
|
||||
├─ If result != OK → early-return [UNCHANGED]
|
||||
└─ If result == OK:
|
||||
▼
|
||||
Transition.TryFindIndoorWalkablePlane [REFACTORED]
|
||||
│
|
||||
├─ Save path.WalkableAllowance
|
||||
├─ Set path.WalkableAllowance = PhysicsGlobals.FloorZ
|
||||
├─ Build localMovement = -localUp * PROBE_DISTANCE
|
||||
├─ Build localSphere from localFootCenter + radius
|
||||
│
|
||||
├─ ╔══════════════════════════════════════════════╗
|
||||
│ ║ BSPQuery.FindWalkableSphere ║ [NEW WRAPPER]
|
||||
│ ║ wraps the existing FindWalkableInternal ║
|
||||
│ ║ (BSPNode.find_walkable + BSPLeaf.find_walkable
|
||||
│ ║ port — retail at acclient_2013_pseudo_c.txt
|
||||
│ ║ 326211 + 326793) ║
|
||||
│ ╚══════════════════════════════════════════════╝
|
||||
│ │
|
||||
│ ▼
|
||||
│ ResolvedPolygon? hitPoly + Vector3 adjustedCenter
|
||||
│
|
||||
├─ Restore path.WalkableAllowance (try/finally)
|
||||
├─ If hitPoly == null → return false (caller falls
|
||||
│ through to outdoor-terrain backstop, unchanged)
|
||||
└─ Transform hitPoly plane + vertices to world space
|
||||
(existing helper logic, kept verbatim)
|
||||
→ return true with (worldPlane, worldVertices, hitPolyId)
|
||||
▼
|
||||
Transition.ValidateWalkable(worldPlane, ...) [UNCHANGED]
|
||||
```
|
||||
|
||||
The refactor is surgical: one helper body changes, one wrapper is added, one dead helper deleted. No call-site changes. No new types. No new flags. No new env vars.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation surface
|
||||
|
||||
### 4.1 `BSPQuery.FindWalkableSphere` (new public entry point) + small extension to `FindWalkableInternal`
|
||||
|
||||
`ResolvedPolygon` does not carry its own id — the polyId is the `Dictionary<ushort, ResolvedPolygon>` key. `FindWalkableInternal` iterates `foreach (ushort polyId in node.Polygons)` in the leaf branch (BSPQuery.cs:665), so the key IS available internally — we just need to expose it. Two coupled changes:
|
||||
|
||||
**(a) Extend `FindWalkableInternal` signature** with a `ref ushort hitPolyId` param. Update the leaf branch's write to set both `hitPoly` AND `hitPolyId` together. Update all internal recursion sites (BSPQuery.cs:688, :695, :701, :703) to thread the new ref. Update the two existing callers (`StepSphereDown` at :1107 and Path 4 at :1492) to declare a local `ushort _` if they don't care about the id (existing behavior preserved — they only use `hitPoly`).
|
||||
|
||||
**(b) Add the public wrapper.** Place in `src/AcDream.Core/Physics/BSPQuery.cs` adjacent to `StepSphereDown` (around line 1085) since they share the same call shape into `FindWalkableInternal`.
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf
|
||||
/// find_walkable BSP traversal. Probes downward by <paramref name="probeDistance"/>
|
||||
/// along <paramref name="up"/> and returns the closest walkable polygon the
|
||||
/// sphere would rest on, with the sphere's center adjusted to lie on that plane.
|
||||
///
|
||||
/// <para>
|
||||
/// Wraps the existing private <see cref="FindWalkableInternal"/> — which already
|
||||
/// implements the retail-faithful walkable-finder
|
||||
/// (BSPNODE::find_walkable + BSPLEAF::find_walkable +
|
||||
/// CPolygon::walkable_hits_sphere + CPolygon::adjust_sphere_to_plane,
|
||||
/// acclient_2013_pseudo_c.txt:326211, :326793, :323006, :322032).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Intended call site: indoor walkable-plane synthesis in
|
||||
/// <see cref="Transition.TryFindIndoorWalkablePlane"/> when the indoor cell-BSP
|
||||
/// collision returns OK (no wall hit) and the resolver still needs a
|
||||
/// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
|
||||
/// (<see cref="PhysicsEngine.SampleTerrainWalkable"/>) and does not use this.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The caller is responsible for setting <c>transition.SpherePath.WalkableAllowance</c>
|
||||
/// to the desired walkability threshold (typically <see cref="PhysicsGlobals.FloorZ"/>)
|
||||
/// before calling, and restoring it after. Cheapest pattern: try/finally with
|
||||
/// save→set→call→restore.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="root">Root of the cell's PhysicsBSP.</param>
|
||||
/// <param name="resolved">Pre-resolved polygon dictionary from PhysicsDataCache.</param>
|
||||
/// <param name="transition">Current transition (read for WalkableAllowance / walk_interp).</param>
|
||||
/// <param name="sphere">Foot sphere in cell-local space.</param>
|
||||
/// <param name="probeDistance">Downward probe distance in meters. Typical: 0.5f.</param>
|
||||
/// <param name="up">Up vector in cell-local space (typically Vector3.UnitZ).</param>
|
||||
/// <param name="hitPoly">Output: the walkable polygon found, or null on miss.</param>
|
||||
/// <param name="hitPolyId">Output: polygon id (dictionary key) of the hit. Zero on miss.</param>
|
||||
/// <param name="adjustedCenter">
|
||||
/// Output: sphere center adjusted onto the polygon plane. Equal to input
|
||||
/// <c>sphere.Origin</c> on miss.
|
||||
/// </param>
|
||||
/// <returns>True if a walkable polygon was found; false otherwise.</returns>
|
||||
public static bool FindWalkableSphere(
|
||||
PhysicsBSPNode? root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
Transition transition,
|
||||
DatReaderWriter.Types.Sphere sphere,
|
||||
float probeDistance,
|
||||
Vector3 up,
|
||||
out ResolvedPolygon? hitPoly,
|
||||
out ushort hitPolyId,
|
||||
out Vector3 adjustedCenter)
|
||||
{
|
||||
adjustedCenter = sphere.Origin;
|
||||
hitPoly = null;
|
||||
hitPolyId = 0;
|
||||
|
||||
if (root is null) return false;
|
||||
|
||||
var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
|
||||
var movement = -up * probeDistance;
|
||||
bool changed = false;
|
||||
ushort polyId = 0;
|
||||
|
||||
FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
|
||||
movement, up, ref hitPoly, ref polyId, ref changed);
|
||||
|
||||
if (changed && hitPoly is not null)
|
||||
{
|
||||
adjustedCenter = validPos.Center;
|
||||
hitPolyId = polyId;
|
||||
return true;
|
||||
}
|
||||
|
||||
hitPoly = null;
|
||||
hitPolyId = 0;
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Notes on the wrapper:**
|
||||
- Pure, no side effects on call args other than `out` params.
|
||||
- `FindWalkableInternal` mutates `validPos` and `path.WalkInterp` (via `AdjustSphereToPlane`); the wrapper isolates `validPos` to a local. `path.WalkInterp` mutation is intentional and matches retail's `walk_interp` ratcheting — caller's responsibility to save/restore if needed.
|
||||
- No new dependencies. All types already in scope.
|
||||
|
||||
### 4.2 `Transition.TryFindIndoorWalkablePlane` (refactored body + extended signature)
|
||||
|
||||
File: `src/AcDream.Core/Physics/TransitionTypes.cs` (around line 1192). Signature gains a `sphereRadius` parameter (rationale §4.3):
|
||||
|
||||
```csharp
|
||||
internal bool TryFindIndoorWalkablePlane(
|
||||
CellPhysics cellPhysics,
|
||||
Vector3 localFootCenter,
|
||||
float sphereRadius,
|
||||
out System.Numerics.Plane worldPlane,
|
||||
out Vector3[] worldVertices,
|
||||
out uint hitPolyId)
|
||||
```
|
||||
|
||||
The helper changes from `internal static` to `internal` (instance method) so it can access `this.SpherePath` for the WalkableAllowance save/restore and pass `this` (Transition) to `BSPQuery.FindWalkableSphere`. The single callsite at TransitionTypes.cs:1358 is already inside a Transition instance method (`FindEnvCollisions`).
|
||||
|
||||
Body:
|
||||
|
||||
```csharp
|
||||
worldPlane = default;
|
||||
worldVertices = System.Array.Empty<Vector3>();
|
||||
hitPolyId = 0;
|
||||
|
||||
if (cellPhysics.BSP?.Root is null) return false;
|
||||
|
||||
// Build foot sphere in cell-local space. Caller passes localFootCenter already
|
||||
// transformed into cell-local space and the resolver's foot-sphere radius.
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = localFootCenter,
|
||||
Radius = sphereRadius,
|
||||
};
|
||||
|
||||
// Save/restore WalkableAllowance: the BSP walkable test consumes
|
||||
// path.WalkableAllowance (CPolygon::walkable_hits_sphere reads this field,
|
||||
// acclient_2013_pseudo_c.txt:323010). For "standing here, find my floor" we
|
||||
// want the walkability slope threshold FloorZ. The outer resolver may have
|
||||
// set it to LandingZ (airborne→ground transition) or another value; we
|
||||
// must not leak our change back to the resolver.
|
||||
float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
|
||||
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
|
||||
ResolvedPolygon? hitPoly = null;
|
||||
ushort hitId = 0;
|
||||
Vector3 adjustedCenter;
|
||||
bool found;
|
||||
|
||||
try
|
||||
{
|
||||
found = BSPQuery.FindWalkableSphere(
|
||||
cellPhysics.BSP.Root,
|
||||
cellPhysics.Resolved,
|
||||
this,
|
||||
localSphere,
|
||||
INDOOR_WALKABLE_PROBE_DISTANCE, // see §4.3
|
||||
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
|
||||
out hitPoly,
|
||||
out hitId,
|
||||
out adjustedCenter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
|
||||
}
|
||||
|
||||
if (!found || hitPoly is null) return false;
|
||||
|
||||
// Transform hit polygon's plane + vertices to world space. This block is
|
||||
// kept verbatim from the existing TryFindIndoorWalkablePlane implementation —
|
||||
// the world-transform math is unchanged.
|
||||
var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
|
||||
worldNormal = Vector3.Normalize(worldNormal);
|
||||
var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
|
||||
float worldD = -Vector3.Dot(worldNormal, worldV0);
|
||||
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
|
||||
|
||||
worldVertices = new Vector3[hitPoly.Vertices.Length];
|
||||
for (int i = 0; i < hitPoly.Vertices.Length; i++)
|
||||
worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
|
||||
|
||||
hitPolyId = hitId;
|
||||
return true;
|
||||
```
|
||||
|
||||
### 4.3 Constants and parameter rationale
|
||||
|
||||
| Symbol | Kind | Value / source | Rationale |
|
||||
|---|---|---|---|
|
||||
| `INDOOR_WALKABLE_PROBE_DISTANCE` | `private const float` in `Transition` | `0.5f` | 50 cm. Larger than the +0.02f cell-origin Z-bump (25× headroom). Larger than any realistic step riser (~20 cm). Smaller than a full cell height (~3 m) so we don't reach through a thin floor into the cell above/below. |
|
||||
| `sphereRadius` | method parameter | sourced from `sp.GlobalSphere[0].Radius` at the `FindEnvCollisions` call site (already bound at TransitionTypes.cs:1268) | The foot sphere radius is per-entity (player ≠ creature). Hardcoding would be wrong; threading the parameter is one line at the callsite. |
|
||||
|
||||
### 4.4 Callsite update
|
||||
|
||||
At TransitionTypes.cs:1358, the existing call:
|
||||
|
||||
```csharp
|
||||
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
|
||||
out var indoorPlane,
|
||||
out var indoorVertices,
|
||||
out uint _))
|
||||
```
|
||||
|
||||
becomes:
|
||||
|
||||
```csharp
|
||||
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
|
||||
out var indoorPlane,
|
||||
out var indoorVertices,
|
||||
out uint _))
|
||||
```
|
||||
|
||||
`sphereRadius` is already in scope from line 1268.
|
||||
|
||||
### 4.5 Delete `Transition.PointInPolygonXY`
|
||||
|
||||
At TransitionTypes.cs:1238. Only call site was the deleted linear-scan loop. Remove the method entirely.
|
||||
|
||||
---
|
||||
|
||||
## 5. Diagnostics
|
||||
|
||||
**Extend existing `[indoor-bsp]` probe surface, no new probe.**
|
||||
|
||||
The existing `[indoor-bsp]` line in `FindEnvCollisions` (at TransitionTypes.cs:1334) already prints per-call BSP-collision state. Add a sibling line `[indoor-walkable]` printed when the OK-result branch calls `TryFindIndoorWalkablePlane`, gated on the same `PhysicsDiagnostics.ProbeIndoorBspEnabled` flag (no new flag).
|
||||
|
||||
Print format:
|
||||
```
|
||||
[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=HIT poly=0x0042 wn=(0.000,0.000,1.000) wD=-2.75 dz=+0.03
|
||||
```
|
||||
or
|
||||
```
|
||||
[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=MISS
|
||||
```
|
||||
|
||||
Where:
|
||||
- `wpos` — world-space foot center.
|
||||
- `wn`/`wD` — world-space plane normal + D (on HIT only).
|
||||
- `dz` — signed Z gap between foot center and plane (positive = player above plane, negative = below).
|
||||
|
||||
Runtime-toggleable via the existing DebugPanel "Indoor BSP probe" checkbox. Cost when probe disabled: a single bool check (early-return).
|
||||
|
||||
For cellar descent, expect the trace to flip from "always picks upper floor (dz≈+3.0)" pre-fix to "picks cellar floor below (dz≈+0.03)" post-fix.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
### 6.1 Unit tests (new)
|
||||
|
||||
Location: `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` (adjacent to existing BSPQuery tests).
|
||||
|
||||
**Test 1: `FindWalkableSphere_TwoOverlappingFloors_PicksClosestBelowFoot`**
|
||||
|
||||
Synthetic mini-BSP with two horizontal walkable polys at Z=0 and Z=3, both passing XY through (0,0). Foot sphere centered at (0,0,1) radius 0.48, probe 0.5. Assert returned poly is the Z=0 one; assert `adjustedCenter.Z` ≈ 0.48.
|
||||
|
||||
**Test 2: `FindWalkableSphere_TwoOverlappingFloors_FootAbove_PicksUpper`**
|
||||
|
||||
Same mini-BSP. Foot at (0,0,3.6), probe 0.5. Assert returned poly is the Z=3 one; assert `adjustedCenter.Z` ≈ 3.48.
|
||||
|
||||
**Test 3: `FindWalkableSphere_NoWalkableInRange_ReturnsFalse`**
|
||||
|
||||
Two polys at Z=0 and Z=3. Foot at (0,0,5), probe 0.5. Sphere is more than `probe + radius` above the Z=3 plane; nothing in range. Assert returns false; assert `adjustedCenter == sphere.Origin`.
|
||||
|
||||
**Test 4: `FindWalkableSphere_SteepPolyRejected`**
|
||||
|
||||
Mini-BSP with one poly whose normal Z = 0.5 (52° slope, below FloorZ=0.6664). Foot above it. Caller sets `WalkableAllowance = FloorZ`. Assert returns false (poly rejected as too steep).
|
||||
|
||||
**Test 5: `TryFindIndoorWalkablePlane_RoutesThroughBSPQuery_PreservesAllowance`**
|
||||
|
||||
Integration test with a real `CellPhysics` fixture (two-floor cell). Set `path.WalkableAllowance` to a sentinel value (e.g. 0.42f). Call `TryFindIndoorWalkablePlane`. Assert returned plane corresponds to the closest floor below the foot. Assert `path.WalkableAllowance == 0.42f` after the call (save/restore worked).
|
||||
|
||||
### 6.2 Existing test baselines
|
||||
|
||||
- `dotnet build` clean.
|
||||
- `dotnet test` shows the same 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline). No new failures.
|
||||
- Phase 2 indoor-walking conformance unchanged (single-floor cottage cell remains correct — the new closest-below algorithm degenerates to the existing first-match behavior when only one walkable poly exists).
|
||||
|
||||
### 6.3 Visual verification (the real acceptance test)
|
||||
|
||||
User-driven, the only "milestone" verification the project uses (per CLAUDE.md "milestones are textual events").
|
||||
|
||||
**Required scenarios:**
|
||||
|
||||
| # | Scenario | Pre-fix behavior | Acceptance |
|
||||
|---|---|---|---|
|
||||
| 1 | Walk into Holtburg cottage, walk around single-floor interior | Works (Phase 2) | Still works — no regression |
|
||||
| 2 | Walk between cottage rooms via doorways | Works (Phase 2) | Still works — no regression |
|
||||
| 3 | Walk back outside through cottage door | Works (Phase 2) | Still works — no regression |
|
||||
| 4 | Find any building with a cellar entry, walk to and descend the stairs | Stuck / bounces at top of stairs | Smooth descent onto cellar floor |
|
||||
| 5 | Find any 2-story building, climb stairs to 2nd floor, walk around upper floor | Snaps back to 1st floor or "invisible obstacles" | Stays on 2nd floor, free movement |
|
||||
| 6 | Walk near previously-reported "invisible obstacle" spots | Hits invisible wall | (Hypothesis check) — invisible obstacles gone if cascade theory correct |
|
||||
| 7 | (Optional) Observe bookshelves/furnaces #88 vibration | Visible jitter | (Hypothesis check) — jitter reduced if cascade theory correct |
|
||||
|
||||
**If scenario 4 or 5 still fails after this lands, that's an indicator the diagnosis is incomplete — file a follow-up phase.** If scenario 6 still fails but 4/5 work, that's a separate bug (probably real BSP-classification issue, not walkable-plane).
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge cases
|
||||
|
||||
Handled by the existing `FindWalkableInternal` (not new logic):
|
||||
- Sphere doesn't intersect any BSP node → no recursion, `changed` stays false, miss path runs.
|
||||
- BSP root is null → wrapper returns false before recursion.
|
||||
- Multiple walkable polys in the same leaf → loop visits all, `AdjustSphereToPlane` ratchets `walk_interp` down to the closest hit (retail-faithful behavior, see acclient_2013_pseudo_c.txt:322055).
|
||||
- `WalkableAllowance > 1.0` (illegal) → `WalkableHitsSphere` returns false for every poly, miss path runs (defensive).
|
||||
|
||||
Introduced by the wrapper:
|
||||
- `WalkableAllowance` save/restore wrapped in try/finally so a thrown exception inside `FindWalkableInternal` doesn't leak modified state to the resolver.
|
||||
|
||||
Cell-state edge cases (unchanged from Phase 2):
|
||||
- Cell has no walkable polys (only walls + ceiling) → wrapper returns false → `FindEnvCollisions` falls through to outdoor-terrain backstop (existing behavior at TransitionTypes.cs:1372).
|
||||
- Cell origin Z-bump (+0.02f) interaction — probe distance 0.5f is 25× the bump, so the bump is noise within the probe range.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
1. `dotnet build -c Debug` clean.
|
||||
2. `dotnet test` shows the same 8 pre-existing failures (no new failures from this work).
|
||||
3. New unit tests 1–5 (§6.1) pass.
|
||||
4. Visual verification scenarios 1–5 (§6.3) all pass per user testing.
|
||||
5. Visual verification scenarios 6 and 7 reported as PASS/FAIL by user (cascade hypothesis confirmation, not gating).
|
||||
6. Roadmap shipped table updated.
|
||||
7. ISSUES.md #83 closed (Walking up stairs broken — bug now scoped to "walking DOWN in multi-floor cells").
|
||||
8. Phase memory updated if a durable lesson surfaces during implementation.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
**Retail oracle:**
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326211` — `BSPNODE::find_walkable`
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326793` — `BSPLEAF::find_walkable`
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323006` — `CPolygon::walkable_hits_sphere`
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:322032` — `CPolygon::adjust_sphere_to_plane`
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323565` — `BSPTREE::step_sphere_up` (related; uses find_walkable via step_sphere_down)
|
||||
|
||||
**acdream code:**
|
||||
- `src/AcDream.Core/Physics/BSPQuery.cs:647` — `FindWalkableInternal` (existing retail port)
|
||||
- `src/AcDream.Core/Physics/BSPQuery.cs:1085` — `StepSphereDown` (existing caller of FindWalkableInternal)
|
||||
- `src/AcDream.Core/Physics/TransitionTypes.cs:1192` — `TryFindIndoorWalkablePlane` (the helper being refactored)
|
||||
- `src/AcDream.Core/Physics/TransitionTypes.cs:1262` — `FindEnvCollisions` (the single callsite)
|
||||
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — diagnostic flag pattern (existing `ProbeIndoorBspEnabled`)
|
||||
|
||||
**Predecessor specs:**
|
||||
- [`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`](2026-05-19-indoor-walking-phase1-bsp-cluster-design.md) — Phase 1 (cell-BSP wall collision).
|
||||
- [`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](2026-05-19-indoor-portal-cell-tracking-design.md) — Phase 2 (portal cell tracking).
|
||||
|
||||
**Phase 2 ship handoff:**
|
||||
- [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md) — context for the `TryFindIndoorWalkablePlane` introduction in commit `eb0f772`.
|
||||
|
||||
**Issue tracking:**
|
||||
- `docs/ISSUES.md` #83 (Walking up stairs broken) — primary scope. Title is misleading per user: actual symptom is walking DOWN in multi-floor cells (cellars, descending stairs).
|
||||
- `docs/ISSUES.md` #88 (Indoor static objects vibrate) — possibly downstream; user re-verifies after fix lands.
|
||||
- `docs/ISSUES.md` #89 (Port `SphereIntersectsCellBsp`) — explicitly out of scope; separate phase.
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
# Indoor Walking Phase 1 — BSP cluster (#84 / #85 / #86)
|
||||
|
||||
**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
|
||||
**Scope:** Diagnostic-first investigation pass across the three "indoor walking is broken" bugs that share a cell-BSP / picker root-cause cluster. Surface evidence with a single probe + one capture session, then ship surgical fixes (one commit per issue).
|
||||
**Predecessors:**
|
||||
- Indoor cell rendering Phase 1 (`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`) — the five `[indoor-*]` render-side probes.
|
||||
- Indoor cell rendering Phase 2 (`docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md`) — silent-failure surfacing + WB Setup-prefix guard. Made floors render.
|
||||
- Handoff: `docs/research/2026-05-19-indoor-followup-handoff.md`.
|
||||
|
||||
The indoor cell rendering Phase 1+2 pair made floors render. The moment floors rendered, nine pre-existing indoor bugs (`docs/ISSUES.md` #78-#86) became user-observable. This phase tackles the **BSP cluster** subset: #84, #85, #86.
|
||||
|
||||
`#78` (outdoor stabs visible through floor) is in the same handoff cluster but a fundamentally different code path (render-side visibility / stencil), so it's deferred to a separate phase. `#79-#83` (lighting / terrain / stairs) are in different clusters.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we know from the code
|
||||
|
||||
Pre-investigation reads (2026-05-19) of the three issue surfaces:
|
||||
|
||||
### `#84` (blocked by air indoors) — cell BSP IS consulted
|
||||
|
||||
The handoff hypothesized "cell BSP isn't being used". Code reading says otherwise:
|
||||
|
||||
- **Cell BSP IS cached.** `PhysicsDataCache.CacheCellStruct` ([src/AcDream.Core/Physics/PhysicsDataCache.cs:131](src/AcDream.Core/Physics/PhysicsDataCache.cs:131)) stores `BSP`, `PhysicsPolygons`, `Vertices`, `WorldTransform`, `InverseWorldTransform`, and pre-resolved polygons (planes computed at cache time).
|
||||
- **Cell BSP IS consulted in collision.** `Transition.FindEnvCollisions` ([src/AcDream.Core/Physics/TransitionTypes.cs:1188-1241](src/AcDream.Core/Physics/TransitionTypes.cs:1188)) has an explicit indoor branch gated on `cellLow >= 0x0100` that:
|
||||
1. Looks up `cellPhysics` via `engine.DataCache.GetCellStruct(sp.CheckCellId)`,
|
||||
2. Transforms the player's sphere to cell-local space via `InverseWorldTransform`,
|
||||
3. Calls `BSPQuery.FindCollisions` with the cell's pre-resolved polys,
|
||||
4. Returns `cellState` if `!= OK`.
|
||||
|
||||
So #84's root cause is not "wiring missing". It's one of: (a) extra physics-only polys with no visible counterpart, (b) `+0.02f` Z-bump misalignment between cellTransform (applied to physics) and player Z (computed from terrain), (c) `BSPQuery` returning false positives at certain poly side-types, (d) `cellTransform` quaternion error on rotated cells. Capture data will pin which.
|
||||
|
||||
### `#85` (pass through walls outside→in) — likely asymmetric path
|
||||
|
||||
Walking outside-in keeps `CheckCellId` as the outdoor land cell (low byte `0x00xx-0x00FF`), so the indoor cell-BSP branch at TransitionTypes.cs:1192 is **gated out by design** (`cellLow >= 0x0100` is false). The only collision tested on the outside-in approach is:
|
||||
|
||||
- **Terrain** (always tested),
|
||||
- **Outdoor stab BSPs** ([`PhysicsDataCache.GetGfxObj`](src/AcDream.Core/Physics/PhysicsDataCache.cs) for `LandBlockInfo.Objects`) — building stab is hit via `FindObjCollisions`.
|
||||
|
||||
L.2d slice 1+1.5 ported `CBuildingObj` collision (per CLAUDE.md), so the outer building shell SHOULD be hit. If #85 reproduces, hypotheses:
|
||||
|
||||
1. The outdoor stab BSP for the Inn covers floor+roof but is missing wall polys (authoring shape — retail's interior cells own the walls, outdoor shell is a partial envelope).
|
||||
2. The outdoor stab BSP has wall polys but with one-sided normals; outside approach hits the back face which BSP treats as "behind plane" → no collision (`feedback_no_patching_collision` memory's faithful-port rule means we'd need to follow retail's handling).
|
||||
3. The L.2g dynamic-physics-state flag work doesn't include outdoor building shells in the collision sweep for the player's CheckCellId.
|
||||
4. **Retail's actual behavior** may be that outside-in BSP probing queries the EnvCell's BSP across the cell boundary — retail's `CCellStructure::find_env_collisions` may walk neighbor-cell BSPs.
|
||||
|
||||
### `#86` (click selection penetrates walls) — root cause definitively pinned by code reading
|
||||
|
||||
`WorldPicker.Pick` ([src/AcDream.Core/Selection/WorldPicker.cs:88-160](src/AcDream.Core/Selection/WorldPicker.cs:88), and the screen-rect overload at line 202) is **pure ray-sphere against entity AABBs**. There is no cell BSP test, no scenery BSP test, no terrain test. Any entity along the ray within `maxDistance` is a candidate; nothing occludes.
|
||||
|
||||
No probe needed for #86. Fix is structural: add a cell-BSP ray-poly occlusion test that runs once per `Pick` call and culls entities whose ray-distance exceeds the nearest wall hit.
|
||||
|
||||
---
|
||||
|
||||
## 2. The three issues
|
||||
|
||||
| # | Title | Code path | Fix shape |
|
||||
|---|---|---|---|
|
||||
| #84 | Blocked by air indoors | `Transition.FindEnvCollisions` cell branch | TBD — pinned by probe capture |
|
||||
| #85 | Pass through walls outside→in | `FindObjCollisions` outdoor-stab path or cross-cell BSP probing | TBD — pinned by probe capture |
|
||||
| #86 | Click selection penetrates walls | `WorldPicker.Pick` (both overloads) | Add cell-BSP ray-poly occlusion test |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
[indoor-bsp] probe
|
||||
↓
|
||||
┌───────────────────┴────────────────────┐
|
||||
▼ ▼
|
||||
Movement path Picker path
|
||||
(FindEnvCollisions cell branch) (WorldPicker.Pick)
|
||||
│ │
|
||||
├─→ #84: blocked by air └─→ #86: click through walls
|
||||
└─→ #85: pass through walls (cause already pinned by code reading)
|
||||
(cause TBD — needs capture)
|
||||
```
|
||||
|
||||
The probe spans only the movement path. #86's diagnosis is already known; its fix is independent of the capture and can land in parallel.
|
||||
|
||||
---
|
||||
|
||||
## 4. Components
|
||||
|
||||
### Component 1 — `PhysicsDiagnostics.IndoorBspEnabled`
|
||||
|
||||
New static toggle on `AcDream.Core.Physics.PhysicsDiagnostics`. Mirrors the existing `ResolveProbeEnabled` / `CellProbeEnabled` pattern:
|
||||
|
||||
- Backed by `ACDREAM_PROBE_INDOOR_BSP` env var read once at startup.
|
||||
- Mutable at runtime via the DebugPanel checkbox.
|
||||
- Zero-cost when off — checked before any string formatting.
|
||||
|
||||
Also extends `PhysicsDiagnostics.IndoorAllEnabled` cascading the way Phase 1 cascaded the render-side `ACDREAM_PROBE_INDOOR_ALL`.
|
||||
|
||||
### Component 2 — `[indoor-bsp]` log site
|
||||
|
||||
One `Console.WriteLine` block in `Transition.FindEnvCollisions` ([TransitionTypes.cs:1222](src/AcDream.Core/Physics/TransitionTypes.cs:1222)), wrapping the existing `BSPQuery.FindCollisions` call. Captured fields per call:
|
||||
|
||||
| Field | Source | Why |
|
||||
|---|---|---|
|
||||
| `cellId` | `sp.CheckCellId` | Which cell's BSP was queried (hex, full 32-bit) |
|
||||
| `localPos` | `localCenter` | Sphere foot center in cell-local space (3 floats) |
|
||||
| `localPrevPos` | `localCurrCenter` | Sphere previous-frame foot center in cell-local space |
|
||||
| `worldPos` | `footCenter` | Sphere foot center in world space (for cross-ref with user-reported spot) |
|
||||
| `result` | `cellState` | `TransitionState` enum (`OK` / `Collided` / etc.) |
|
||||
| `polyId` | `ci.LastHitCellPolyId` (NEW field if needed) | Which cell poly was hit, if any |
|
||||
| `polyNormal` | `cellPhysics.Resolved[polyId].Plane.Normal` | Local-space normal (3 floats) — diagnoses one-sided / orientation bugs |
|
||||
| `sidesType` | `cellPhysics.Resolved[polyId].SidesType` | `Front` / `Back` / `Both` — diagnoses #85 candidate |
|
||||
| `walkable` | `ci.LastKnownContactPlaneValid` | Walkable surface tracking state |
|
||||
|
||||
Log line format (one line, pipe-separated, machine-greppable):
|
||||
|
||||
```
|
||||
[indoor-bsp] cell=0xA9B40100 wpos=(82.45,71.23,1.04) lpos=(0.45,2.10,1.02) result=Collided poly=0x0042 n=(0.00,1.00,0.00) sides=Front walkable=true
|
||||
```
|
||||
|
||||
If `BSPQuery.FindCollisions` doesn't already expose the hit poly id, the log fields shrink to what's available without expanding the BSPQuery API. A separate small change to surface `lastHitPolyId` from `BSPQuery` would be in-scope for this phase if needed.
|
||||
|
||||
### Component 3 — DebugPanel checkbox
|
||||
|
||||
Adds a checkbox row in the DebugPanel's Diagnostics section (already hosts the L.2a `Resolve` and `Cell-transit` toggles, plus the Phase 1 `Indoor walk/cull/upload/lookup/xform` toggles). Surface area: ~3 lines. No new file.
|
||||
|
||||
### Component 4 — `WorldPicker` cell-BSP occluder
|
||||
|
||||
Two implementation options:
|
||||
|
||||
**Option C1 — Inline in `WorldPicker.Pick`.** Add a `cellOccluder` callback parameter `Func<Vector3, Vector3, float>?` that returns the nearest wall-hit `t` along the ray (or `float.PositiveInfinity` if no hit). Inside `Pick`, after computing the entity hit `t`, gate by `entityHit < cellOccluder(origin, direction)`.
|
||||
|
||||
**Option C2 — Separate `CellBspRayOccluder` static class.** New file `src/AcDream.Core/Selection/CellBspRayOccluder.cs`. Function `NearestWallT(Vector3 origin, Vector3 direction, IEnumerable<CellPhysics> loadedCells)` — Möller-Trumbore ray-triangle against each cell's resolved polys, returns nearest `t`. WorldPicker calls it once per `Pick` invocation.
|
||||
|
||||
**Recommend C2.** Reasons: testable in isolation (synthetic cell + ray); two `WorldPicker.Pick` overloads share one implementation; future picker improvements (entity body refine, scenery BSP refine) get a parallel structure to copy.
|
||||
|
||||
The caller (`GameWindow` Use/Select handlers) must supply the loaded `CellPhysics` set. `PhysicsDataCache` already has `GetCellStruct(id)` so the caller iterates currently-loaded `LoadedCell` ids from `CellVisibility._cellLookup` (Holtburg radius 4 keeps maybe 80 cells loaded — fast Möller-Trumbore).
|
||||
|
||||
### Component 5 — Fix patches (TBD)
|
||||
|
||||
Concrete commits drafted only after capture data lands. Candidates by issue:
|
||||
|
||||
**#84**:
|
||||
- Remove `+0.02f` Z bump from the physics-side `cellTransform` while keeping it for render's `cellMeshRef` (separate transforms). Or apply the bump symmetrically (also bump player Z by `+0.02f` when entering an indoor cell).
|
||||
- Filter out physics-only polys with no visible counterpart, IF capture data shows phantom polys are the issue.
|
||||
- Patch `BSPQuery.FindCollisions` side-type handling, IF capture data shows specific side-types misbehaving.
|
||||
|
||||
**#85**:
|
||||
- Port retail's outside-in BSP cross-cell probing — query an EnvCell's BSP from an outdoor cell when the sphere overlaps the EnvCell's world AABB. Reference: PDB-named `CCellStructure::find_env_collisions` and neighbors.
|
||||
- OR ensure outdoor building-shell stab BSPs include wall polys with two-sided handling.
|
||||
- Path picked from capture evidence + decomp grep.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow
|
||||
|
||||
### Capture session
|
||||
|
||||
User runs the canonical Holtburg launch (`ACDREAM_LIVE=1`, `+Acdream` char) with `ACDREAM_PROBE_INDOOR_BSP=1` + `ACDREAM_PROBE_RESOLVE=1` (latter already shipped from L.2a). Three scripted scenarios:
|
||||
|
||||
1. **Inside Inn walkaround (~30 s)** — walk slowly around the common room, attempt to reproduce #84. Note world-position when an invisible block happens.
|
||||
2. **Outside-in approach (~30 s)** — stand 5+ m west of the Inn, sprint at the west wall. Reproduce #85.
|
||||
3. **Inside-out sanity (~30 s)** — stand inside, walk into east wall from interior. This SHOULD block (per issue text); confirms inside-out path works.
|
||||
|
||||
Total launch: one. Captures all three.
|
||||
|
||||
### Offline analysis
|
||||
|
||||
```
|
||||
grep "\[indoor-bsp\]" launch.log | head -200 # see what fired during scenario 1
|
||||
grep "\[resolve\]" launch.log | grep "obj=0x" # see which objects were hit during scenario 2
|
||||
grep "\[cell-transit\]" launch.log # confirm cell ids during transitions
|
||||
```
|
||||
|
||||
Diagnosis per issue:
|
||||
|
||||
- **#84**: in scenario-1 lines, find `result=Collided` events where world-pos is in open space (no visible wall). Cross-ref `polyId` with the cell's `cellStruct.PhysicsPolygons` to identify what the offending poly is. Compare its local-Z with player's local-Z to test the Z-bump hypothesis.
|
||||
- **#85**: in scenario-2 lines, expect zero `[indoor-bsp]` events (gated out). Check `[resolve]` lines for the moment the player crosses the wall plane — did `FindObjCollisions` fire for any building stab? If yes, what poly? If no, the outdoor stab path is missing wall geometry → fix shape is the cross-cell BSP probing.
|
||||
- **#86**: no capture needed. Code reading already pinned the cause; fix is structural.
|
||||
|
||||
### Fix application
|
||||
|
||||
Per CLAUDE.md "no workarounds" rule:
|
||||
- The probe data must point at one specific code site before any fix lands.
|
||||
- Each fix commit cites the evidence in its message ("`[indoor-bsp] cell=0x... wpos=... poly=... n=...` — the poly at local-Z=0.0 is the floor poly; player local-Z=-0.02 from the +0.02f bump puts foot below floor → spurious floor-up push at cell boundary").
|
||||
- No try/catch swallow, no early-return guard at the symptom site.
|
||||
|
||||
---
|
||||
|
||||
## 6. Commit shape
|
||||
|
||||
```
|
||||
1. feat(physics): Cluster A — indoor BSP collision probe
|
||||
- PhysicsDiagnostics.IndoorBspEnabled toggle + env var + DebugPanel checkbox
|
||||
- [indoor-bsp] log site in TransitionTypes.FindEnvCollisions cell branch
|
||||
- (if needed) BSPQuery.LastHitPolyId surfacing
|
||||
|
||||
[CAPTURE SESSION — user-driven, no commit]
|
||||
|
||||
2. fix(physics): Cluster A #84 — <root cause from probe>
|
||||
- One surgical change to TransitionTypes / GameWindow / BSPQuery
|
||||
- Commit message cites probe evidence line
|
||||
- Closes ISSUES.md #84
|
||||
|
||||
3. fix(physics): Cluster A #85 — <root cause from probe + decomp>
|
||||
- One surgical change to TransitionTypes or PhysicsDataCache
|
||||
- Commit message cites probe evidence + retail decomp anchor
|
||||
- Closes ISSUES.md #85
|
||||
|
||||
4. fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
|
||||
- New CellBspRayOccluder static class (Option C2)
|
||||
- WorldPicker.Pick (both overloads) consults occluder before returning hit
|
||||
- Unit test covering synthetic wall-between-camera-and-entity case
|
||||
- Closes ISSUES.md #86
|
||||
|
||||
5. docs(roadmap+issues): Cluster A shipped — close #84/#85/#86, update roadmap
|
||||
- ISSUES.md moves three issues to Recently closed
|
||||
- docs/plans/2026-04-11-roadmap.md shipped table updated
|
||||
- CLAUDE.md "Currently in Phase L.2..." line advanced if appropriate
|
||||
```
|
||||
|
||||
Visual verification gate sits between commits 4 and 5. User confirms each acceptance criterion in the live client before closing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files touched
|
||||
|
||||
**Definite:**
|
||||
|
||||
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — new `IndoorBspEnabled` toggle.
|
||||
- `src/AcDream.Core/Physics/TransitionTypes.cs` — `[indoor-bsp]` log site at the cell branch.
|
||||
- `src/AcDream.App/UI/Panels/DebugPanel.cs` (or wherever the diagnostics checkboxes live) — UI toggle.
|
||||
- `src/AcDream.Core/Selection/WorldPicker.cs` — call the new occluder.
|
||||
- `src/AcDream.Core/Selection/CellBspRayOccluder.cs` — new file.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LoadedCell` set / `CellPhysics` enumeration into the Use/Select handlers' picker calls.
|
||||
- `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs` — new unit test for #86 fix.
|
||||
- `docs/ISSUES.md` — close #84/#85/#86.
|
||||
- `docs/plans/2026-04-11-roadmap.md` — shipped table entry.
|
||||
|
||||
**TBD (depends on capture):**
|
||||
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs:5362` (+0.02f Z bump site).
|
||||
- `src/AcDream.Core/Physics/BSPQuery.cs`.
|
||||
- `src/AcDream.Core/Physics/PhysicsDataCache.cs`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
- Probe always behind `PhysicsDiagnostics.IndoorBspEnabled`. Zero-cost when off.
|
||||
- Probe writes to `Console.WriteLine`, captured by the launch.log `Tee-Object` pipe (matches existing probe convention).
|
||||
- `CellBspRayOccluder` returns `float.PositiveInfinity` when no cells are loaded (outdoor camera). Picker behaves exactly as today in that case.
|
||||
- No try/catch around fix sites. If a fix doesn't behave, the user reports the residual symptom and the probe re-fires to identify the new cause.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
- **`WorldPickerCellOcclusionTests`** (new): synthetic `CellPhysics` with one wall poly between origin and an entity at 5 m. `Pick` returns null. Remove the wall — `Pick` returns the entity. Verifies the occluder is wired and triangulates correctly.
|
||||
- **`CellBspRayOccluderTests`** (new): direct unit tests for the Möller-Trumbore intersection — ray hits poly front, back, edge, miss, parallel-to-poly. Standard ray-triangle coverage.
|
||||
- **Existing tests**: `dotnet test` green. `WorldPickerTests` + `WorldPickerRectOverloadTests` + all `BSPQuery` tests must remain green.
|
||||
|
||||
### Visual verification (user-driven)
|
||||
|
||||
Three checks, one per issue:
|
||||
|
||||
1. **#84 acceptance** — User walks the common-room loop in Holtburg Inn. No invisible blocks. Probe shows no `TransitionState != OK` events at positions away from visible walls/furniture.
|
||||
2. **#85 acceptance** — User stands 5+ m west of the Inn, runs at the west wall. Player blocks at the wall plane (within ~0.05 m of the visible wall surface). User cannot enter the building except via a door portal.
|
||||
3. **#86 acceptance** — Mouse over a wall pixel from outside the Inn → cursor shows no selection. Mouse over an NPC through an open door portal → cursor shows the NPC selection ring (selection still works through real apertures).
|
||||
|
||||
---
|
||||
|
||||
## 10. Acceptance criteria
|
||||
|
||||
- All three issues meet their respective acceptance gates above (visual confirmation by user).
|
||||
- `dotnet build` green.
|
||||
- `dotnet test` green (new tests + all existing).
|
||||
- Roadmap "shipped" table updated.
|
||||
- `docs/ISSUES.md` #84/#85/#86 moved to "Recently closed" with commit SHAs.
|
||||
- A short post-phase handoff doc (`docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md`) records the probe evidence + the three root causes, parallel to the existing Phase 1+2 docs.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase name + roadmap placement
|
||||
|
||||
**Proposed name:** "Indoor walking Phase 1 — BSP cluster (#84/#85/#86)".
|
||||
|
||||
Reasons:
|
||||
- Continues the "Indoor X Phase N" naming established by Phase 1 (probes) + Phase 2 (rendering fix).
|
||||
- Distinguishes from indoor RENDERING work (which is done) — the focus has shifted to indoor WALKING.
|
||||
- "Phase 1" implies more phases follow (Phase 2 likely = #78 outdoor-stab visibility cluster).
|
||||
|
||||
**Roadmap placement:** Add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Insert after the Indoor cell rendering Phase 2 entry. Cross-link to ISSUES.md #84/#85/#86.
|
||||
|
||||
**Milestone:** This is parallel to the M2 critical path (which is F.2 / F.3 / F.5a / L.1c / L.1b). M1 already landed and is frozen. Indoor walking work is a quality-of-life parallel track — the user's recent commits put it ahead of M2 work because the rendering Phase 2 ship made it actionable.
|
||||
|
||||
---
|
||||
|
||||
## 12. Out of scope
|
||||
|
||||
- **#78** — outdoor stabs/buildings visible through rendered floor. Different code path (visibility / stencil). Filed for Indoor walking Phase 2.
|
||||
- **#79-#82** — lighting / terrain shading. Cluster B in the handoff. Separate phase.
|
||||
- **#83** — walking up stairs broken. Standalone issue. May share code with this phase if the cell BSP fix touches step-up; address opportunistically only if so.
|
||||
- **Refactoring `WorldPicker`** beyond adding the occluder. The existing two-overload structure stays.
|
||||
- **Stage B picker refine** (Möller-Trumbore against entity body polygons) — Issue #71, deferred per existing roadmap.
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks
|
||||
|
||||
1. **Capture is inconclusive.** If the probe fires zero unexpected events during scenario 1 (i.e., #84 cannot be reproduced live during the capture), we extend the probe to also log `BSPQuery` internals or capture a longer session. Probably one more launch.
|
||||
2. **#85 fix requires significant retail-decomp port.** Cross-cell BSP probing (querying an EnvCell's BSP from an outdoor cell) is not in the current code. The retail decomp at `named-retail/acclient_2013_pseudo_c.txt` has `CCellStructure::find_env_collisions` and neighbors that handle this. If the port is non-trivial (more than ~100 lines), promote #85 to its own dedicated phase rather than including it here. Decision point: after the capture, before commit 3.
|
||||
3. **`CellBspRayOccluder` performance.** Möller-Trumbore against ~80 cells × ~50 polys each = ~4K triangle tests per `Pick` call. Picker fires once per click — acceptable. If we ever move to hover-pick (every frame), this needs an acceleration structure; not in scope here.
|
||||
4. **Probe gets noisy.** If `FindEnvCollisions` fires at 30 Hz × N cells, the log can grow fast. Add a per-call rate limit only if the capture log is unreadable; default to unlimited (Phase 1+2 didn't need limiting).
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# Indoor Cell Rendering Fix — Phase 2 Design
|
||||
|
||||
**Status:** Brainstormed 2026-05-19. Awaiting user review.
|
||||
**Scope:** Surface the silent failure in WB's `PrepareEnvCellMeshData` for 26/123 Holtburg cells, then implement the targeted fix.
|
||||
**Predecessor:** Phase 1 (`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`) shipped the five `[indoor-*]` probes that confirmed hypothesis H1.
|
||||
**Capture evidence:** `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we know
|
||||
|
||||
Phase 1's `ACDREAM_PROBE_INDOOR_ALL=1` capture at Holtburg `0xA9B4` proved:
|
||||
|
||||
- 123 EnvCells requested via `WbMeshAdapter.IncrementRefCount` → only **97 complete**.
|
||||
- **26 cells** silently fail. They get `[indoor-upload] requested` but never `[indoor-upload] completed`.
|
||||
- The dispatcher then tries to draw them, `TryGetRenderData` returns null, draw is silently skipped → user sees **missing floor**.
|
||||
- The first interior cell `0xA9B40100` (likely the inn entry or another major building anchor) is among the 26.
|
||||
|
||||
The smoking gun is in WB's [`ObjectMeshManager.PrepareMeshData`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs):
|
||||
|
||||
```csharp
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
WB logs the exception via its injected `_logger`. But [`WbMeshAdapter.cs:71`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:71) constructs `ObjectMeshManager` with `NullLogger<ObjectMeshManager>.Instance` — so the log goes to `/dev/null`. The exception type and message are lost.
|
||||
|
||||
## 2. Solution — three components
|
||||
|
||||
### Component 1 — Exception-surfacing wrap
|
||||
|
||||
Capture the `Task<ObjectMeshData?>` returned by `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` and attach a continuation that, for EnvCell IDs only, logs the failure cause.
|
||||
|
||||
Three logged outcomes:
|
||||
|
||||
- **Task faulted** → `[indoor-upload] FAILED cellId=0x... exception=<TypeName>: <Message> stack=[<top 3 frames>]`. Unwrap `AggregateException.InnerException` for cleaner output.
|
||||
- **Task succeeded with null result** → `[indoor-upload] NULL_RESULT cellId=0x...`. WB's deliberate null-return path (e.g., `ResolveId` returned empty, type was `Unknown`).
|
||||
- **Task succeeded with non-null result** → no extra log. The existing `Tick()` drain already emits `[indoor-upload] completed`.
|
||||
|
||||
The continuation:
|
||||
- Runs on `TaskScheduler.Default` (`ThreadPool`) so it doesn't block the render thread.
|
||||
- Only attached for EnvCell IDs (gated by `RenderingDiagnostics.IsEnvCellId(id)`) when `ProbeIndoorUploadEnabled` is true — zero cost when off.
|
||||
- Captures `cellId` (a `ulong` value) only; no instance closure leakage.
|
||||
- Truncates stack trace to top 3 frames.
|
||||
|
||||
Concrete code shape:
|
||||
|
||||
```csharp
|
||||
if (_metadataPopulated.Add(id))
|
||||
{
|
||||
PopulateMetadata(id);
|
||||
var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
|
||||
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
|
||||
{
|
||||
_pendingEnvCellRequests.Add(id);
|
||||
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
|
||||
|
||||
ulong cellId = id;
|
||||
_ = prepTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted && t.Exception is not null)
|
||||
{
|
||||
var ex = t.Exception.InnerException ?? t.Exception;
|
||||
var stack = (ex.StackTrace ?? "").Split('\n')
|
||||
.Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
|
||||
Console.WriteLine(
|
||||
$"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
|
||||
$"exception={ex.GetType().Name}: {ex.Message} " +
|
||||
$"stack=[{string.Join(" | ", stack)}]");
|
||||
}
|
||||
else if (t.IsCompletedSuccessfully && t.Result is null)
|
||||
{
|
||||
Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}");
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`using System.Linq;` and `using System.Threading.Tasks;` may need adding (likely already present).
|
||||
|
||||
### Component 2 — Capture procedure
|
||||
|
||||
Standard launch:
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_PROBE_INDOOR_UPLOAD = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log
|
||||
```
|
||||
|
||||
Walk into Holtburg Inn, walk into nearby buildings whose cells were on the missing-26 list (`0xA9B40100`, `0xA9B40111`, etc.). Close gracefully.
|
||||
|
||||
Analyze:
|
||||
|
||||
```powershell
|
||||
Get-Content launch.log |
|
||||
Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' } |
|
||||
Select-Object -Unique
|
||||
```
|
||||
|
||||
Expected output: a per-cell list of distinct exception types or null-return signals. Most cells likely share 1–3 root causes.
|
||||
|
||||
### Component 3 — Targeted fix (shape unknown until Component 2 captures)
|
||||
|
||||
Once Component 2 reveals the exception type + message, the fix is one localized code change. Likely shapes:
|
||||
|
||||
| Captured cause | Fix shape |
|
||||
|---|---|
|
||||
| Texture decode `Exception` (e.g. `KeyNotFoundException` on surface ID) | Guard at `WbMeshAdapter.PopulateMetadata` or pre-validate surfaces; possibly patch WB fork. |
|
||||
| `KeyNotFoundException` for missing `Environment` / `CellStruct` | Log + skip cell with a sentinel render-data; report which dat is stale. |
|
||||
| `NullReferenceException` in `PrepareCellStructMeshData` | Add null guard at the specific call site. |
|
||||
| WB internal logic bug | Fork patch to WB. |
|
||||
| `NULL_RESULT` (ResolveId returned empty / type was Unknown) | Investigate dat file integrity; possibly user needs a dat update. |
|
||||
|
||||
The fix is one or two code edits, lands as a single commit, and is followed by a re-launch verifying:
|
||||
- `[indoor-upload] FAILED` / `NULL_RESULT` lines disappear for the previously-failing cells.
|
||||
- `[indoor-upload] completed` appears for those cells.
|
||||
- Visual verification: floor renders in Holtburg Inn.
|
||||
|
||||
---
|
||||
|
||||
## 3. Edge cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Probe toggled off mid-session | Continuation still emits if attached at request time. Acceptable — capturing the cause once matters more than honoring runtime toggle. |
|
||||
| Continuation fires after adapter disposed | Harmless console write on dying process. No memory leak; closure captures only the `ulong` cellId. |
|
||||
| Same cell requested twice | `_metadataPopulated.Add(id)` guards; continuation attaches exactly once. Re-streaming after Remove+Add keeps the sticky set. First failure is what we want. |
|
||||
| Cancellation | `t.IsCanceled` is neither `IsFaulted` nor `IsCompletedSuccessfully`. Continuation silently skips. Acceptable — cancellation isn't a failure cause. |
|
||||
| `Task.Result` on faulted task | Re-throws AggregateException. Our gate `else if (t.IsCompletedSuccessfully && t.Result is null)` ensures we never read Result without a clean success state. |
|
||||
| WB's `_logger.LogError` for the same exception | WbMeshAdapter passes `NullLogger` — WB's log goes nowhere. Our continuation is what surfaces it. Discussed below. |
|
||||
|
||||
**Why not just inject a real logger into `ObjectMeshManager`?** Could replace `NullLogger<ObjectMeshManager>.Instance` with a real logger that writes to `Console.WriteLine`. Tradeoff:
|
||||
|
||||
- Real logger: simpler, leverages WB's existing `_logger.LogError` call → catches GfxObj + Setup + EnvCell failures.
|
||||
- Our continuation: scoped to EnvCell IDs only → less noise.
|
||||
|
||||
Going with the continuation approach because:
|
||||
1. The probe flag is already in place.
|
||||
2. Phase 2 is targeted at EnvCells.
|
||||
3. Real-logger would emit thousands of GfxObj/Setup log lines during landblock streaming, drowning the EnvCell signal.
|
||||
|
||||
We can revisit if a future debugging session calls for broader visibility.
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
None for Component 1 — the continuation is straight wiring around an async API; the logic is "if faulted, log; if null result, log." Testing requires either mocking `Task<ObjectMeshData?>` (low value) or running a real WB instance (impractical in unit tests).
|
||||
|
||||
### Visual verification (end-to-end)
|
||||
|
||||
Component 2's capture procedure is the verification mechanism:
|
||||
|
||||
1. Build green.
|
||||
2. Launch with probe flag on, walk into Holtburg.
|
||||
3. Confirm `[indoor-upload] FAILED` or `NULL_RESULT` lines appear for ~26 cells.
|
||||
4. Apply Component 3's fix.
|
||||
5. Re-launch, re-walk Holtburg.
|
||||
6. **Acceptance:** previously-failing cells now produce `[indoor-upload] completed` lines AND the user can see the floor in Holtburg Inn.
|
||||
|
||||
---
|
||||
|
||||
## 5. What's NOT in this phase
|
||||
|
||||
- Tightening `IsEnvCellId` false-positives (flagged in Phase 1 capture note). Deferred — doesn't block Phase 2 since the upload probe gates on the correct path.
|
||||
- Cell collision symptoms (no wall collision when exiting, weird open-air collisions). Separate investigation phase.
|
||||
- Stab-leak-through-walls (Phase 1 Task 3). Deferred.
|
||||
- Broader WB logger injection for GfxObj/Setup failures. Open if we ever want broader diagnostic visibility.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria
|
||||
|
||||
- [ ] `WbMeshAdapter.IncrementRefCount` captures the prep task and attaches a continuation for EnvCell IDs.
|
||||
- [ ] Continuation logs `[indoor-upload] FAILED cellId=0x... exception=<TypeName>: <Message> stack=[...]` for faulted tasks.
|
||||
- [ ] Continuation logs `[indoor-upload] NULL_RESULT cellId=0x...` for clean-null returns.
|
||||
- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged).
|
||||
- [ ] Capture launched, FAILED/NULL_RESULT lines appear for the previously-missing cells, distinct causes identified.
|
||||
- [ ] Component 3 fix designed and implemented for each distinct cause.
|
||||
- [ ] Re-capture confirms `[indoor-upload] completed` appears for cells previously missing.
|
||||
- [ ] Visual verification: floor renders in Holtburg Inn.
|
||||
- [ ] Roadmap updated with Phase 2 shipped.
|
||||
- [ ] Commit messages cite the captured exception types + the fix rationale.
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81
|
||||
Subproject commit 34460c44d7fb921afa50ee30288a53236f50f451
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Core.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.App.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||
|
|
|
|||
|
|
@ -154,6 +154,16 @@ public sealed class PlayerMovementController
|
|||
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
|
||||
public Vector3 BodyVelocity => _body.Velocity;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16 — current contact plane (normal + distance) for the
|
||||
/// physics body. Exposed so the network outbound layer can stamp
|
||||
/// it into <see cref="NotePositionSent"/> for retail's diff-driven
|
||||
/// AP cadence: SendPositionEvent re-sends if cell OR contact-plane
|
||||
/// changed since last_sent, per
|
||||
/// <c>acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent</c>.
|
||||
/// </summary>
|
||||
public System.Numerics.Plane ContactPlane => _body.ContactPlane;
|
||||
|
||||
// Jump charge state.
|
||||
private bool _jumpCharging;
|
||||
private float _jumpExtent;
|
||||
|
|
@ -186,10 +196,33 @@ public sealed class PlayerMovementController
|
|||
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
|
||||
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
|
||||
// but wasteful and probably looked like jitter to observers.
|
||||
private float _heartbeatAccum;
|
||||
public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
|
||||
/// <summary>
|
||||
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's
|
||||
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
|
||||
/// at address 0x006b45e0) which gates on either (a) position-or-cell
|
||||
/// change since the last send, or (b) at-rest 1 sec heartbeat elapsed.
|
||||
/// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
|
||||
///
|
||||
/// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
|
||||
/// missed retail's per-frame-while-moving behaviour and forced the
|
||||
/// four B.6 workarounds (arrival margin, re-send on arrival, AP
|
||||
/// flush, retry flag) to compensate for the lag in ACE's server-side
|
||||
/// WithinUseRadius poll. Replaced by diff-driven cadence below.
|
||||
/// </summary>
|
||||
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
|
||||
|
||||
private System.Numerics.Vector3 _lastSentPos;
|
||||
private uint _lastSentCellId;
|
||||
private System.Numerics.Plane _lastSentContactPlane;
|
||||
private float _lastSentTime;
|
||||
private bool _lastSentInitialized;
|
||||
private float _simTimeSeconds;
|
||||
public bool HeartbeatDue { get; private set; }
|
||||
|
||||
/// <summary>Sim-time accumulator (advanced by dt at the top of Update).
|
||||
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
|
||||
public float SimTimeSeconds => _simTimeSeconds;
|
||||
|
||||
// L.5 retail physics-tick gate (2026-04-30).
|
||||
//
|
||||
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
|
||||
|
|
@ -234,22 +267,51 @@ public sealed class PlayerMovementController
|
|||
private float _autoWalkMinDistance;
|
||||
private float _autoWalkDistanceToObject;
|
||||
private bool _autoWalkMoveTowards;
|
||||
// Walk-vs-run decision is made ONCE at BeginServerAutoWalk based on
|
||||
// initial distance vs the wire's WalkRunThreshold, then held for the
|
||||
// duration of the auto-walk. Earlier per-frame evaluation produced
|
||||
// "runs partway then walks the rest" which the user reported as
|
||||
// wrong: the character should run all the way to the target then
|
||||
// stop, not transition to a walk near the end.
|
||||
// 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT
|
||||
// decision at chain start. Per user observation 2026-05-16: if
|
||||
// initial distance is at or above the walk-run threshold, the
|
||||
// body runs all the way to the target; otherwise it walks all
|
||||
// the way. No per-frame switching as the player closes distance.
|
||||
//
|
||||
// Formula matches retail's MovementParameters::get_command
|
||||
// (decomp 0x0052aa00, line 308000+):
|
||||
// running = (initialDist - distance_to_object) >= walk_run_threshhold
|
||||
// The "distance left to walk" (current minus use-radius) is
|
||||
// compared against the wire-supplied threshold (15m default,
|
||||
// retail constant at 0x005243b5). The retail function reads
|
||||
// `arg2` as the current distance but in practice is called at
|
||||
// chain setup with the initial distance, and the resulting
|
||||
// decision is cached for the rest of the chain — matching the
|
||||
// user-observed "run all the way / walk all the way" behaviour.
|
||||
private bool _autoWalkInitiallyRunning;
|
||||
|
||||
/// <summary>
|
||||
/// True while a server-initiated auto-walk (MoveToObject inbound) is
|
||||
/// active on the local player. The next <see cref="Update"/> call
|
||||
/// synthesizes Forward+Run input and steers <see cref="Yaw"/> toward
|
||||
/// the destination until arrival or user-input cancellation.
|
||||
/// active on the local player. Update drives the body's velocity
|
||||
/// and motion state machine DIRECTLY from the wire-supplied path
|
||||
/// data, NOT via synthesized player-input. The
|
||||
/// motion-state-change detection downstream sees no user input
|
||||
/// during auto-walk, so no MoveToState wire packet is built — ACE's
|
||||
/// server-side MoveToChain can run uninterrupted until its callback
|
||||
/// fires.
|
||||
/// </summary>
|
||||
public bool IsServerAutoWalking => _autoWalkActive;
|
||||
|
||||
// 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is
|
||||
// actually advancing the body this frame. False during the
|
||||
// turn-first phase (rotating in place toward target) and after
|
||||
// arrival. Drives the animation cycle override: walking animation
|
||||
// only plays when the body is actually moving forward.
|
||||
private bool _autoWalkMovingForwardThisFrame;
|
||||
|
||||
// 2026-05-16 (issue #69 fix) — turn direction this frame.
|
||||
// +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
|
||||
// -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle
|
||||
// 0 = aligned or not turning
|
||||
// Drives the animation cycle override during turn-first phase so
|
||||
// the body plays the actual turn animation instead of statue-pivoting.
|
||||
private int _autoWalkTurnDirectionThisFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Fires once when an auto-walk reaches its destination naturally
|
||||
/// (i.e. <see cref="EndServerAutoWalk"/> called with
|
||||
|
|
@ -368,7 +430,7 @@ public sealed class PlayerMovementController
|
|||
float minDistance,
|
||||
float distanceToObject,
|
||||
bool moveTowards,
|
||||
float walkRunThreshold)
|
||||
bool canCharge)
|
||||
{
|
||||
_autoWalkActive = true;
|
||||
_autoWalkDestination = destinationWorld;
|
||||
|
|
@ -376,17 +438,28 @@ public sealed class PlayerMovementController
|
|||
_autoWalkDistanceToObject = distanceToObject;
|
||||
_autoWalkMoveTowards = moveTowards;
|
||||
|
||||
// Decide run vs walk ONCE based on the initial horizontal
|
||||
// distance from the player to the destination. Run-all-the-way
|
||||
// is the retail-faithful behaviour the user observed: pick a
|
||||
// distant target → character runs the whole way, decelerates
|
||||
// to a stop at use radius. Earlier per-frame evaluation made
|
||||
// the body transition to a walk inside threshold and felt
|
||||
// wrong (the user reported "runs partway then walks").
|
||||
float dx = destinationWorld.X - _body.Position.X;
|
||||
float dy = destinationWorld.Y - _body.Position.Y;
|
||||
float initialDist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
_autoWalkInitiallyRunning = initialDist > walkRunThreshold;
|
||||
// Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run.
|
||||
//
|
||||
// Retail's MovementParameters::get_command (decomp 0x0052aa00)
|
||||
// gates run on the CanCharge flag (bit 0x10 of
|
||||
// MovementParameters). Cleared → fall through to the inner
|
||||
// walk_run_threshold check, which ACE's 15 m wire default +
|
||||
// 0.6 m use-radius makes practically always walk for any
|
||||
// chase under 15.6 m. Set → unconditional HoldKey_Run.
|
||||
//
|
||||
// ACE's Creature.SetWalkRunThreshold sets CanCharge when
|
||||
// (server-side player→target distance) >= WalkRunThreshold /
|
||||
// 2 (= 7.5 m for the 15 m default), and clears it otherwise.
|
||||
// The CanCharge bit IS the wire-side walk-vs-run answer; we
|
||||
// just relay it.
|
||||
//
|
||||
// Previously we hardcoded a 1.0 m threshold against
|
||||
// initialDist - distanceToObject, which forced run at any
|
||||
// chase past ~1.6 m — including the 3-5 m "walk range" the
|
||||
// user expected to walk in (issue #77 reproduction). Honoring
|
||||
// CanCharge restores the retail bucket: walk under ~7.5 m,
|
||||
// run beyond.
|
||||
_autoWalkInitiallyRunning = canCharge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -405,6 +478,29 @@ public sealed class PlayerMovementController
|
|||
AutoWalkArrived?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Called by the network outbound layer after every
|
||||
/// AutonomousPosition or MoveToState that carries the player's
|
||||
/// position. Resets the diff-driven heartbeat clock so the next
|
||||
/// `HeartbeatDue` evaluation requires either a fresh state change
|
||||
/// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
|
||||
/// Mirrors retail's SendPositionEvent at
|
||||
/// <c>acclient_2013_pseudo_c.txt:700345-700348</c> which updates
|
||||
/// `last_sent_position`, `last_sent_position_time`, AND
|
||||
/// `last_sent_contact_plane` after every send.
|
||||
/// </summary>
|
||||
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||
uint cellId,
|
||||
System.Numerics.Plane contactPlane,
|
||||
float nowSeconds)
|
||||
{
|
||||
_lastSentPos = worldPos;
|
||||
_lastSentCellId = cellId;
|
||||
_lastSentContactPlane = contactPlane;
|
||||
_lastSentTime = nowSeconds;
|
||||
_lastSentInitialized = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
|
||||
/// active, either cancel it (user pressed a movement key) or
|
||||
|
|
@ -422,9 +518,33 @@ public sealed class PlayerMovementController
|
|||
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
|
||||
/// <summary>
|
||||
/// 2026-05-16 (issue #75 refactor) — drive the body directly from
|
||||
/// the wire-supplied path data during server-initiated auto-walk,
|
||||
/// without synthesizing player-input. Replaces the earlier
|
||||
/// ApplyAutoWalkOverlay which returned a synthesized Forward+Run
|
||||
/// MovementInput; that synthesis leaked to the wire as an outbound
|
||||
/// MoveToState packet ("user is RunForward") which ACE read as
|
||||
/// user-took-manual-control and cancelled its own MoveToChain. The
|
||||
/// architecture now mirrors retail's MovementManager::PerformMovement
|
||||
/// case 6 (decomp 0x00524440): step the body's velocity + motion
|
||||
/// state directly; the user-input pipeline downstream sees no input
|
||||
/// because the user didn't press anything, so no MoveToState gets
|
||||
/// built.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>true</c> when this method consumed motion control for
|
||||
/// the frame (auto-walk active, no user override, no arrival).
|
||||
/// Caller (<see cref="Update"/>) must skip the user-input motion +
|
||||
/// body-velocity sections to avoid them overriding the auto-walk's
|
||||
/// velocity assignment.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private bool DriveServerAutoWalk(float dt, MovementInput input)
|
||||
{
|
||||
if (!_autoWalkActive) return input;
|
||||
_autoWalkMovingForwardThisFrame = false;
|
||||
_autoWalkTurnDirectionThisFrame = 0;
|
||||
if (!_autoWalkActive) return false;
|
||||
|
||||
// User-input cancellation. Any direct movement key takes over.
|
||||
// Mouse-only turning (no movement key) doesn't cancel — the
|
||||
|
|
@ -435,7 +555,7 @@ public sealed class PlayerMovementController
|
|||
if (userOverride)
|
||||
{
|
||||
EndServerAutoWalk("user-input");
|
||||
return input;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Horizontal distance to target — server owns Z, our local body
|
||||
|
|
@ -463,11 +583,17 @@ public sealed class PlayerMovementController
|
|||
float arrivalThreshold = _autoWalkMoveTowards
|
||||
? _autoWalkDistanceToObject
|
||||
: _autoWalkMinDistance;
|
||||
const float TinyMargin = 0.05f;
|
||||
float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f);
|
||||
// 2026-05-16 — retail "stop at the radius" semantics.
|
||||
// Previously had a 0.05 m TinyMargin inside the threshold to
|
||||
// ensure ACE's server-side WithinUseRadius poll saw us inside
|
||||
// the radius before our next AP heartbeat. With the
|
||||
// diff-driven AP cadence (Task B2) ACE sees the final position
|
||||
// the same frame we arrive — no margin needed. Retail's
|
||||
// arrival check is `dist <= radius` exact at
|
||||
// CMotionInterp::apply_interpreted_movement integration.
|
||||
bool withinArrival =
|
||||
(_autoWalkMoveTowards
|
||||
&& dist <= effectiveArrival)
|
||||
&& dist <= arrivalThreshold)
|
||||
|| (!_autoWalkMoveTowards
|
||||
&& dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon);
|
||||
|
||||
|
|
@ -502,16 +628,26 @@ public sealed class PlayerMovementController
|
|||
// MathF.Min(|delta|, maxStep) naturally clamps the final
|
||||
// fractional step to exactly delta, so we land on the
|
||||
// target heading without overshoot.
|
||||
// 2026-05-16 — retail-faithful turn rate. Auto-walk knows
|
||||
// its run/walk decision from _autoWalkInitiallyRunning
|
||||
// (set at BeginServerAutoWalk based on initial distance vs
|
||||
// WalkRunThreshold). Running rotation is 50% faster per
|
||||
// 2026-05-16 — retail-faithful turn rate. Auto-walk's
|
||||
// run/walk decision (one-shot at chain start) drives the
|
||||
// turn rate: running rotation is 50% faster per
|
||||
// run_turn_factor at retail 0x007c8914.
|
||||
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
|
||||
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||
float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||
Yaw += yawStep;
|
||||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||||
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
||||
|
||||
// 2026-05-16 (issue #69) — record rotation direction so the
|
||||
// animation override can pick the TurnLeft/TurnRight cycle.
|
||||
// Sign convention matches user-driven A/D in Update:
|
||||
// yawStep > 0 ⇔ TurnLeft (Yaw increases)
|
||||
// yawStep < 0 ⇔ TurnRight (Yaw decreases)
|
||||
// Small dead-zone avoids flickering between Turn cycles
|
||||
// when the residual delta is effectively zero.
|
||||
if (MathF.Abs(yawStep) > 1e-5f)
|
||||
_autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1;
|
||||
|
||||
// Two alignment thresholds:
|
||||
// walkWhileTurning (30°): outside this, body turns in place.
|
||||
// Inside, body walks forward while
|
||||
|
|
@ -536,15 +672,14 @@ public sealed class PlayerMovementController
|
|||
if (withinArrival && aligned)
|
||||
{
|
||||
EndServerAutoWalk("arrived");
|
||||
return input;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk vs run decided ONCE at BeginServerAutoWalk based on
|
||||
// initial distance — held for the rest of the auto-walk so the
|
||||
// character keeps running all the way to the target instead of
|
||||
// transitioning to a walk inside the threshold. Matches user-
|
||||
// observed retail behaviour ("if its far away it should run
|
||||
// all the way to the object and then stop").
|
||||
// Walk vs run uses the one-shot decision from BeginServerAutoWalk
|
||||
// (initial distance minus use-radius vs walkRunThreshold).
|
||||
// Held for the rest of the auto-walk so the body runs all
|
||||
// the way to a far target, or walks all the way to a near
|
||||
// one — matching user-observed retail behaviour.
|
||||
bool shouldRun = _autoWalkInitiallyRunning;
|
||||
|
||||
// Turn-first gate: if not yet within the 30° walking band,
|
||||
|
|
@ -554,22 +689,80 @@ public sealed class PlayerMovementController
|
|||
// into it.
|
||||
bool moveForward = walkAligned && !withinArrival;
|
||||
|
||||
// Synthesize "moving forward" input. The rest of Update reads
|
||||
// Yaw + input.Forward + input.Run to drive _motion + body
|
||||
// velocity exactly as it does for user-driven W (+ optional Shift).
|
||||
// We zero any mouse delta so a stale frame doesn't fight the
|
||||
// steering.
|
||||
return input with
|
||||
if (!moveForward)
|
||||
{
|
||||
Forward = moveForward,
|
||||
Run = moveForward && shouldRun,
|
||||
Backward = false,
|
||||
StrafeLeft = false,
|
||||
StrafeRight = false,
|
||||
TurnLeft = false,
|
||||
TurnRight = false,
|
||||
MouseDeltaX = 0f,
|
||||
};
|
||||
// Turn-in-place phase. Two sub-cases land here:
|
||||
// (a) initial turn — body must rotate to face the target
|
||||
// before we drive forward (walkAligned == false at chain
|
||||
// start, body is stationary).
|
||||
// (b) overshoot recovery — body crossed the destination, so
|
||||
// desiredYaw flipped ~180° and walkAligned dropped to
|
||||
// false; body needs to turn around before walking back.
|
||||
// (c) settling — body is within use-radius but not aligned
|
||||
// enough to fire arrival (withinArrival == true,
|
||||
// !aligned); body holds position while finishing rotation
|
||||
// so the arrival predicate fires on the next tick.
|
||||
//
|
||||
// Issue #77 fix: explicitly zero horizontal velocity. Without
|
||||
// this, in case (b) the body keeps the prior frame's running
|
||||
// velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slides past
|
||||
// the destination by several meters before the turn-around
|
||||
// rotation completes — the "runs and slides away, runs back,
|
||||
// picks up" symptom reported in issue #77 / bug B. Cases (a)
|
||||
// and (c) zero a velocity that's already zero, so the change
|
||||
// is a no-op there.
|
||||
//
|
||||
// The motion-interpreter state also has to step out of
|
||||
// WalkForward so get_state_velocity (used downstream) reports
|
||||
// standing-velocity, not the prior frame's run-speed.
|
||||
_motion.DoMotion(MotionCommand.Ready, 1.0f);
|
||||
if (_body.OnWalkable)
|
||||
{
|
||||
float savedWorldVz = _body.Velocity.Z;
|
||||
_body.set_local_velocity(new Vector3(0f, 0f, savedWorldVz));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Drive motion state machine + body velocity directly. This
|
||||
// mirrors what the user-input section would have done with
|
||||
// synthesized Forward+Run, but without putting anything into
|
||||
// MovementInput — so the outbound-packet pipeline never builds
|
||||
// a MoveToState packet for auto-walk frames.
|
||||
uint forwardCmd;
|
||||
float forwardCmdSpeed;
|
||||
if (shouldRun && _weenie.InqRunRate(out float runRate))
|
||||
{
|
||||
// Wire-compatible: WalkForward command @ runRate triggers
|
||||
// ACE's auto-upgrade to RunForward for observers. Same
|
||||
// shape as the user-input section's running path.
|
||||
forwardCmd = MotionCommand.WalkForward;
|
||||
forwardCmdSpeed = runRate;
|
||||
}
|
||||
else
|
||||
{
|
||||
forwardCmd = MotionCommand.WalkForward;
|
||||
forwardCmdSpeed = 1.0f;
|
||||
}
|
||||
|
||||
_autoWalkMovingForwardThisFrame = true;
|
||||
|
||||
// Update interpreted motion state — drives the animation cycle
|
||||
// via UpdatePlayerAnimation downstream + the MotionInterpreter's
|
||||
// state-velocity getter (used for our velocity assignment below).
|
||||
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
|
||||
|
||||
// Set body velocity directly. Only meaningful when grounded;
|
||||
// mirror the user-input section's `if (_body.OnWalkable)` gate
|
||||
// so we don't override gravity/jump velocity mid-air.
|
||||
if (_body.OnWalkable)
|
||||
{
|
||||
float savedWorldVz = _body.Velocity.Z;
|
||||
var stateVel = _motion.get_state_velocity();
|
||||
_body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
|
||||
|
|
@ -613,14 +806,24 @@ public sealed class PlayerMovementController
|
|||
|
||||
public MovementResult Update(float dt, MovementInput input)
|
||||
{
|
||||
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
|
||||
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
|
||||
// synthesize Forward+Run input so the rest of Update runs the
|
||||
// body forward as if the user were holding W. User movement-key
|
||||
// input cancels the auto-walk (retail UX). Arrival check fires
|
||||
// before synthesizing, so a one-frame arrival doesn't waste a
|
||||
// tick of velocity past the target.
|
||||
input = ApplyAutoWalkOverlay(dt, input);
|
||||
_simTimeSeconds += dt;
|
||||
|
||||
// 2026-05-16 (issue #75 refactor): server-initiated auto-walk
|
||||
// drives the body's velocity + motion state machine DIRECTLY.
|
||||
// When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes
|
||||
// velocity from wire-supplied runRate, calls _motion.DoMotion,
|
||||
// and sets _body.set_local_velocity. The user-input motion +
|
||||
// velocity sections below are SKIPPED so they don't override
|
||||
// the auto-walk's assignments. Critically, no synthesized input
|
||||
// gets put back into `input` — the outbound-packet pipeline at
|
||||
// GameWindow.cs:6410 sees user-input null/Ready throughout the
|
||||
// auto-walk and never builds a MoveToState packet, leaving
|
||||
// ACE's server-side MoveToChain to run uninterrupted until its
|
||||
// TryUseItem/TryPickUp callback fires. Retail equivalent:
|
||||
// MovementManager::PerformMovement case 6 (decomp 0x00524440)
|
||||
// calls CPhysicsObj::MoveToObject server-side; the local body
|
||||
// is moved without ever touching CommandInterpreter input.
|
||||
bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, input);
|
||||
|
||||
// Portal-space guard: while teleporting, no input is processed and
|
||||
// no physics is resolved. Return a zero-movement result so the caller
|
||||
|
|
@ -668,6 +871,14 @@ public sealed class PlayerMovementController
|
|||
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
|
||||
|
||||
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
|
||||
// 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns
|
||||
// motion control this frame — it has already called
|
||||
// _motion.DoMotion + _body.set_local_velocity from the auto-
|
||||
// walk's path data + runRate. Running this section would
|
||||
// overwrite the auto-walk velocity with the user-input
|
||||
// (Ready/Stand) velocity, freezing the body.
|
||||
if (!autoWalkConsumedMotion)
|
||||
{
|
||||
// Determine the dominant forward/backward command and speed.
|
||||
uint forwardCmd;
|
||||
float forwardCmdSpeed;
|
||||
|
|
@ -755,6 +966,7 @@ public sealed class PlayerMovementController
|
|||
|
||||
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
||||
}
|
||||
} // end of `if (!autoWalkConsumedMotion)` — section 2
|
||||
|
||||
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
||||
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
||||
|
|
@ -919,7 +1131,7 @@ public sealed class PlayerMovementController
|
|||
// L.4-diag (2026-04-30): trace position transitions so we can see
|
||||
// whether the body is actually moving frame-to-frame on the steep
|
||||
// roof, or whether it's frozen at the impact point.
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled
|
||||
&& resolveResult.CollisionNormalValid)
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
@ -1001,7 +1213,7 @@ public sealed class PlayerMovementController
|
|||
: (!prevOnWalkable && !nowOnWalkable);
|
||||
|
||||
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
|
||||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||||
bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled;
|
||||
if (diagSteep && resolveResult.CollisionNormalValid)
|
||||
{
|
||||
var n0 = resolveResult.CollisionNormal;
|
||||
|
|
@ -1192,31 +1404,55 @@ public sealed class PlayerMovementController
|
|||
}
|
||||
|
||||
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
|
||||
// Holtburger fires AutonomousPosition heartbeat at 1 Hz regardless of
|
||||
// motion state (gated only by has_autonomous_position_sync_target).
|
||||
// Retail's CommandInterpreter::SendPositionEvent gates on
|
||||
// transient_state (Contact + OnWalkable + valid Position), not on
|
||||
// motion. The pre-fix isMoving gate stopped acdream from heart-beating
|
||||
// at rest, which left observers with stale last-known positions during
|
||||
// long idle periods. PortalSpace (handled at the top of Update via
|
||||
// early return) skips Update entirely, so reaching this line implies
|
||||
// we're in a valid in-world pose.
|
||||
_heartbeatAccum += dt;
|
||||
// B.6+B.7 (2026-05-15): bump heartbeat from 1 Hz to ~10 Hz while
|
||||
// the body is actively moving (auto-walk OR user pressing W/A/S/D).
|
||||
// ACE's server-side CreateMoveToChain polls WithinUseRadius every
|
||||
// ~0.1 s using the latest Player.Location; 1 Hz heartbeats leave
|
||||
// up to 1 s of stale position data on the server, which meant
|
||||
// ACE's MoveToChain rejected our re-sent Use action as still
|
||||
// out-of-range. With 10 Hz updates ACE sees us approaching in
|
||||
// ~real-time and the server-side chain converges normally —
|
||||
// retires the arrival-margin / re-send / flush-AP workarounds.
|
||||
bool activelyMoving = _autoWalkActive
|
||||
|| input.Forward || input.Backward
|
||||
|| input.StrafeLeft || input.StrafeRight;
|
||||
float effectiveInterval = activelyMoving ? 0.1f : HeartbeatInterval;
|
||||
HeartbeatDue = _heartbeatAccum >= effectiveInterval;
|
||||
if (HeartbeatDue) _heartbeatAccum = 0f;
|
||||
// 2026-05-16 (closes #74) — retail-faithful AP cadence per
|
||||
// CommandInterpreter::ShouldSendPositionEvent at
|
||||
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
|
||||
//
|
||||
// Branch 1 — interval NOT yet elapsed (< 1 sec since last
|
||||
// send): send only if cell changed OR contact-plane changed
|
||||
// (mid-walk events that matter — stair / hill / cell cross).
|
||||
//
|
||||
// Branch 2 — interval HAS elapsed (>= 1 sec): send only if
|
||||
// cell OR position frame changed. Truly idle = no send
|
||||
// (retail's `last_sent.frame == player.frame` check at
|
||||
// acclient_2013_pseudo_c.txt:700248-700265).
|
||||
//
|
||||
// SendPositionEvent (line 700327) gates the actual send on
|
||||
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
|
||||
// OnWalkable both set. We mirror that gate so airborne and
|
||||
// wall-contact-without-walkable suppress AP entirely;
|
||||
// MoveToState carries jump/fall snapshots while airborne.
|
||||
//
|
||||
// Effective rates:
|
||||
// Truly idle (grounded, no movement) : 0 Hz
|
||||
// Smooth movement (no cell/plane changes) : ~1 Hz (interval)
|
||||
// Cell crossings + stair/hill steps : per-event
|
||||
// Airborne : 0 Hz
|
||||
//
|
||||
// Bootstrap: when NotePositionSent has never been called
|
||||
// (_lastSentInitialized=false), every state-changed branch is
|
||||
// forced true so the first AP gets a chance to fire.
|
||||
|
||||
bool intervalElapsed = !_lastSentInitialized
|
||||
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||
|
||||
bool cellChanged = !_lastSentInitialized
|
||||
|| _lastSentCellId != CellId;
|
||||
bool planeChanged = !_lastSentInitialized
|
||||
|| !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane);
|
||||
bool frameChanged = !_lastSentInitialized
|
||||
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||
|
||||
bool sendThisFrame = intervalElapsed
|
||||
? (cellChanged || frameChanged)
|
||||
: (cellChanged || planeChanged);
|
||||
|
||||
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||
// set simultaneously, NOT a bitwise-OR mask test.
|
||||
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||
|
||||
HeartbeatDue = groundedOnWalkable && sendThisFrame;
|
||||
|
||||
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
||||
// should match the actual movement speed. For Forward+Run this is
|
||||
|
|
@ -1230,6 +1466,40 @@ public sealed class PlayerMovementController
|
|||
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
|
||||
: 1f;
|
||||
|
||||
// 2026-05-16 (issue #75) — server-initiated auto-walk drives
|
||||
// the local animation cycle directly:
|
||||
// - moving forward → WalkForward / RunForward (legs animate)
|
||||
// - turn-first phase → TurnLeft / TurnRight (issue #69 fix)
|
||||
// - aligned but pre-step / arrival → no override, falls to
|
||||
// the user-input section's default (idle)
|
||||
// UpdatePlayerAnimation reads LocalAnimationCommand +
|
||||
// LocalAnimationSpeed; without these overrides the body
|
||||
// translates/rotates without leg/arm animation. The motion
|
||||
// cycle commands here flow into the animation sequencer
|
||||
// ONLY — the wire-layer guard at GameWindow.cs:6419 prevents
|
||||
// them from leaking to a user-MoveToState packet during
|
||||
// auto-walk.
|
||||
if (_autoWalkMovingForwardThisFrame)
|
||||
{
|
||||
if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate))
|
||||
{
|
||||
localAnimCmd = MotionCommand.RunForward;
|
||||
localAnimSpeed = autoWalkRunRate;
|
||||
}
|
||||
else
|
||||
{
|
||||
localAnimCmd = MotionCommand.WalkForward;
|
||||
localAnimSpeed = 1f;
|
||||
}
|
||||
}
|
||||
else if (_autoWalkTurnDirectionThisFrame != 0)
|
||||
{
|
||||
localAnimCmd = _autoWalkTurnDirectionThisFrame > 0
|
||||
? MotionCommand.TurnLeft
|
||||
: MotionCommand.TurnRight;
|
||||
localAnimSpeed = 1f;
|
||||
}
|
||||
|
||||
return new MovementResult(
|
||||
Position: Position,
|
||||
RenderPosition: RenderPosition,
|
||||
|
|
@ -1256,4 +1526,41 @@ public sealed class PlayerMovementController
|
|||
JumpExtent: outJumpExtent,
|
||||
JumpVelocity: outJumpVelocity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Position-equality test for diff-driven AP cadence.
|
||||
/// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263
|
||||
/// which is essentially exact float comparison after a memcmp of
|
||||
/// the frame struct. For floating-point safety we use a tiny epsilon
|
||||
/// — sub-millimeter — that's well below any movement we'd want to
|
||||
/// suppress sending for.
|
||||
/// </summary>
|
||||
private static bool ApproxPositionEqual(
|
||||
System.Numerics.Vector3 a, System.Numerics.Vector3 b)
|
||||
{
|
||||
const float Epsilon = 0.001f; // 1 mm
|
||||
return MathF.Abs(a.X - b.X) < Epsilon
|
||||
&& MathF.Abs(a.Y - b.Y) < Epsilon
|
||||
&& MathF.Abs(a.Z - b.Z) < Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Contact-plane-equality test for retail's
|
||||
/// sub-interval AP gate. Retail's SendPositionEvent stores
|
||||
/// last_sent_contact_plane and ShouldSendPositionEvent re-sends
|
||||
/// during the sub-interval window if the plane has changed (e.g.,
|
||||
/// player stepped onto stairs / a hill — same cell but different
|
||||
/// contact normal). Tiny epsilon on normal + distance covers
|
||||
/// floating-point noise from the physics integration.
|
||||
/// </summary>
|
||||
private static bool ApproxPlaneEqual(
|
||||
System.Numerics.Plane a, System.Numerics.Plane b)
|
||||
{
|
||||
const float NormalEpsilon = 1e-4f;
|
||||
const float DistanceEpsilon = 0.001f;
|
||||
return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon
|
||||
&& MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon
|
||||
&& MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon
|
||||
&& MathF.Abs(a.D - b.D) < DistanceEpsilon;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
src/AcDream.App/Net/LiveSessionController.cs
Normal file
114
src/AcDream.App/Net/LiveSessionController.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the network-side lifecycle of a live <see cref="WorldSession"/> —
|
||||
/// DNS resolution, endpoint construction, session instantiation, per-frame
|
||||
/// <c>Tick</c>, and disposal. The post-construction work (event wiring,
|
||||
/// <c>Connect</c>, character validation, <c>EnterWorld</c>, post-login UI
|
||||
/// state setup) stays in <c>GameWindow</c> for now because it touches
|
||||
/// renderer / chat / player-controller state that hasn't been extracted yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Step 2 of the extraction sequence described in
|
||||
/// <c>docs/architecture/code-structure.md</c> §4. Future expansions can
|
||||
/// fold more of <c>TryStartLiveSession</c> into this controller as the
|
||||
/// surrounding state (event handlers, command bus, settings VM) gets
|
||||
/// extracted in later steps.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Behavior preservation contract:</strong> this class produces
|
||||
/// byte-identical console output and event-wireup sequencing to the
|
||||
/// pre-refactor inline code path. The DNS-resolution lines, the
|
||||
/// "live: connecting to ..." line, and the wiring-vs-Connect ordering
|
||||
/// all match the previous flow.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LiveSessionController : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Active session, or <see langword="null"/> when offline / before
|
||||
/// <see cref="CreateAndWire"/> succeeded / after <see cref="Dispose"/>.
|
||||
/// </summary>
|
||||
public WorldSession? Session { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the endpoint, instantiates the <see cref="WorldSession"/>,
|
||||
/// hands it to <paramref name="wireEvents"/> for caller-side event
|
||||
/// subscriptions, and returns the live session. The caller is
|
||||
/// responsible for the subsequent <c>Connect</c> /
|
||||
/// <c>EnterWorld</c> dance.
|
||||
/// </summary>
|
||||
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> wireEvents)
|
||||
{
|
||||
if (options is null) throw new ArgumentNullException(nameof(options));
|
||||
if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents));
|
||||
|
||||
if (!options.LiveMode) return null;
|
||||
|
||||
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
|
||||
{
|
||||
Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort);
|
||||
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
|
||||
Session = new WorldSession(endpoint);
|
||||
wireEvents(Session);
|
||||
return Session;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"live: session setup failed: {ex.Message}");
|
||||
Session?.Dispose();
|
||||
Session = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains the inbound network queue. Proxies to
|
||||
/// <see cref="WorldSession.Tick"/>; no-op when <see cref="Session"/>
|
||||
/// is <see langword="null"/>.
|
||||
/// </summary>
|
||||
public void Tick() => Session?.Tick();
|
||||
|
||||
/// <summary>
|
||||
/// Tears down the live session. Safe to call multiple times.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Session?.Dispose();
|
||||
Session = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a host string (literal IP or DNS name) to an
|
||||
/// <see cref="IPEndPoint"/>. Pre-refactor logic preserved exactly:
|
||||
/// try <see cref="IPAddress.TryParse"/> first, fall back to
|
||||
/// <see cref="Dns.GetHostAddresses"/>, prefer IPv4 (ACE + retail use
|
||||
/// IPv4 UDP exclusively), throw on empty resolution.
|
||||
/// </summary>
|
||||
private static IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
IPAddress ip;
|
||||
if (!IPAddress.TryParse(host, out ip!))
|
||||
{
|
||||
var addrs = Dns.GetHostAddresses(host);
|
||||
ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? (addrs.Length > 0
|
||||
? addrs[0]
|
||||
: throw new Exception($"DNS resolved no addresses for '{host}'"));
|
||||
Console.WriteLine($"live: resolved {host} → {ip}");
|
||||
}
|
||||
return new IPEndPoint(ip, port);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.App;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Plugins;
|
||||
|
|
@ -15,6 +16,11 @@ if (string.IsNullOrWhiteSpace(datDir))
|
|||
return 2;
|
||||
}
|
||||
|
||||
// Single read of the startup-time process environment. Every downstream
|
||||
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
||||
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
||||
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||
|
||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
|
||||
|
|
@ -50,7 +56,7 @@ try
|
|||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
using var window = new GameWindow(datDir, worldGameState, worldEvents);
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
|
||||
window.Run();
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -1,47 +1,80 @@
|
|||
// src/AcDream.App/Rendering/CameraController.cs
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed class CameraController
|
||||
{
|
||||
public OrbitCamera Orbit { get; }
|
||||
public FlyCamera Fly { get; }
|
||||
public ChaseCamera? Chase { get; private set; }
|
||||
public ICamera Active { get; private set; }
|
||||
public bool IsFlyMode => Active == Fly;
|
||||
public bool IsChaseMode => Chase is not null && Active == Chase;
|
||||
public OrbitCamera Orbit { get; }
|
||||
public FlyCamera Fly { get; }
|
||||
public ChaseCamera? Chase { get; private set; }
|
||||
public RetailChaseCamera? RetailChase { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The renderer-facing active camera. Both the legacy and retail
|
||||
/// chase cameras are held simultaneously so that flipping
|
||||
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/> takes effect
|
||||
/// on the very next access to this property — no re-entry required,
|
||||
/// no notification mechanism, no stale state.
|
||||
/// </summary>
|
||||
public ICamera Active
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_mode == Mode.Fly) return Fly;
|
||||
if (_mode == Mode.Chase)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera && RetailChase is not null)
|
||||
return RetailChase;
|
||||
if (Chase is not null) return Chase;
|
||||
}
|
||||
return Orbit;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFlyMode => _mode == Mode.Fly;
|
||||
public bool IsChaseMode => _mode == Mode.Chase;
|
||||
|
||||
public event Action<bool>? ModeChanged;
|
||||
|
||||
private enum Mode { Orbit, Fly, Chase }
|
||||
private Mode _mode = Mode.Orbit;
|
||||
|
||||
public CameraController(OrbitCamera orbit, FlyCamera fly)
|
||||
{
|
||||
Orbit = orbit;
|
||||
Fly = fly;
|
||||
Active = orbit;
|
||||
Fly = fly;
|
||||
}
|
||||
|
||||
public void ToggleFly()
|
||||
{
|
||||
Active = IsFlyMode ? (ICamera)Orbit : Fly;
|
||||
_mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
|
||||
ModeChanged?.Invoke(IsFlyMode);
|
||||
}
|
||||
|
||||
public void EnterChaseMode(ChaseCamera chase)
|
||||
/// <summary>
|
||||
/// Store both cameras simultaneously; <see cref="Active"/> picks
|
||||
/// between them per-read via the flag — no re-entry needed on flip.
|
||||
/// </summary>
|
||||
public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
|
||||
{
|
||||
Chase = chase;
|
||||
Active = chase;
|
||||
Chase = legacy;
|
||||
RetailChase = retail;
|
||||
_mode = Mode.Chase;
|
||||
ModeChanged?.Invoke(IsChaseMode);
|
||||
}
|
||||
|
||||
public void ExitChaseMode()
|
||||
{
|
||||
Active = Fly;
|
||||
Chase = null;
|
||||
Chase = null;
|
||||
RetailChase = null;
|
||||
_mode = Mode.Fly;
|
||||
ModeChanged?.Invoke(IsFlyMode);
|
||||
}
|
||||
|
||||
public void SetAspect(float aspect)
|
||||
{
|
||||
Orbit.Aspect = aspect;
|
||||
Fly.Aspect = aspect;
|
||||
Fly.Aspect = aspect;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,6 +330,21 @@ public sealed class CellVisibility
|
|||
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brute-force scan of every loaded cell to test whether
|
||||
/// <paramref name="worldPoint"/> is inside any of them. Does not touch
|
||||
/// the camera cache (<see cref="_lastCameraCell"/>), so this is safe
|
||||
/// to call alongside <see cref="ComputeVisibility"/> in the same frame
|
||||
/// for a different position (e.g. player position when the camera is
|
||||
/// in third-person chase mode).
|
||||
/// </summary>
|
||||
public bool IsInsideAnyCell(Vector3 worldPoint)
|
||||
{
|
||||
foreach (var cell in _cellLookup.Values)
|
||||
if (PointInCell(worldPoint, cell)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GetVisibleCells (BFS)
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
377
src/AcDream.App/Rendering/RetailChaseCamera.cs
Normal file
377
src/AcDream.App/Rendering/RetailChaseCamera.cs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
|
||||
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
|
||||
/// exponential damping toward a target pose, 5-frame velocity-averaged
|
||||
/// slope-aligned heading frame, mouse-input low-pass filter.
|
||||
///
|
||||
/// <para>
|
||||
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
|
||||
/// next to the legacy <see cref="ChaseCamera"/>; both update every
|
||||
/// frame so toggling the flag swaps cameras instantly. Visible behavior
|
||||
/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on
|
||||
/// hills, jump-feedback without the legacy <c>_trackedZ</c> hack.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RetailChaseCamera : ICamera
|
||||
{
|
||||
// ICamera surface.
|
||||
public Vector3 Position { get; private set; }
|
||||
public float Aspect { get; set; } = 16f / 9f;
|
||||
public float FovY { get; set; } = MathF.PI / 3f;
|
||||
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
|
||||
public Matrix4x4 Projection =>
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
|
||||
|
||||
// ── Public tunables (per-instance) ──────────────────────────────
|
||||
|
||||
/// <summary>Length of the viewer_offset vector. Retail default ≈ 2.61.</summary>
|
||||
public float Distance { get; set; } = 2.61f;
|
||||
|
||||
/// <summary>Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).</summary>
|
||||
public float Pitch { get; set; } = 0.291f;
|
||||
|
||||
/// <summary>
|
||||
/// Yaw offset added on top of player yaw when slope-align is off
|
||||
/// or velocity is too small to derive a heading. Used by hold-RMB
|
||||
/// orbit to swing the camera around the player without rotating
|
||||
/// the character.
|
||||
/// </summary>
|
||||
public float YawOffset { get; set; } = 0f;
|
||||
|
||||
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
|
||||
public float PivotHeight { get; set; } = 1.5f;
|
||||
|
||||
/// <summary>Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.</summary>
|
||||
public float PlayerTranslucency { get; private set; }
|
||||
|
||||
/// <summary>Clamp bounds carried over from legacy ChaseCamera.</summary>
|
||||
public const float DistanceMin = 2f;
|
||||
public const float DistanceMax = 40f;
|
||||
public const float PitchMin = -0.7f;
|
||||
public const float PitchMax = 1.4f;
|
||||
|
||||
// ── Damped state ────────────────────────────────────────────────
|
||||
|
||||
private readonly Vector3[] _velocityRing = new Vector3[5];
|
||||
private int _velocityCount;
|
||||
private Vector3 _dampedEye;
|
||||
private Vector3 _dampedForward = new(1f, 0f, 0f);
|
||||
private bool _initialised;
|
||||
|
||||
// Mouse-filter state — shared by FilterMouseDelta entrypoint.
|
||||
private float _lastMouseDeltaX;
|
||||
private float _lastMouseDeltaY;
|
||||
private float _lastFilterTimeSec;
|
||||
|
||||
// ── Per-frame entry point ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Advance the camera one frame. Caller passes the player's current
|
||||
/// pose + velocity (in world space) + the frame's <c>dt</c> in
|
||||
/// seconds. After this returns, <see cref="Position"/>,
|
||||
/// <see cref="View"/>, and <see cref="PlayerTranslucency"/> reflect
|
||||
/// the new state.
|
||||
/// </summary>
|
||||
public void Update(
|
||||
Vector3 playerPosition,
|
||||
float playerYaw,
|
||||
Vector3 playerVelocity,
|
||||
bool isOnGround,
|
||||
Vector3 contactPlaneNormal,
|
||||
float dt)
|
||||
{
|
||||
// 1. Push velocity into 5-frame ring, get average.
|
||||
PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
|
||||
Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount);
|
||||
|
||||
// 2. Heading vector — player's facing projected onto the contact
|
||||
// plane (grounded) or world XY (airborne). See ComputeHeading
|
||||
// doc + retail decomp :95644-95795 for why this is facing-based
|
||||
// rather than velocity-based.
|
||||
Vector3 heading = ComputeHeading(
|
||||
avgVel,
|
||||
playerYaw + YawOffset,
|
||||
isOnGround,
|
||||
contactPlaneNormal,
|
||||
CameraDiagnostics.AlignToSlope);
|
||||
|
||||
// 3. Orthonormal heading-frame basis.
|
||||
var (forward, _, up) = BuildBasis(heading);
|
||||
|
||||
// 4. Target pose.
|
||||
Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight);
|
||||
float horizontal = Distance * MathF.Cos(Pitch);
|
||||
float vertical = Distance * MathF.Sin(Pitch);
|
||||
// viewer_offset = -horizontal along forward + vertical along up.
|
||||
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
|
||||
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
|
||||
|
||||
// 5. Exponential damping (independent translation + rotation rates).
|
||||
if (!_initialised)
|
||||
{
|
||||
_dampedEye = targetEye;
|
||||
_dampedForward = targetForward;
|
||||
_initialised = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
|
||||
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
|
||||
_dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
|
||||
_dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
|
||||
}
|
||||
|
||||
// 6. Publish renderer surface.
|
||||
Position = _dampedEye;
|
||||
View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f));
|
||||
|
||||
// 7. Auto-fade translucency.
|
||||
float d = Vector3.Distance(_dampedEye, pivotWorld);
|
||||
PlayerTranslucency = ComputeTranslucency(d);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjust the camera distance (zoom) by a delta, clamped to
|
||||
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
|
||||
/// legacy <c>ChaseCamera.AdjustDistance</c>.
|
||||
/// </summary>
|
||||
public void AdjustDistance(float delta) =>
|
||||
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
|
||||
|
||||
/// <summary>
|
||||
/// Adjust the camera pitch by a delta (radians), clamped to
|
||||
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
|
||||
/// <c>ChaseCamera.AdjustPitch</c>.
|
||||
/// </summary>
|
||||
public void AdjustPitch(float delta) =>
|
||||
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
||||
|
||||
/// <summary>
|
||||
/// Public entry point for the mouse-input low-pass filter. Calls
|
||||
/// <see cref="FilterMouseAxis"/> on each axis with shared state.
|
||||
/// </summary>
|
||||
public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec)
|
||||
{
|
||||
// X first — advances the shared timestamp.
|
||||
float x = FilterMouseAxis(rawX, weight, nowSec,
|
||||
ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec);
|
||||
// Y uses a throwaway timestamp so the within-window check still uses the original delta
|
||||
// (X already advanced _lastFilterTimeSec to nowSec; if Y reused it, the within-window
|
||||
// check would be 0 < windowSec which is always true — which is what we want here, since
|
||||
// both axes are sampled simultaneously and should both blend.).
|
||||
float yTimeShadow = _lastFilterTimeSec - 1f; // force within-window path for the Y axis
|
||||
float y = FilterMouseAxis(rawY, weight, nowSec,
|
||||
ref _lastMouseDeltaY, ref yTimeShadow, CameraDiagnostics.MouseLowPassWindowSec);
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
// Math primitives — pure, internal-static for unit-testability.
|
||||
|
||||
/// <summary>
|
||||
/// Pick the heading vector that drives the camera basis. Mirrors
|
||||
/// retail's <c>CameraManager::UpdateCamera</c> ALIGN_WITH_PLANE
|
||||
/// path (decomp <c>acclient_2013_pseudo_c.txt:95644-95795</c>):
|
||||
/// <list type="number">
|
||||
/// <item><description>Base heading is the player's facing
|
||||
/// direction in world space — <c>(cos yaw, sin yaw, 0)</c>
|
||||
/// — not the velocity vector. Velocity only gates whether
|
||||
/// slope-alignment fires.</description></item>
|
||||
/// <item><description>If <paramref name="alignToSlope"/> is off
|
||||
/// OR the player's horizontal velocity is below epsilon (i.e.
|
||||
/// stationary OR jumping straight up), return that base
|
||||
/// heading unchanged. This is the bit that keeps the camera
|
||||
/// from swinging vertically during a jump.</description></item>
|
||||
/// <item><description>Otherwise project the base heading onto
|
||||
/// the plane perpendicular to a surface normal:
|
||||
/// <see cref="System.Numerics.Plane"/>'s <c>Normal</c> when
|
||||
/// grounded (slope-aligned), world <c>(0, 0, 1)</c> when
|
||||
/// airborne (which is a no-op since the base is already
|
||||
/// horizontal).</description></item>
|
||||
/// <item><description>Normalize. If the projection collapsed
|
||||
/// (heading parallel to normal), fall back to the unprojected
|
||||
/// base.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="avgVelocity">5-frame averaged player velocity in world space.</param>
|
||||
/// <param name="yaw">Player facing yaw + any orbit offset, radians.</param>
|
||||
/// <param name="isOnGround">Player's <c>transient_state & 1</c> — does <paramref name="contactPlaneNormal"/> describe a valid contact plane?</param>
|
||||
/// <param name="contactPlaneNormal">Player's current contact plane normal in world space; ignored when <paramref name="isOnGround"/> is false.</param>
|
||||
/// <param name="alignToSlope">User-tunable; when false skips the projection and returns the flat facing direction.</param>
|
||||
internal static Vector3 ComputeHeading(
|
||||
Vector3 avgVelocity,
|
||||
float yaw,
|
||||
bool isOnGround,
|
||||
Vector3 contactPlaneNormal,
|
||||
bool alignToSlope)
|
||||
{
|
||||
// Base heading: player's facing direction in world XY plane.
|
||||
Vector3 baseHeading = new(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
|
||||
|
||||
if (!alignToSlope) return baseHeading;
|
||||
|
||||
// Slope-align gate: player must be moving in XY. Retail tests
|
||||
// |vx| > 0.0002 AND |vy| > 0.0002 (decomp :95704, :95713). The
|
||||
// horizontal-magnitude-squared form is a cleaner equivalent.
|
||||
// Without this, the airborne path would still project against
|
||||
// world up (no-op) which is fine — but the standing-jump case
|
||||
// wants the historical `direction` fallback that retail uses.
|
||||
float hMagSq = avgVelocity.X * avgVelocity.X + avgVelocity.Y * avgVelocity.Y;
|
||||
if (hMagSq < 1e-4f) return baseHeading;
|
||||
|
||||
// Pick the projection plane normal:
|
||||
// grounded → contact_plane.N (slope-aligned camera basis)
|
||||
// airborne → world up (projection becomes a no-op because
|
||||
// baseHeading is already in the XY plane — but
|
||||
// keeping the code path uniform makes the airborne
|
||||
// case impossible to swing vertically).
|
||||
Vector3 normal;
|
||||
if (isOnGround && contactPlaneNormal.LengthSquared() > 0.01f)
|
||||
normal = Vector3.Normalize(contactPlaneNormal);
|
||||
else
|
||||
normal = new Vector3(0f, 0f, 1f);
|
||||
|
||||
// Project baseHeading onto plane perpendicular to normal:
|
||||
// projected = forward - normal * dot(forward, normal)
|
||||
// On flat ground this is a no-op (dot ≈ 0). On a slope the
|
||||
// projected vector gains a Z component matching the slope angle,
|
||||
// which tilts the camera basis with the terrain.
|
||||
float dot = Vector3.Dot(baseHeading, normal);
|
||||
Vector3 projected = baseHeading - normal * dot;
|
||||
|
||||
// Degenerate: facing nearly parallel to normal (rare — would
|
||||
// require player rotated to face into the ground). Fall back to
|
||||
// the unprojected base heading.
|
||||
if (projected.LengthSquared() < 1e-4f) return baseHeading;
|
||||
return Vector3.Normalize(projected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an orthonormal basis with <c>forward = heading</c>. World
|
||||
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it
|
||||
/// the right axis falls back to world <c>+X</c> so the cross
|
||||
/// product doesn't collapse.
|
||||
/// </summary>
|
||||
internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading)
|
||||
{
|
||||
Vector3 forward = Vector3.Normalize(heading);
|
||||
Vector3 worldUp = new(0f, 0f, 1f);
|
||||
|
||||
Vector3 right;
|
||||
if (MathF.Abs(forward.Z) > 0.99f)
|
||||
{
|
||||
// Near-vertical forward — use world +X as the secondary axis.
|
||||
right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
|
||||
}
|
||||
else
|
||||
{
|
||||
right = Vector3.Normalize(Vector3.Cross(forward, worldUp));
|
||||
}
|
||||
Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal)
|
||||
return (forward, right, up);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FIFO-push a velocity sample into the 5-entry ring. Returns the
|
||||
/// updated ring (mutates the input array; the return is for fluent
|
||||
/// usage in tests). <paramref name="count"/> grows from 0 toward 5
|
||||
/// and stays at 5 once the ring is full.
|
||||
/// </summary>
|
||||
internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample)
|
||||
{
|
||||
if (ring.Length != 5)
|
||||
throw new ArgumentException("velocity ring must have 5 entries", nameof(ring));
|
||||
|
||||
// Shift left by 1 (oldest is overwritten), append new sample at the tail.
|
||||
for (int i = 0; i < 4; i++) ring[i] = ring[i + 1];
|
||||
ring[4] = sample;
|
||||
if (count < 5) count++;
|
||||
return ring;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Average the <paramref name="count"/> most-recent entries of the
|
||||
/// ring (entries <c>[ring.Length-count .. ring.Length)</c>). Returns
|
||||
/// <see cref="Vector3.Zero"/> when count is zero.
|
||||
/// </summary>
|
||||
internal static Vector3 AverageVelocity(Vector3[] ring, int count)
|
||||
{
|
||||
if (count == 0) return Vector3.Zero;
|
||||
Vector3 sum = Vector3.Zero;
|
||||
int start = ring.Length - count;
|
||||
for (int i = start; i < ring.Length; i++) sum += ring[i];
|
||||
return sum / count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exponential-damping rate per frame.
|
||||
/// <c>alpha = clamp(stiffness * dt * 10, 0, 1)</c>. At
|
||||
/// <c>stiffness=0.45</c>, <c>dt=1/60</c> → <c>~0.075</c>
|
||||
/// (~150 ms half-life). Matches retail's
|
||||
/// <c>x_1 = stiffness * dt * 10</c> formulation.
|
||||
/// </summary>
|
||||
internal static float ComputeDampingAlpha(float stiffness, float dt)
|
||||
{
|
||||
float a = stiffness * dt * 10f;
|
||||
if (a <= 0f) return 0f;
|
||||
if (a >= 1f) return 1f;
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Low-pass filter for a single mouse axis. Mirrors retail's
|
||||
/// <c>CameraSet::FilterMouseInput</c>: if last sample was within
|
||||
/// <paramref name="windowSec"/>, blend output with the average of
|
||||
/// (previous, raw); otherwise pass-through. Final output =
|
||||
/// <c>raw * (1 - weight) + blended * weight</c>. Updates
|
||||
/// <paramref name="lastDelta"/> and <paramref name="lastTimeSec"/>
|
||||
/// to the new state.
|
||||
/// </summary>
|
||||
internal static float FilterMouseAxis(
|
||||
float raw,
|
||||
float weight,
|
||||
float nowSec,
|
||||
ref float lastDelta,
|
||||
ref float lastTimeSec,
|
||||
float windowSec)
|
||||
{
|
||||
float avg;
|
||||
if (nowSec - lastTimeSec < windowSec)
|
||||
avg = (lastDelta + raw) * 0.5f;
|
||||
else
|
||||
avg = raw;
|
||||
|
||||
float output = raw * (1f - weight) + avg * weight;
|
||||
lastDelta = output;
|
||||
lastTimeSec = nowSec;
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player-mesh translucency as a function of camera-to-pivot
|
||||
/// distance. <c>0</c> = fully opaque, <c>1</c> = fully transparent.
|
||||
/// Opaque at and beyond 0.45 m; fully transparent at and within
|
||||
/// 0.20 m; linear ramp between. Matches retail's <c>CameraSet::
|
||||
/// UpdateCamera</c> distance check (decomp :97703–97725).
|
||||
/// </summary>
|
||||
internal static float ComputeTranslucency(float distance)
|
||||
{
|
||||
const float Far = 0.45f;
|
||||
const float Near = 0.20f;
|
||||
|
||||
if (distance >= Far) return 0f;
|
||||
if (distance <= Near) return 1f;
|
||||
// Linear: t = 1 - (Near - distance) / (Near - Far)
|
||||
return 1f - (Near - distance) / (Near - Far);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
|
|
@ -140,6 +141,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Per-cell-entity last-log frame number for rate-limiting the
|
||||
/// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
|
||||
/// probes. Defaults to 30 frames at 30Hz = 1 sec.
|
||||
/// </summary>
|
||||
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
|
||||
private int _indoorProbeFrameCounter;
|
||||
private const int IndoorProbeRateLimitFrames = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
|
||||
/// frames per cellId. Caller must already have checked that an indoor
|
||||
/// probe flag is enabled.
|
||||
/// </summary>
|
||||
private bool ShouldEmitIndoorProbe(ulong cellId)
|
||||
{
|
||||
if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
|
||||
|| _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
|
||||
{
|
||||
_lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
|
||||
private int _entitiesSeen;
|
||||
private int _entitiesDrawn;
|
||||
|
|
@ -271,6 +297,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
|
||||
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
|
||||
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
|
||||
///
|
||||
/// <para>
|
||||
/// When <paramref name="indoorProbeState"/> is non-null the method emits
|
||||
/// <c>[indoor-cull]</c> lines for cell entities rejected by the
|
||||
/// visibleCellIds or frustum filters, and <c>[indoor-walk]</c> lines for
|
||||
/// cell entities that pass all filters. Rate-limited by
|
||||
/// <see cref="IndoorProbeState"/>. Pass <see langword="null"/> (the default)
|
||||
/// to disable all probe emission — used by the test-friendly
|
||||
/// <see cref="WalkEntities"/> overload.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void WalkEntitiesInto(
|
||||
IEnumerable<LandblockEntry> landblockEntries,
|
||||
|
|
@ -279,7 +315,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
HashSet<uint>? visibleCellIds,
|
||||
HashSet<uint>? animatedEntityIds,
|
||||
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
||||
ref WalkResult result)
|
||||
ref WalkResult result,
|
||||
IndoorProbeState? indoorProbeState = null)
|
||||
{
|
||||
scratch.Clear();
|
||||
result.EntitiesWalked = 0;
|
||||
|
|
@ -314,19 +351,65 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
{
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||||
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
|
||||
// is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
|
||||
// result reused for all probe checks below.
|
||||
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
|
||||
bool isCellEntity = indoorProbeState is not null
|
||||
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
|
||||
|
||||
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||
&& visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||
if (!cellInVis)
|
||||
{
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
|
||||
$"reason=visibleCellIds-miss " +
|
||||
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
|
||||
// they're tracked at landblock level + need per-frame work regardless.
|
||||
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
bool aabbVisible = true;
|
||||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||||
{
|
||||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
|
||||
continue;
|
||||
aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
|
||||
}
|
||||
|
||||
if (!aabbVisible)
|
||||
{
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
|
||||
$"reason=frustum " +
|
||||
$"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
|
||||
$"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Passed all filters — emit walk probe.
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
|
||||
$"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
|
||||
$"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
|
||||
$"meshRef0=0x{cellProbeId:X8} " +
|
||||
$"meshRefCount={entity.MeshRefs.Count} " +
|
||||
$"landblockVisible=true aabbVisible=true cellInVis=true");
|
||||
}
|
||||
|
||||
result.EntitiesWalked++;
|
||||
|
|
@ -347,6 +430,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
HashSet<uint>? animatedEntityIds = null)
|
||||
{
|
||||
_shader.Use();
|
||||
_indoorProbeFrameCounter++;
|
||||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
|
||||
|
|
@ -391,6 +475,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
|
||||
// that populates _walkScratch (a per-dispatcher field reused across frames)
|
||||
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
|
||||
//
|
||||
// Pass an IndoorProbeState when any indoor probe is active so the static
|
||||
// WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk]
|
||||
// lines without needing access to instance fields. Null = probes off.
|
||||
IndoorProbeState? probeState = null;
|
||||
if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled)
|
||||
{
|
||||
// _currentFrame is snapped at construction time. Construct
|
||||
// once per Draw() call only — a second construction within
|
||||
// the same frame would stamp the dictionary with the
|
||||
// (already-advanced) counter value, suppressing the second
|
||||
// pass's emissions for IndoorProbeRateLimitFrames frames.
|
||||
// Today Draw() is called exactly once per frame; if a
|
||||
// future refactor adds a shadow / reflection / second pass,
|
||||
// this assumption needs revisiting.
|
||||
probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter);
|
||||
}
|
||||
|
||||
var walkResult = default(WalkResult);
|
||||
WalkEntitiesInto(
|
||||
ToEntries(landblockEntries),
|
||||
|
|
@ -399,7 +501,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
visibleCellIds,
|
||||
animatedEntityIds,
|
||||
_walkScratch,
|
||||
ref walkResult);
|
||||
ref walkResult,
|
||||
probeState);
|
||||
|
||||
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
|
||||
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
|
||||
|
|
@ -582,6 +685,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
ulong gfxObjId = meshRef.GfxObjId;
|
||||
|
||||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||
|
||||
// [indoor-lookup] probe — emit once per cell entity per sec.
|
||||
// Fires BEFORE the null-renderData early-continue so a miss still
|
||||
// emits hit=false, distinguishing H2 (empty batches) from H6
|
||||
// (dispatcher fails to traverse Setup).
|
||||
ulong lookupCellId = (ulong)gfxObjId;
|
||||
if (RenderingDiagnostics.IsEnvCellId(lookupCellId)
|
||||
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
|
||||
// Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull]
|
||||
// (which key on the same gfxObjId). Without this, IndoorAll=1 would
|
||||
// silence the lookup probe whenever the walk probe fired first.
|
||||
&& ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL))
|
||||
{
|
||||
bool hit = renderData is not null;
|
||||
bool isSetup = hit && renderData!.IsSetup;
|
||||
int partCount = isSetup ? renderData!.SetupParts.Count : 0;
|
||||
|
||||
int partsHit = 0, partsMiss = 0;
|
||||
if (isSetup)
|
||||
{
|
||||
foreach (var (partId, _) in renderData!.SetupParts)
|
||||
{
|
||||
if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
|
||||
else partsMiss++;
|
||||
}
|
||||
}
|
||||
|
||||
bool hasEnvCellGeom = isSetup
|
||||
&& renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[indoor-lookup] cellId=0x{lookupCellId:X8} " +
|
||||
$"hit={hit} isSetup={isSetup} partCount={partCount} " +
|
||||
$"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
|
||||
}
|
||||
|
||||
if (renderData is null)
|
||||
{
|
||||
// Tier 1 cache (#53): mesh data is still async-decoding via
|
||||
|
|
@ -614,6 +753,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
var model = ComposePartWorldMatrix(
|
||||
entityWorld, meshRef.PartTransform, partTransform);
|
||||
|
||||
// [indoor-xform] probe — only for the cell's synthetic
|
||||
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
|
||||
// cellGeomId convention). One line per part per sec.
|
||||
// Disambiguates hypothesis H5 (transform double-apply —
|
||||
// composedT lands at 2 × cellOrigin).
|
||||
if ((partGfxObjId & 0x1_0000_0000UL) != 0
|
||||
&& RenderingDiagnostics.ProbeIndoorXformEnabled
|
||||
&& ShouldEmitIndoorProbe(partGfxObjId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
|
||||
$"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
|
||||
$"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
|
||||
$"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
|
||||
$"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
|
||||
}
|
||||
|
||||
var restPose = partTransform * meshRef.PartTransform;
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
|
||||
drewAny = true;
|
||||
|
|
@ -1289,6 +1445,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around an instance's rate-limit dictionary + frame
|
||||
/// counter, passed into the static <see cref="WalkEntitiesInto"/>
|
||||
/// overload so it can emit rate-limited probe lines without access
|
||||
/// to instance fields. Null = probes disabled (test-friendly overload).
|
||||
/// </summary>
|
||||
internal sealed class IndoorProbeState
|
||||
{
|
||||
private readonly Dictionary<ulong, int> _lastFrame;
|
||||
private readonly int _currentFrame;
|
||||
private const int RateLimit = IndoorProbeRateLimitFrames;
|
||||
|
||||
internal IndoorProbeState(Dictionary<ulong, int> lastFrame, int currentFrame)
|
||||
{
|
||||
_lastFrame = lastFrame;
|
||||
_currentFrame = currentFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
|
||||
/// frames per <paramref name="cellId"/>. Side-effect: stamps the frame
|
||||
/// number into the dictionary on success.
|
||||
/// </summary>
|
||||
internal bool ShouldEmit(ulong cellId)
|
||||
{
|
||||
if (!_lastFrame.TryGetValue(cellId, out int last)
|
||||
|| _currentFrame - last >= RateLimit)
|
||||
{
|
||||
_lastFrame[cellId] = _currentFrame;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InstanceGroup
|
||||
{
|
||||
public uint Ibo;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Rendering;
|
||||
using Chorizite.OpenGLSDLBackend;
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -34,6 +37,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
private readonly AcSurfaceMetadataTable _metadataTable = new();
|
||||
private readonly HashSet<ulong> _metadataPopulated = new();
|
||||
|
||||
/// <summary>
|
||||
/// EnvCell ids we've requested via PrepareMeshDataAsync but not yet
|
||||
/// seen completion for in Tick(). Used by the [indoor-upload] probe
|
||||
/// to log requested + completed pairs. Cleared per completion;
|
||||
/// missing completions after a few seconds indicate WB silently
|
||||
/// returned null (hypothesis H1 in the design spec).
|
||||
/// </summary>
|
||||
private readonly HashSet<ulong> _pendingEnvCellRequests = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when this instance was created via <see cref="CreateUninitialized"/>;
|
||||
/// all public methods no-op when uninitialized.
|
||||
|
|
@ -65,10 +77,52 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
_dats = dats;
|
||||
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
|
||||
_wbDats = new DefaultDatReaderWriter(datDir);
|
||||
// Phase 2 diagnostic — replace NullLogger with a Console-backed
|
||||
// logger so WB's internal catch block at ObjectMeshManager.cs:589
|
||||
// (and similar) surfaces its swallowed exceptions instead of
|
||||
// dropping them. ConsoleErrorLogger filters to LogLevel.Error+
|
||||
// so successful operations stay quiet.
|
||||
_meshManager = new ObjectMeshManager(
|
||||
_graphicsDevice,
|
||||
_wbDats,
|
||||
NullLogger<ObjectMeshManager>.Instance);
|
||||
new ConsoleErrorLogger<ObjectMeshManager>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Console-backed logger that fires only on
|
||||
/// <see cref="LogLevel.Error"/> and above. Format:
|
||||
/// <code>[wb-error] <message>
|
||||
/// [wb-error] <ExceptionType>: <ExceptionMessage>
|
||||
/// [wb-error] at <frame> (up to 5 frames)</code>
|
||||
/// Used to surface WB's silently-caught exceptions in
|
||||
/// <c>ObjectMeshManager.PrepareMeshData</c>.
|
||||
/// </summary>
|
||||
private sealed class ConsoleErrorLogger<T> : ILogger<T>
|
||||
{
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel)) return;
|
||||
var message = formatter(state, exception);
|
||||
Console.WriteLine($"[wb-error] {message}");
|
||||
if (exception is not null)
|
||||
{
|
||||
Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}");
|
||||
var stack = (exception.StackTrace ?? "")
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Take(5);
|
||||
foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
private WbMeshAdapter()
|
||||
|
|
@ -133,7 +187,80 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
// isSetup: false — acdream's MeshRefs already carry expanded
|
||||
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
||||
// unused.
|
||||
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
|
||||
// [indoor-upload] requested probe — only for EnvCell ids.
|
||||
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
|
||||
{
|
||||
bool hadRenderDataAtRequest = _meshManager.HasRenderData(id);
|
||||
_pendingEnvCellRequests.Add(id);
|
||||
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8} hadRenderData={hadRenderDataAtRequest}");
|
||||
|
||||
// Phase 2 — surface what WB's catch block silently swallows.
|
||||
// ObjectMeshManager.PrepareMeshData has a try/catch at line 589
|
||||
// that calls _logger.LogError(ex, ...) — but we construct
|
||||
// ObjectMeshManager with NullLogger.Instance so the log is
|
||||
// dropped. This continuation captures the same data scoped to
|
||||
// EnvCell ids only. Runs on ThreadPool; non-blocking. Zero cost
|
||||
// when the probe is off.
|
||||
ulong cellId = id;
|
||||
_ = prepTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted && t.Exception is not null)
|
||||
{
|
||||
var ex = t.Exception.InnerException ?? t.Exception;
|
||||
var stack = (ex.StackTrace ?? "").Split('\n')
|
||||
.Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
|
||||
Console.WriteLine(
|
||||
$"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
|
||||
$"exception={ex.GetType().Name}: {ex.Message} " +
|
||||
$"stack=[{string.Join(" | ", stack)}]");
|
||||
}
|
||||
else if (t.IsCompletedSuccessfully && t.Result is null)
|
||||
{
|
||||
// Phase 2 cause-narrowing: WB's PrepareMeshData can return
|
||||
// null for several reasons (ResolveId empty / TryGet<EnvCell>
|
||||
// failed / type Unknown). Cross-check against acdream's own
|
||||
// DatCollection — if WE find the cell but WB doesn't, the
|
||||
// divergence is between dat readers, not a missing record.
|
||||
bool ourCellFound = false;
|
||||
try
|
||||
{
|
||||
ourCellFound = _dats?.Cell.TryGet<DatReaderWriter.DBObjs.EnvCell>(
|
||||
(uint)cellId, out _) ?? false;
|
||||
}
|
||||
catch { /* swallow — this is best-effort diagnostic */ }
|
||||
|
||||
int wbResolveCount = -1;
|
||||
string wbSelectedType = "none";
|
||||
bool wbDbTryGetEnvCell = false;
|
||||
bool wbDbIsPortal = false;
|
||||
try
|
||||
{
|
||||
var wbResolutions = _wbDats?.ResolveId((uint)cellId).ToList();
|
||||
wbResolveCount = wbResolutions?.Count ?? -1;
|
||||
if (wbResolutions is not null && wbResolutions.Count > 0)
|
||||
{
|
||||
var selected = wbResolutions
|
||||
.OrderByDescending(r => r.Database == _wbDats!.Portal)
|
||||
.First();
|
||||
wbSelectedType = selected.Type.ToString();
|
||||
wbDbIsPortal = selected.Database == _wbDats!.Portal;
|
||||
try { wbDbTryGetEnvCell = selected.Database.TryGet<DatReaderWriter.DBObjs.EnvCell>((uint)cellId, out _); } catch {}
|
||||
}
|
||||
}
|
||||
catch { /* swallow — best-effort */ }
|
||||
|
||||
Console.WriteLine(
|
||||
$"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " +
|
||||
$"ourCellDb.TryGet={ourCellFound} " +
|
||||
$"wbResolveId.Count={wbResolveCount} " +
|
||||
$"wbSelectedType={wbSelectedType} " +
|
||||
$"wbDbIsPortal={wbDbIsPortal} " +
|
||||
$"wbDbTryGet<EnvCell>={wbDbTryGetEnvCell}");
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +299,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
_graphicsDevice!.ProcessGLQueue();
|
||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
||||
{
|
||||
_meshManager.UploadMeshData(meshData);
|
||||
// [indoor-upload] completed probe — check BEFORE upload so we
|
||||
// see what WB actually produced (vertex counts, parts) before
|
||||
// any post-upload mutation.
|
||||
bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled
|
||||
&& _pendingEnvCellRequests.Remove(meshData.ObjectId);
|
||||
|
||||
var renderData = _meshManager.UploadMeshData(meshData);
|
||||
|
||||
if (isPendingEnvCell)
|
||||
{
|
||||
int parts = meshData.SetupParts?.Count ?? 0;
|
||||
bool hasGeom = meshData.EnvCellGeometry is not null;
|
||||
int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0;
|
||||
bool uploadOk = renderData is not null;
|
||||
Console.WriteLine(
|
||||
$"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " +
|
||||
$"isSetup={meshData.IsSetup} parts={parts} " +
|
||||
$"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " +
|
||||
$"uploadOk={uploadOk}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
100
src/AcDream.App/RuntimeOptions.cs
Normal file
100
src/AcDream.App/RuntimeOptions.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AcDream.App;
|
||||
|
||||
/// <summary>
|
||||
/// Typed bundle of startup-time configuration read from the process
|
||||
/// environment. Built once in <c>Program.cs</c> and passed to
|
||||
/// <c>GameWindow</c> so the rest of the app reads its config through
|
||||
/// strongly-typed fields instead of scattered
|
||||
/// <c>Environment.GetEnvironmentVariable</c> calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Scope:</strong> startup-time only — values that don't change
|
||||
/// once the window is up. Runtime diagnostic toggles
|
||||
/// (e.g. <c>ACDREAM_DUMP_MOTION</c>, <c>ACDREAM_PROBE_*</c>) belong in
|
||||
/// diagnostic owner classes (see <c>AcDream.Core.Physics.PhysicsDiagnostics</c>
|
||||
/// for the template), not here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <c>docs/architecture/code-structure.md</c> §2 Rule 4 for the
|
||||
/// rule that drove this extraction, and §4 Step 1 for the broader
|
||||
/// extraction sequence this is the first cut of.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record RuntimeOptions(
|
||||
string DatDir,
|
||||
bool LiveMode,
|
||||
string LiveHost,
|
||||
int LivePort,
|
||||
string? LiveUser,
|
||||
string? LivePass,
|
||||
bool DevTools,
|
||||
bool DumpMoveTruth,
|
||||
bool NoAudio,
|
||||
bool EnableSkyPesDebug,
|
||||
int HidePartIndex,
|
||||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
int? LegacyStreamRadius)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
/// <c>Program.cs</c> at startup.
|
||||
/// </summary>
|
||||
public static RuntimeOptions FromEnvironment(string datDir)
|
||||
=> Parse(datDir, Environment.GetEnvironmentVariable);
|
||||
|
||||
/// <summary>
|
||||
/// Build options from a custom environment getter. Used by tests to
|
||||
/// inject controlled env values without touching the process
|
||||
/// environment.
|
||||
/// </summary>
|
||||
/// <param name="datDir">Resolved dat-file directory.</param>
|
||||
/// <param name="env">Function returning the value for an env-var
|
||||
/// name, or <c>null</c> when unset.</param>
|
||||
public static RuntimeOptions Parse(string datDir, Func<string, string?> env)
|
||||
{
|
||||
if (datDir is null) throw new ArgumentNullException(nameof(datDir));
|
||||
if (env is null) throw new ArgumentNullException(nameof(env));
|
||||
|
||||
return new RuntimeOptions(
|
||||
DatDir: datDir,
|
||||
LiveMode: IsExactlyOne(env("ACDREAM_LIVE")),
|
||||
LiveHost: env("ACDREAM_TEST_HOST") ?? "127.0.0.1",
|
||||
LivePort: TryParseInt(env("ACDREAM_TEST_PORT")) ?? 9000,
|
||||
LiveUser: NullIfEmpty(env("ACDREAM_TEST_USER")),
|
||||
LivePass: NullIfEmpty(env("ACDREAM_TEST_PASS")),
|
||||
DevTools: IsExactlyOne(env("ACDREAM_DEVTOOLS")),
|
||||
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
|
||||
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
|
||||
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
|
||||
HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
|
||||
// Default-on: any value other than the literal string "0" enables
|
||||
// retail close-detail degrades. Set ACDREAM_RETAIL_CLOSE_DEGRADES=0
|
||||
// only for before/after diagnostic comparisons.
|
||||
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
||||
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
public bool HasLiveCredentials =>
|
||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||
|
||||
private static bool IsExactlyOne(string? s)
|
||||
=> string.Equals(s, "1", StringComparison.Ordinal);
|
||||
|
||||
private static string? NullIfEmpty(string? s)
|
||||
=> string.IsNullOrEmpty(s) ? null : s;
|
||||
|
||||
private static int? TryParseInt(string? s)
|
||||
=> int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
private static int? TryParseNonNegativeInt(string? s)
|
||||
=> TryParseInt(s) is { } v && v >= 0 ? v : null;
|
||||
}
|
||||
|
|
@ -84,89 +84,18 @@ public sealed class TargetIndicatorPanel
|
|||
public float EntityHeight { get; set; } = 1.8f;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the world-space height to use for a given entity's
|
||||
/// indicator box. The base height per type is multiplied by the
|
||||
/// entity's <paramref name="scale"/> so an upscaled sign or NPC
|
||||
/// gets a proportionally bigger box.
|
||||
///
|
||||
/// <para>Per-type base height:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Creature (NPC / monster / player): 1.8 m (humanoid)</item>
|
||||
/// <item>Door / Lifestone / Portal: 2.4 m (door-frame tall)</item>
|
||||
/// <item>Small carry items (Money, Food, Gem, SpellComponents,
|
||||
/// Misc, Weapons, Armour, Clothing, Jewelry, Container):
|
||||
/// 0.8 m (item dropped on the ground)</item>
|
||||
/// <item>Everything else (signs on a pole, generic tall scenery,
|
||||
/// untyped scenery interactables): 3.0 m (post-on-ground
|
||||
/// tall — bumped from 1.5 m on 2026-05-15 because the
|
||||
/// Holtburg sign was getting a tiny pole-only box. Most
|
||||
/// non-typed non-flat AC scenery is either small-item-on-
|
||||
/// ground (handled above) or post-mounted; 3 m is the
|
||||
/// right midpoint for the post case. Scale > 1 grows
|
||||
/// the box proportionally.)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Future refinement (deferred): read the entity's actual mesh
|
||||
/// AABB at registration time and use the projected silhouette
|
||||
/// for an exact-fit box.
|
||||
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.GetVisualBounds"/>
|
||||
/// already caches per-GfxObj AABBs; combining them across a
|
||||
/// multi-part Setup gives the entity-level bounds we'd want.
|
||||
/// </para>
|
||||
/// Defensive fallback height when the entity has no usable
|
||||
/// SelectionSphere (Radius ≤ 1e-4f). With B.7's sphere-projection
|
||||
/// path active (since commit f4f4143), this fallback only fires
|
||||
/// for entities whose Setup didn't bake a selection sphere —
|
||||
/// rare in practice. The single 1.5 m × scale default is a sane
|
||||
/// midpoint; per-type branches were retired in the 2026-05-16
|
||||
/// Commit B because the sphere path is authoritative.
|
||||
/// </summary>
|
||||
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null)
|
||||
{
|
||||
if (scale <= 0f) scale = 1f; // defensive
|
||||
bool isCreature = (itemType & (uint)AcDream.Core.Items.ItemType.Creature) != 0;
|
||||
if (isCreature) return 1.8f * scale;
|
||||
|
||||
// BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000, BF_PORTAL = 0x40000,
|
||||
// BF_CORPSE = 0x2000 (acclient.h:6431-6463).
|
||||
const uint TallStructureMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
if ((pwdBitfield & TallStructureMask) != 0) return 2.4f * scale;
|
||||
|
||||
// 2026-05-15 — KEY DISCRIMINATOR. Misc-class ItemTypes are
|
||||
// ambiguous in retail: dropped jewellery / coins / food / tapers
|
||||
// are Misc, but so are signs, banners, and decorative scenery.
|
||||
// ACE distinguishes the two via ITEM_USEABLE (acclient.h:6478):
|
||||
// a real pickup item has USEABLE_REMOTE (0x20) set; a sign has
|
||||
// USEABLE_UNDEF (0). If we know useability and it lacks
|
||||
// USEABLE_REMOTE, treat the entity as tall scenery regardless
|
||||
// of ItemType. This is what fixes the Holtburg town sign
|
||||
// showing a tiny pole-base box.
|
||||
const uint USEABLE_REMOTE_BIT = 0x20u;
|
||||
bool useableFromWorld = useability is uint u
|
||||
&& (u & USEABLE_REMOTE_BIT) != 0;
|
||||
|
||||
// Small carry items: weapons / armour / clothing / jewellery /
|
||||
// money / food / misc / weapons / containers / gems / spell
|
||||
// components / writable / keys / casters / lockables.
|
||||
const uint SmallItemMask =
|
||||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
||||
| AcDream.Core.Items.ItemType.Armor
|
||||
| AcDream.Core.Items.ItemType.Clothing
|
||||
| AcDream.Core.Items.ItemType.Jewelry
|
||||
| AcDream.Core.Items.ItemType.Food
|
||||
| AcDream.Core.Items.ItemType.Money
|
||||
| AcDream.Core.Items.ItemType.Misc
|
||||
| AcDream.Core.Items.ItemType.MissileWeapon
|
||||
| AcDream.Core.Items.ItemType.Container
|
||||
| AcDream.Core.Items.ItemType.Gem
|
||||
| AcDream.Core.Items.ItemType.SpellComponents
|
||||
| AcDream.Core.Items.ItemType.Writable
|
||||
| AcDream.Core.Items.ItemType.Key
|
||||
| AcDream.Core.Items.ItemType.Caster);
|
||||
|
||||
// Real pickup item: ItemType is small-item-class AND the server
|
||||
// marked it useable from the world. 0.8 m × scale box.
|
||||
if ((itemType & SmallItemMask) != 0 && useableFromWorld) return 0.8f * scale;
|
||||
|
||||
// Tall scenery: anything else (signs, banners, untyped
|
||||
// post-mounted objects, AND Misc-typed-but-non-useable entities
|
||||
// like the Holtburg sign). 3 m × scale covers a typical
|
||||
// post-mounted sign from ground to top.
|
||||
return 3.0f * scale;
|
||||
if (scale <= 0f) scale = 1f;
|
||||
return 1.5f * scale;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -214,8 +143,10 @@ public sealed class TargetIndicatorPanel
|
|||
|
||||
if (info.WorldSphereCenter is Vector3 sphereCenter
|
||||
&& info.WorldSphereRadius is float sphereRadius
|
||||
&& TryComputeScreenRectFromSphere(sphereCenter, sphereRadius, view, projection, viewport,
|
||||
out var rMin, out var rMax))
|
||||
&& AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
|
||||
sphereCenter, sphereRadius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out _,
|
||||
minSidePixels: 12f))
|
||||
{
|
||||
// 2026-05-16 — retail-faithful path per
|
||||
// SmartBox::GetObjectBoundingBox (decomp 0x00452e20).
|
||||
|
|
@ -294,67 +225,6 @@ public sealed class TargetIndicatorPanel
|
|||
drawList.AddTriangleFilled(bl + new Vector2( t, -t), bl + new Vector2( t, 0), bl + new Vector2(0, -t), col);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Project a world-space sphere (center + radius) as a
|
||||
/// screen-space square and return its bounding rectangle. Matches
|
||||
/// retail <c>SmartBox::GetObjectBoundingBox</c> (decomp
|
||||
/// <c>0x00452e20</c>) which uses
|
||||
/// <c>Render::GetViewerBBox(sphere, &corner1, &corner2)</c>
|
||||
/// to compute a camera-aligned BBox of the sphere then projects
|
||||
/// the 2 corner points.
|
||||
///
|
||||
/// <para>
|
||||
/// Mathematical equivalent (faster, no per-corner reprojection):
|
||||
/// project the sphere center to screen, then compute the
|
||||
/// screen-space radius as
|
||||
/// <c>worldRadius * projection.M22 * viewport.Y / (2 * clip.W)</c>
|
||||
/// where <c>M22 = 1/tan(fovY/2)</c> for a standard right-handed
|
||||
/// perspective. The rect is centered at the projected sphere
|
||||
/// center with side length <c>2 * screenRadius</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>false</c> if the sphere center is behind the camera
|
||||
/// (<c>clip.W <= 0</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool TryComputeScreenRectFromSphere(
|
||||
Vector3 worldCenter, float worldRadius,
|
||||
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
|
||||
out Vector2 rectMin, out Vector2 rectMax)
|
||||
{
|
||||
rectMin = default;
|
||||
rectMax = default;
|
||||
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
|
||||
if (clip.W <= 0.001f) return false;
|
||||
|
||||
float ndcX = clip.X / clip.W;
|
||||
float ndcY = clip.Y / clip.W;
|
||||
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
|
||||
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
|
||||
|
||||
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
|
||||
// the camera-space distance (positive in front of camera for
|
||||
// standard right-handed perspective).
|
||||
float scaleY = projection.M22;
|
||||
if (scaleY <= 0f) return false;
|
||||
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
|
||||
|
||||
// Cull obviously-off-screen entities (more than a screen away).
|
||||
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
|
||||
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
|
||||
|
||||
// Floor at MinSide so distant entities still get a visible indicator.
|
||||
const float MinSide = 12f;
|
||||
if (screenRadius < MinSide * 0.5f) screenRadius = MinSide * 0.5f;
|
||||
|
||||
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
|
||||
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a world-space point to screen-space pixels. Returns
|
||||
/// <c>false</c> if the point is behind the camera or outside the
|
||||
|
|
|
|||
|
|
@ -203,6 +203,28 @@ public static class CreateObject
|
|||
/// </summary>
|
||||
public bool MoveTowards => MoveToParameters.HasValue
|
||||
&& (MoveToParameters.Value & 0x200u) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// MovementParameters bit 4 (mask 0x10) — set when the mover should
|
||||
/// charge (run) rather than walk. ACE's
|
||||
/// <c>Creature.SetWalkRunThreshold</c> sets this when the player-to-
|
||||
/// target distance is at least <c>WalkRunThreshold / 2</c> (7.5 m
|
||||
/// for the 15 m default), and clears it for shorter chases — so this
|
||||
/// bit IS the wire-side walk-vs-run decision.
|
||||
/// <para>
|
||||
/// Retail's <c>MovementParameters::get_command</c>
|
||||
/// (<c>0x0052aa00</c>) gates the run path on this bit: cleared →
|
||||
/// fall through to the inner walk_run_threshold check (which ACE's
|
||||
/// 15 m default + 0.6 m use-radius makes practically always walk for
|
||||
/// any < 15.6 m chase); set → unconditional <c>HoldKey_Run</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cross-ref: ACE <c>MovementParams.CanCharge = 0x10</c>
|
||||
/// (<c>ACE.Entity/Enum/MovementParams.cs:12</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool CanCharge => MoveToParameters.HasValue
|
||||
&& (MoveToParameters.Value & 0x10u) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -479,6 +479,29 @@ public sealed class AnimationSequencer
|
|||
? null
|
||||
: GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
|
||||
|
||||
// Stop-anim fallback: dat-authored leg-settle / turn-stop links are
|
||||
// keyed under the FORWARD/RIGHT variant only. Stopping from
|
||||
// WalkBackward / SideStepLeft / TurnLeft hits a null linkData and
|
||||
// would visibly snap to Ready. The settle anim is direction-agnostic
|
||||
// (legs come to standing the same way regardless of which way you
|
||||
// were walking), so retry GetLink with the substate's low-byte
|
||||
// remapped to its forward/right peer.
|
||||
if (linkData is null && !skipTransitionLink && CurrentMotion != 0)
|
||||
{
|
||||
uint substateLow = CurrentMotion & 0xFFu;
|
||||
uint adjustedSubstate = substateLow switch
|
||||
{
|
||||
0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward
|
||||
0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight
|
||||
0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight
|
||||
_ => CurrentMotion,
|
||||
};
|
||||
if (adjustedSubstate != CurrentMotion)
|
||||
{
|
||||
linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
|
||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||||
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
||||
|
|
@ -1419,18 +1442,24 @@ public sealed class AnimationSequencer
|
|||
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
||||
|
||||
// Next frame for interpolation: step in the playback direction.
|
||||
// Wrap to opposite end ONLY for looping cyclic nodes. For one-shot
|
||||
// nodes (link transitions, action overlays), hold the boundary
|
||||
// frame instead — otherwise the fractional tail of the anim
|
||||
// blends frame[end] with frame[0], producing a brief flash through
|
||||
// the anim's starting pose at the link→cycle boundary (issue #61:
|
||||
// door swing-open flap; run-stop twitch).
|
||||
int nextIdx;
|
||||
if (curr.Framerate >= 0.0)
|
||||
{
|
||||
nextIdx = frameIdx + 1;
|
||||
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
||||
nextIdx = rangeLo; // wrap forward
|
||||
nextIdx = curr.IsLooping ? rangeLo : frameIdx;
|
||||
}
|
||||
else
|
||||
{
|
||||
nextIdx = frameIdx - 1;
|
||||
if (nextIdx < rangeLo)
|
||||
nextIdx = rangeHi; // wrap backward
|
||||
nextIdx = curr.IsLooping ? rangeHi : frameIdx;
|
||||
}
|
||||
|
||||
// Fractional blend weight (always in [0, 1]).
|
||||
|
|
|
|||
|
|
@ -652,6 +652,7 @@ public static class BSPQuery
|
|||
Vector3 movement,
|
||||
Vector3 up,
|
||||
ref ResolvedPolygon? hitPoly,
|
||||
ref ushort hitPolyId,
|
||||
ref bool changed)
|
||||
{
|
||||
if (node is null) return;
|
||||
|
|
@ -673,6 +674,7 @@ public static class BSPQuery
|
|||
{
|
||||
changed = true;
|
||||
hitPoly = poly;
|
||||
hitPolyId = polyId;
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -686,22 +688,22 @@ public static class BSPQuery
|
|||
if (dist >= reach)
|
||||
{
|
||||
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
|
||||
ref hitPoly, ref changed);
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dist <= -reach)
|
||||
{
|
||||
FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
|
||||
ref hitPoly, ref changed);
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Straddles.
|
||||
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
|
||||
ref hitPoly, ref changed);
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
|
||||
ref hitPoly, ref changed);
|
||||
ref hitPoly, ref hitPolyId, ref changed);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -928,16 +930,24 @@ public static class BSPQuery
|
|||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// BSPNode.point_inside_cell_bsp — test if a 3D point is inside the cell BSP.
|
||||
/// BSPNode.point_inside_cell_bsp — recursive cell-BSP point containment test.
|
||||
///
|
||||
/// <para>
|
||||
/// Follows the front side of each splitting plane. A point is inside when it
|
||||
/// reaches a front leaf or null PosNode (solid interior).
|
||||
/// Indoor walking Phase 2 (2026-05-19): retyped from PhysicsBSPNode? to
|
||||
/// CellBSPNode? — the function operates on the CellBSP tree (which is
|
||||
/// distinct from the PhysicsBSP tree). The dead-code typing was wrong;
|
||||
/// no callers existed, so the retype is safe.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Walks down the tree following splitting planes; returns true when the
|
||||
/// point reaches a front leaf or null PosNode (solid interior). Behind
|
||||
/// any splitting plane → outside.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: BSPNode.cs point_inside_cell_bsp.</para>
|
||||
/// </summary>
|
||||
public static bool PointInsideCellBsp(PhysicsBSPNode? node, Vector3 point)
|
||||
public static bool PointInsideCellBsp(CellBSPNode? node, Vector3 point)
|
||||
{
|
||||
if (node is null) return true;
|
||||
if (node.Type == BSPNodeType.Leaf) return true;
|
||||
|
|
@ -1095,9 +1105,10 @@ public static class BSPQuery
|
|||
var validPos = new CollisionSphere(checkPos);
|
||||
bool changed = false;
|
||||
ResolvedPolygon? polyHit = null;
|
||||
ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it
|
||||
|
||||
FindWalkableInternal(root, resolved, path, validPos, movement, up,
|
||||
ref polyHit, ref changed);
|
||||
ref polyHit, ref _polyId, ref changed);
|
||||
|
||||
if (changed && polyHit is not null)
|
||||
{
|
||||
|
|
@ -1119,6 +1130,91 @@ public static class BSPQuery
|
|||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// find_walkable_sphere — "stand here, find my contact plane"
|
||||
// Indoor walkable-plane synthesis entry point (Phase 2 follow-up 2026-05-19).
|
||||
// Not a direct retail port; wraps BSPNODE::find_walkable + BSPLEAF::find_walkable
|
||||
// (acclient_2013_pseudo_c.txt:326211, :326793) via the existing FindWalkableInternal.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf
|
||||
/// find_walkable BSP traversal. Probes downward by <paramref name="probeDistance"/>
|
||||
/// along <paramref name="up"/> and returns the closest walkable polygon the
|
||||
/// sphere would rest on, with the sphere's center adjusted to lie on that plane.
|
||||
///
|
||||
/// <para>
|
||||
/// Wraps the existing private <see cref="FindWalkableInternal"/> — which already
|
||||
/// implements the retail-faithful walkable-finder
|
||||
/// (BSPNODE::find_walkable + BSPLEAF::find_walkable +
|
||||
/// CPolygon::walkable_hits_sphere + CPolygon::adjust_sphere_to_plane,
|
||||
/// acclient_2013_pseudo_c.txt:326211, :326793, :323006, :322032).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Intended call site: indoor walkable-plane synthesis in
|
||||
/// <c>Transition.TryFindIndoorWalkablePlane</c> when the indoor cell-BSP
|
||||
/// collision returns OK (no wall hit) and the resolver still needs a
|
||||
/// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
|
||||
/// (<see cref="PhysicsEngine.SampleTerrainWalkable"/>) and does not use this.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The caller is responsible for setting <c>transition.SpherePath.WalkableAllowance</c>
|
||||
/// to the desired walkability threshold (typically <see cref="PhysicsGlobals.FloorZ"/>)
|
||||
/// before calling, and restoring it after.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="root">Root of the cell's PhysicsBSP.</param>
|
||||
/// <param name="resolved">Pre-resolved polygon dictionary from PhysicsDataCache.</param>
|
||||
/// <param name="transition">Current transition (read for WalkableAllowance / walk_interp).</param>
|
||||
/// <param name="sphere">Foot sphere in cell-local space.</param>
|
||||
/// <param name="probeDistance">Downward probe distance in meters. Typical: 0.5f.</param>
|
||||
/// <param name="up">Up vector in cell-local space (typically Vector3.UnitZ).</param>
|
||||
/// <param name="hitPoly">Output: the walkable polygon found, or null on miss.</param>
|
||||
/// <param name="hitPolyId">Output: polygon id (dictionary key) of the hit. Zero on miss.</param>
|
||||
/// <param name="adjustedCenter">
|
||||
/// Output: sphere center adjusted onto the polygon plane. Equal to input
|
||||
/// <c>sphere.Origin</c> on miss.
|
||||
/// </param>
|
||||
/// <returns>True if a walkable polygon was found; false otherwise.</returns>
|
||||
public static bool FindWalkableSphere(
|
||||
PhysicsBSPNode? root,
|
||||
Dictionary<ushort, ResolvedPolygon> resolved,
|
||||
Transition transition,
|
||||
Sphere sphere,
|
||||
float probeDistance,
|
||||
Vector3 up,
|
||||
out ResolvedPolygon? hitPoly,
|
||||
out ushort hitPolyId,
|
||||
out Vector3 adjustedCenter)
|
||||
{
|
||||
adjustedCenter = sphere.Origin;
|
||||
hitPoly = null;
|
||||
hitPolyId = 0;
|
||||
|
||||
if (root is null) return false;
|
||||
|
||||
var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
|
||||
var movement = -up * probeDistance;
|
||||
bool changed = false;
|
||||
ushort polyId = 0;
|
||||
ResolvedPolygon? poly = null;
|
||||
|
||||
FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
|
||||
movement, up, ref poly, ref polyId, ref changed);
|
||||
|
||||
if (changed && poly is not null)
|
||||
{
|
||||
hitPoly = poly;
|
||||
hitPolyId = polyId;
|
||||
adjustedCenter = validPos.Center;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// step_sphere_up — BSPTree level
|
||||
// ACE: BSPTree.cs step_sphere_up
|
||||
|
|
@ -1215,7 +1311,7 @@ public static class BSPQuery
|
|||
{
|
||||
collisions.SetCollisionNormal(collisionNormal);
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
|
@ -1228,14 +1324,14 @@ public static class BSPQuery
|
|||
// the early-out — collisions.SetCollisionNormal isn't called on
|
||||
// this path, but the caller's CollisionInfo.CollisionNormalValid
|
||||
// check will catch the parent slide site's normal write instead.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
collisions.SetCollisionNormal(collisionNormal);
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||
|
||||
var adjusted = validPos.Center - checkPos.Center;
|
||||
|
|
@ -1479,10 +1575,11 @@ public static class BSPQuery
|
|||
{
|
||||
var validPos = new CollisionSphere(sphere0);
|
||||
ResolvedPolygon? hitPoly = null;
|
||||
ushort _hitPolyId = 0; // Path 4 doesn't need the id
|
||||
bool changed = false;
|
||||
|
||||
FindWalkableInternal(root, resolved, path, validPos, movement, localSpaceZ,
|
||||
ref hitPoly, ref changed);
|
||||
ref hitPoly, ref _hitPolyId, ref changed);
|
||||
|
||||
if (changed && hitPoly is not null)
|
||||
{
|
||||
|
|
@ -1551,7 +1648,7 @@ public static class BSPQuery
|
|||
// is the dominant grounded-player path; without this the
|
||||
// probe's [resolve-bldg] line for every grounded BSP hit was
|
||||
// mis-labeled as "n/a (cylinder)".
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||
|
||||
var worldNormal = L2W(hitPoly0!.Plane.Normal);
|
||||
|
|
@ -1585,7 +1682,7 @@ public static class BSPQuery
|
|||
// L.2d slice 1.5 (2026-05-13): same early-record as foot
|
||||
// sphere — head-sphere wall hits also recurse via
|
||||
// StepSphereUp on the grounded path.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||
|
||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||
|
|
@ -1669,7 +1766,7 @@ public static class BSPQuery
|
|||
collisions.SetCollisionNormal(worldNormal0);
|
||||
collisions.SetSlidingNormal(worldNormal0);
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
|
|
@ -1679,7 +1776,7 @@ public static class BSPQuery
|
|||
path.SetCollide(worldNormal0);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
|
@ -1709,7 +1806,7 @@ public static class BSPQuery
|
|||
collisions.SetCollisionNormal(worldNormal1);
|
||||
collisions.SetSlidingNormal(worldNormal1);
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
|
|
@ -1718,7 +1815,7 @@ public static class BSPQuery
|
|||
path.SetCollide(worldNormal1);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
|
|
|||
52
src/AcDream.Core/Physics/BuildingPhysics.cs
Normal file
52
src/AcDream.Core/Physics/BuildingPhysics.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
|
||||
/// for outdoor→indoor cell entry. One per outdoor landcell that contains
|
||||
/// a building stab. Mirrors retail's <c>BuildingObj.Portals</c> array
|
||||
/// (per the pseudocode doc §"LandCell.find_transit_cells").
|
||||
/// </summary>
|
||||
public sealed class BuildingPhysics
|
||||
{
|
||||
public required Matrix4x4 WorldTransform { get; init; }
|
||||
public required Matrix4x4 InverseWorldTransform { get; init; }
|
||||
public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One building portal: the connection from a SortCell's BuildingObj to
|
||||
/// an interior EnvCell. ExactMatch is decoded from <see cref="Flags"/>
|
||||
/// bit 0 (<c>PortalFlags.ExactMatch = 0x0001</c>).
|
||||
/// </summary>
|
||||
public readonly struct BldPortalInfo
|
||||
{
|
||||
public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags)
|
||||
{
|
||||
OtherCellId = otherCellId;
|
||||
OtherPortalId = otherPortalId;
|
||||
Flags = flags;
|
||||
}
|
||||
|
||||
/// <summary>Full id of the interior EnvCell this portal connects to.</summary>
|
||||
public uint OtherCellId { get; }
|
||||
/// <summary>The portal id within the destination EnvCell.</summary>
|
||||
public ushort OtherPortalId { get; }
|
||||
public ushort Flags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Bit 0 of <see cref="Flags"/> (<c>DatReaderWriter.Enums.PortalFlags.ExactMatch</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Reserved per retail's <c>CBldPortal::exact_match</c>. NOT currently
|
||||
/// consumed by <see cref="CellTransit.CheckBuildingTransit"/> — every
|
||||
/// portal overlap is treated as a valid entry trigger. If a future
|
||||
/// regression surfaces (e.g., a building entered by overlapping a
|
||||
/// non-exact-match portal), wire this into the entry test.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool ExactMatch => (Flags & (ushort)PortalFlags.ExactMatch) != 0;
|
||||
}
|
||||
326
src/AcDream.Core/Physics/CellTransit.cs
Normal file
326
src/AcDream.Core/Physics/CellTransit.cs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
|
||||
/// ported from retail's <c>CObjCell::find_cell_list</c> family
|
||||
/// (sphere variant for the player's single foot sphere).
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
|
||||
/// faithful point-in-cell tests via
|
||||
/// <see cref="BSPQuery.PointInsideCellBsp"/>. Walks the portal graph
|
||||
/// starting from a given current cell to find which cells a moving
|
||||
/// sphere overlaps.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Reference pseudocode:
|
||||
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
|
||||
/// (2026-04-13). Retail decomp: <c>CEnvCell::find_transit_cells</c>
|
||||
/// (sphere variant) at <c>acclient_2013_pseudo_c.txt</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CellTransit
|
||||
{
|
||||
/// <summary>
|
||||
/// Small radius padding matching retail's <c>EPSILON</c> usage in the
|
||||
/// sphere-plane distance test (research doc §"EnvCell.find_transit_cells").
|
||||
/// </summary>
|
||||
private const float EPSILON = 0.02f;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor portal-neighbour expansion. For each portal of
|
||||
/// <paramref name="currentCell"/>, test whether the sphere overlaps
|
||||
/// the portal polygon's plane in cell-local space. If so, add the
|
||||
/// neighbour cell to <paramref name="candidates"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Ported from <c>CEnvCell::find_transit_cells</c> (sphere variant)
|
||||
/// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void FindTransitCellsSphere(
|
||||
PhysicsDataCache cache,
|
||||
CellPhysics currentCell,
|
||||
uint currentCellId,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
HashSet<uint> candidates,
|
||||
out bool exitOutside)
|
||||
{
|
||||
exitOutside = false;
|
||||
if (currentCell.PortalPolygons is null) return;
|
||||
|
||||
uint lbPrefix = currentCellId & 0xFFFF0000u;
|
||||
float rad = sphereRadius + EPSILON;
|
||||
|
||||
// Cell-local sphere center.
|
||||
var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform);
|
||||
|
||||
foreach (var portal in currentCell.Portals)
|
||||
{
|
||||
if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
|
||||
continue;
|
||||
|
||||
// Signed distance from sphere center to portal plane (cell-local).
|
||||
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
|
||||
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
{
|
||||
// Exit portal. Sphere must straddle the plane.
|
||||
if (dist > -rad && dist < rad)
|
||||
exitOutside = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
uint otherId = lbPrefix | portal.OtherCellId;
|
||||
|
||||
// Conservative add: the sphere is near the portal plane and on the
|
||||
// outward side (per PortalSide). This is the load-hint branch from
|
||||
// the research doc. A more retail-faithful path would call
|
||||
// CellBSP.sphere_intersects_cell on the neighbour — deferred.
|
||||
if (portal.PortalSide ? dist > -rad : dist < rad)
|
||||
candidates.Add(otherId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor neighbour expansion. Ported from
|
||||
/// <c>CLandCell::add_all_outside_cells</c> (sphere variant) per the
|
||||
/// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
|
||||
///
|
||||
/// <para>
|
||||
/// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
|
||||
/// within a landblock is computed from local X/Y mod 24. The sphere
|
||||
/// adds the primary cell plus up to 3 neighbours when the radius
|
||||
/// reaches a cell boundary.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void AddAllOutsideCells(
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
uint currentCellId,
|
||||
HashSet<uint> candidates)
|
||||
{
|
||||
const float CellSize = 24f;
|
||||
|
||||
uint lbPrefix = currentCellId & 0xFFFF0000u;
|
||||
|
||||
float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f;
|
||||
float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f;
|
||||
float localX = worldSphereCenter.X - lbXf;
|
||||
float localY = worldSphereCenter.Y - lbYf;
|
||||
|
||||
float cellLocalX = localX % CellSize;
|
||||
float cellLocalY = localY % CellSize;
|
||||
float minRad = sphereRadius;
|
||||
float maxRad = CellSize - sphereRadius;
|
||||
|
||||
int gridX = (int)(localX / CellSize);
|
||||
int gridY = (int)(localY / CellSize);
|
||||
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
|
||||
|
||||
AddOutsideCell(candidates, lbPrefix, gridX, gridY);
|
||||
|
||||
if (cellLocalX > maxRad)
|
||||
{
|
||||
AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
|
||||
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
|
||||
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
|
||||
}
|
||||
if (cellLocalX < minRad)
|
||||
{
|
||||
AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
|
||||
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
|
||||
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
|
||||
}
|
||||
if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
|
||||
if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
|
||||
}
|
||||
|
||||
private static void AddOutsideCell(HashSet<uint> candidates, uint lbPrefix, int gridX, int gridY)
|
||||
{
|
||||
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
|
||||
|
||||
// Cell index within landblock: row-major (X * 8 + Y) + 1.
|
||||
uint low = (uint)(gridX * 8 + gridY + 1);
|
||||
candidates.Add(lbPrefix | low);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor→indoor entry path. Ported from retail's
|
||||
/// <c>BuildingObj::find_building_transit_cells</c> +
|
||||
/// <c>EnvCell::check_building_transit</c>. For each portal of the
|
||||
/// outdoor building, look up the destination interior cell and test
|
||||
/// whether the sphere center is inside it via
|
||||
/// <see cref="BSPQuery.PointInsideCellBsp"/>. If so, add the interior
|
||||
/// cell to <paramref name="candidates"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail divergence:</b> retail's <c>check_building_transit</c>
|
||||
/// uses <c>CCellStruct::sphere_intersects_cell</c> (radius-aware
|
||||
/// BSP-vs-sphere test) which fires the moment ANY part of the sphere
|
||||
/// overlaps the destination cell. Our port uses
|
||||
/// <see cref="BSPQuery.PointInsideCellBsp"/> (radius-less, tests only
|
||||
/// the sphere CENTER). Practical effect: entry into a building fires
|
||||
/// when the player's foot-sphere center crosses the destination cell
|
||||
/// boundary — roughly <paramref name="sphereRadius"/> (~0.48m) DEEPER
|
||||
/// into the doorway than retail. If visual verification at the cottage
|
||||
/// door shows a noticeable "late entry" effect (player visually inside
|
||||
/// the building before walls switch from outdoor-stab to indoor-cell),
|
||||
/// port <c>sphere_intersects_cell</c> in a follow-up.
|
||||
/// <paramref name="sphereRadius"/> is plumbed through for that future
|
||||
/// upgrade; currently unused.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void CheckBuildingTransit(
|
||||
PhysicsDataCache cache,
|
||||
BuildingPhysics building,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
HashSet<uint> candidates)
|
||||
{
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
var otherCell = cache.GetCellStruct(portal.OtherCellId);
|
||||
if (otherCell?.CellBSP?.Root is null)
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
{
|
||||
string reason = otherCell is null ? "cell not cached" : "CellBSP null";
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[check-bldg] portal->0x{portal.OtherCellId:X8} skipped: {reason}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sphere center in the OTHER cell's local space.
|
||||
var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
|
||||
bool inside = BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter);
|
||||
|
||||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[check-bldg] portal->0x{portal.OtherCellId:X8} wpos=({worldSphereCenter.X:F3},{worldSphereCenter.Y:F3},{worldSphereCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) inside={inside}"));
|
||||
}
|
||||
|
||||
if (inside)
|
||||
{
|
||||
candidates.Add(portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-level cell-tracking driver, ported from retail's
|
||||
/// <c>CObjCell::find_cell_list</c> (sphere variant).
|
||||
///
|
||||
/// <para>
|
||||
/// Walks the portal graph from <paramref name="currentCellId"/>,
|
||||
/// finds the cell whose <see cref="CellPhysics.CellBSP"/> contains
|
||||
/// the sphere center, and returns its full id (landblock-prefixed).
|
||||
/// Falls back to <paramref name="currentCellId"/> when no candidate
|
||||
/// matches.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Pseudocode reference:
|
||||
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
|
||||
/// §"Overall Driver: find_cell_list".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static uint FindCellList(
|
||||
PhysicsDataCache cache,
|
||||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
uint currentCellId)
|
||||
{
|
||||
var candidates = new HashSet<uint>();
|
||||
uint currentLow = currentCellId & 0xFFFFu;
|
||||
|
||||
if (currentLow >= 0x0100u)
|
||||
{
|
||||
// Indoor seed.
|
||||
var currentCell = cache.GetCellStruct(currentCellId);
|
||||
if (currentCell is null) return currentCellId;
|
||||
|
||||
candidates.Add(currentCellId);
|
||||
|
||||
// BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
|
||||
var pending = new Queue<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
pending.Enqueue(currentCellId);
|
||||
visited.Add(currentCellId);
|
||||
int maxIterations = 16; // hard cap; portal graphs are small
|
||||
while (pending.Count > 0 && maxIterations-- > 0)
|
||||
{
|
||||
uint cellId = pending.Dequeue();
|
||||
var cell = cache.GetCellStruct(cellId);
|
||||
if (cell is null) continue;
|
||||
|
||||
var sizeBefore = candidates.Count;
|
||||
FindTransitCellsSphere(
|
||||
cache, cell, cellId, worldSphereCenter, sphereRadius,
|
||||
candidates, out bool exitOutside);
|
||||
|
||||
if (candidates.Count > sizeBefore)
|
||||
{
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (visited.Add(c)) // only enqueue if NEW
|
||||
pending.Enqueue(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (exitOutside)
|
||||
{
|
||||
// Add neighbour outdoor cells too.
|
||||
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Outdoor seed: expand neighbour landcells AND check for building stabs
|
||||
// with portals into interior EnvCells.
|
||||
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
|
||||
|
||||
// For each landcell candidate, see if it carries a building stab; if so,
|
||||
// check whether the sphere has crossed into any of the building's interior
|
||||
// EnvCells via CheckBuildingTransit.
|
||||
//
|
||||
// NOTE: PhysicsEngine.ResolveCellId currently bypasses this entire branch
|
||||
// for outdoor seeds (it uses its own _landblocks terrain grid loop). The
|
||||
// outdoor→indoor production path therefore runs through ResolveCellId's
|
||||
// OWN outdoor branch (see below for the call there too). This block is
|
||||
// exercised by direct-FindCellList callers (tests, future re-entry from
|
||||
// an indoor cell exiting through a portal that lands outside near a
|
||||
// building).
|
||||
var landcellSnapshot = new List<uint>(candidates);
|
||||
foreach (uint landcellId in landcellSnapshot)
|
||||
{
|
||||
var building = cache.GetBuilding(landcellId);
|
||||
if (building is null) continue;
|
||||
CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
// Containment test: for each candidate, transform worldSphereCenter to
|
||||
// local and test PointInsideCellBsp.
|
||||
foreach (uint candId in candidates)
|
||||
{
|
||||
var cand = cache.GetCellStruct(candId);
|
||||
if (cand?.CellBSP?.Root is null) continue;
|
||||
|
||||
var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
|
||||
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
|
||||
return candId;
|
||||
}
|
||||
|
||||
// No cell contained the sphere center. Stay in the input cell.
|
||||
return currentCellId;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ public sealed class PhysicsDataCache
|
|||
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
||||
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
|
||||
|
||||
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
|
||||
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
|
||||
|
||||
/// <summary>
|
||||
/// Extract and cache the physics BSP + polygon data from a GfxObj,
|
||||
/// PLUS always cache a visual AABB from the vertex data regardless of
|
||||
|
|
@ -128,14 +131,39 @@ public sealed class PhysicsDataCache
|
|||
/// (indoor room geometry). No-ops if the id is already cached or the
|
||||
/// CellStruct has no physics BSP.
|
||||
/// </summary>
|
||||
public void CacheCellStruct(uint envCellId, CellStruct cellStruct,
|
||||
Matrix4x4 worldTransform)
|
||||
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
|
||||
CellStruct cellStruct, Matrix4x4 worldTransform)
|
||||
{
|
||||
if (_cellStruct.ContainsKey(envCellId)) return;
|
||||
if (cellStruct.PhysicsBSP?.Root is null) return;
|
||||
|
||||
Matrix4x4.Invert(worldTransform, out var inverseTransform);
|
||||
|
||||
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
|
||||
|
||||
// Visible polygons — portals reference these (NOT PhysicsPolygons).
|
||||
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
|
||||
|
||||
// Portal list from envCell.CellPortals.
|
||||
var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
|
||||
foreach (var p in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new PortalInfo(
|
||||
otherCellId: p.OtherCellId,
|
||||
polygonId: p.PolygonId,
|
||||
flags: (ushort)p.Flags));
|
||||
}
|
||||
|
||||
// VisibleCells set — populated for future use; not consulted this phase.
|
||||
// envCell.VisibleCells is List<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
|
||||
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
{
|
||||
uint lbPrefix = envCellId & 0xFFFF0000u;
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCellIds.Add(lbPrefix | lowId);
|
||||
}
|
||||
|
||||
_cellStruct[envCellId] = new CellPhysics
|
||||
{
|
||||
BSP = cellStruct.PhysicsBSP,
|
||||
|
|
@ -143,8 +171,53 @@ public sealed class PhysicsDataCache
|
|||
Vertices = cellStruct.VertexArray,
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inverseTransform,
|
||||
Resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray),
|
||||
Resolved = resolved,
|
||||
// ── Phase 2 portal fields ──
|
||||
CellBSP = cellStruct.CellBSP,
|
||||
Portals = portals,
|
||||
PortalPolygons = portalPolygons,
|
||||
VisibleCellIds = visibleCellIds,
|
||||
};
|
||||
|
||||
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
|
||||
{
|
||||
var root = cellStruct.PhysicsBSP?.Root;
|
||||
int bspRootPolyCount = root?.Polygons?.Count ?? 0;
|
||||
bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
|
||||
|
||||
int bspTotalLeafPolys = 0;
|
||||
int bspUnmatchedIds = 0;
|
||||
if (root is not null)
|
||||
{
|
||||
var stack = new System.Collections.Generic.Stack<DatReaderWriter.Types.PhysicsBSPNode>();
|
||||
stack.Push(root);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var n = stack.Pop();
|
||||
if (n.Polygons is not null)
|
||||
{
|
||||
foreach (var pid in n.Polygons)
|
||||
{
|
||||
bspTotalLeafPolys++;
|
||||
if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
|
||||
}
|
||||
}
|
||||
if (n.PosNode is not null) stack.Push(n.PosNode);
|
||||
if (n.NegNode is not null) stack.Push(n.NegNode);
|
||||
}
|
||||
}
|
||||
|
||||
var bs = root?.BoundingSphere;
|
||||
string bsStr = bs is null
|
||||
? "bsphere=n/a"
|
||||
: System.FormattableString.Invariant(
|
||||
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
|
||||
|
||||
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
|
||||
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -152,7 +225,7 @@ public sealed class PhysicsDataCache
|
|||
/// and compute the face plane. Matches ACE's Polygon constructor which calls
|
||||
/// make_plane() and resolves Vertices from VertexIDs at load time.
|
||||
/// </summary>
|
||||
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
|
||||
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
|
||||
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
|
||||
VertexArray vertexArray)
|
||||
{
|
||||
|
|
@ -210,6 +283,15 @@ public sealed class PhysicsDataCache
|
|||
public int SetupCount => _setup.Count;
|
||||
public int CellStructCount => _cellStruct.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
|
||||
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
|
||||
/// to enumerate occluder candidates without exposing the underlying
|
||||
/// dictionary. Returns the live key-set; callers should snapshot the
|
||||
/// collection if they need stability across frames.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
|
||||
/// Intended for unit-test fixtures that construct synthetic BSP trees
|
||||
|
|
@ -217,6 +299,39 @@ public sealed class PhysicsDataCache
|
|||
/// </summary>
|
||||
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
|
||||
=> _gfxObj[gfxObjId] = physics;
|
||||
|
||||
/// <summary>
|
||||
/// Register a pre-built <see cref="CellPhysics"/> directly. Intended for
|
||||
/// unit-test fixtures that construct synthetic cells without going through
|
||||
/// dat-driven <see cref="CacheCellStruct"/>.
|
||||
/// </summary>
|
||||
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
|
||||
=> _cellStruct[envCellId] = physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
|
||||
/// for an outdoor landcell that contains a building stab. Used by
|
||||
/// <see cref="CellTransit.CheckBuildingTransit"/>.
|
||||
/// </summary>
|
||||
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
|
||||
{
|
||||
if (_buildings.ContainsKey(landcellId)) return;
|
||||
Matrix4x4.Invert(worldTransform, out var inverse);
|
||||
_buildings[landcellId] = new BuildingPhysics
|
||||
{
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
Portals = portals,
|
||||
};
|
||||
}
|
||||
|
||||
public BuildingPhysics? GetBuilding(uint landcellId)
|
||||
=> _buildings.TryGetValue(landcellId, out var b) ? b : null;
|
||||
|
||||
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
|
||||
|
||||
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
|
||||
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -285,9 +400,15 @@ public sealed class SetupPhysics
|
|||
/// </summary>
|
||||
public sealed class CellPhysics
|
||||
{
|
||||
public required PhysicsBSPTree BSP { get; init; }
|
||||
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
|
||||
public required VertexArray Vertices { get; init; }
|
||||
/// <summary>
|
||||
/// The physics BSP tree for this cell. Nullable so that test fixtures
|
||||
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
|
||||
/// alone without needing a real DAT BSP object. Production code must
|
||||
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
|
||||
/// </summary>
|
||||
public PhysicsBSPTree? BSP { get; init; }
|
||||
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
|
||||
public VertexArray? Vertices { get; init; }
|
||||
public Matrix4x4 WorldTransform { get; init; }
|
||||
public Matrix4x4 InverseWorldTransform { get; init; }
|
||||
|
||||
|
|
@ -295,4 +416,39 @@ public sealed class CellPhysics
|
|||
/// Pre-resolved polygon data with vertex positions and computed planes.
|
||||
/// </summary>
|
||||
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
|
||||
|
||||
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
|
||||
|
||||
/// <summary>
|
||||
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
|
||||
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
|
||||
/// (collision) and from the renderer's drawing-BSP.
|
||||
/// Source: <c>cellStruct.CellBSP</c> at cache time.
|
||||
/// Nullable: cells without a CellBSP cannot participate in portal
|
||||
/// containment and are skipped by <see cref="CellTransit"/>.
|
||||
/// </summary>
|
||||
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Portal connections to neighbouring cells, in cell-local space.
|
||||
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
|
||||
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
|
||||
/// holds <c>PhysicsPolygons</c>. Portal lookup via
|
||||
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
|
||||
/// Nullable when the cell has no visible polys (rare).
|
||||
/// </summary>
|
||||
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The full cell ids visible from this cell (with landblock prefix).
|
||||
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
|
||||
/// this phase; reserved for the optional <c>find_cell_list</c>
|
||||
/// visibility filter.
|
||||
/// </summary>
|
||||
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,4 +141,84 @@ public static class PhysicsDiagnostics
|
|||
/// </summary>
|
||||
public static bool ProbeUseabilityFallbackEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// L.4-diag (2026-04-30) → promoted into <see cref="PhysicsDiagnostics"/>
|
||||
/// 2026-05-16 per CLAUDE.md "Code Structure Rules" §5 (diagnostic owner
|
||||
/// classes, not per-call-site env reads). Gates the <c>[steep-roof]</c>
|
||||
/// trace family that fires from four sites during the rooftop-bounce
|
||||
/// investigation:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>PhysicsEngine.ResolveWithTransition</c> —
|
||||
/// <c>[steep-roof] KILL-VELOCITY-APPLIED</c> when retail-faithful
|
||||
/// <c>kill_velocity</c> zeroes the body's velocity on steep-slope
|
||||
/// impact.</description></item>
|
||||
/// <item><description><c>TransitionTypes</c> (<c>FindEnvCollisions</c>
|
||||
/// post-step) — per-frame plane-normal trace on the active
|
||||
/// <see cref="CollisionInfo"/>.</description></item>
|
||||
/// <item><description><c>PlayerMovementController</c> — two sites
|
||||
/// emitting <c>[steep-roof]</c> + the per-frame bounce trace when
|
||||
/// the post-collision velocity disagrees with retail.</description></item>
|
||||
/// </list>
|
||||
/// Initial state from <c>ACDREAM_DUMP_STEEP_ROOF=1</c>. Runtime-toggleable
|
||||
/// via the property setter; not yet wired to a DebugPanel checkbox (open
|
||||
/// follow-up if a debugging session calls for it).
|
||||
/// </summary>
|
||||
public static bool DumpSteepRoofEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). When true, emits one
|
||||
/// <c>[indoor-bsp]</c> line per <see cref="BSPQuery.FindCollisions"/>
|
||||
/// call made from <see cref="Transition.FindEnvCollisions"/>'s indoor
|
||||
/// cell-BSP branch. Captures the cell id, sphere local position,
|
||||
/// resulting <see cref="TransitionState"/>, and the hit poly's id,
|
||||
/// local-normal, and side-type — pinpoints why indoor collision
|
||||
/// returns spurious collisions (#84) and helps cross-check the
|
||||
/// outdoor-in approach path (#85).
|
||||
///
|
||||
/// <para>
|
||||
/// While true, this also un-gates the diagnostic
|
||||
/// <see cref="LastBspHitPoly"/> side-channel inside
|
||||
/// <see cref="BSPQuery"/> — see the OR'd condition at every poly
|
||||
/// write site. Zero-cost when off.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
|
||||
/// Runtime-toggleable via DebugPanel.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorBspEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase D follow-up (2026-05-19). When true, emits one
|
||||
/// <c>[cell-cache]</c> line each time <see cref="PhysicsDataCache.CacheCellStruct"/>
|
||||
/// caches a new EnvCell. Reports per-cell polygon counts and BSP root
|
||||
/// structure so the caller can cross-reference with <c>[indoor-bsp]</c>
|
||||
/// lines to distinguish between:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Empty data (physicsPolyCount=0 or resolvedCount=0)
|
||||
/// — candidate (a)/(c) in the poly=n/a investigation.</description></item>
|
||||
/// <item><description>Non-zero polygon counts but bspRootPolyCount=0 at
|
||||
/// root + tree has children — correct structure for non-leaf root,
|
||||
/// leaves hold the poly refs; not a bug.</description></item>
|
||||
/// <item><description>Non-zero polygon counts but bspRootPolyCount=0 at
|
||||
/// root AND root is a leaf (bspRootHasChildren=false) — BSP leaf with
|
||||
/// zero poly refs, candidate (b)/(d).</description></item>
|
||||
/// </list>
|
||||
/// This diagnostic fires at most once per EnvCell (cache is no-op after
|
||||
/// first population). It does NOT have a DebugPanel mirror yet — this is
|
||||
/// a one-shot capture tool, not a persistent toggle. Promote to full
|
||||
/// infrastructure after the root cause is identified.
|
||||
///
|
||||
/// <para>Initial state from <c>ACDREAM_PROBE_CELL_CACHE=1</c>.</para>
|
||||
/// </summary>
|
||||
public static bool ProbeCellCacheEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,20 +230,43 @@ public sealed class PhysicsEngine
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the outdoor cell id that owns a world-space position.
|
||||
/// Indoor ids are preserved because EnvCell ownership still comes from
|
||||
/// portal/cell BSP state; outdoor ids are derived from the registered
|
||||
/// landblock that currently contains the point.
|
||||
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
|
||||
/// given world position via retail's portal-graph traversal for indoor
|
||||
/// cells, or via terrain grid lookup for outdoor cells.
|
||||
///
|
||||
/// <para>
|
||||
/// Indoor seed: delegates to <see cref="CellTransit.FindCellList"/> which
|
||||
/// BFS-walks the portal graph and uses <see cref="BSPQuery.PointInsideCellBsp"/>
|
||||
/// for containment. This replaces Phase D's AABB shortcut.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Outdoor seed: uses the registered landblock terrain grid to compute
|
||||
/// the correct prefixed cell ID, preserving the pre-existing outdoor
|
||||
/// resolution behavior (the L.2e prefix-preservation fix).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Design: <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId)
|
||||
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
|
||||
{
|
||||
if (fallbackCellId == 0)
|
||||
return 0;
|
||||
if (fallbackCellId == 0) return 0;
|
||||
|
||||
uint fallbackLow = fallbackCellId & 0xFFFFu;
|
||||
if (fallbackLow >= 0x0100u)
|
||||
return fallbackCellId;
|
||||
|
||||
if (fallbackLow >= 0x0100u)
|
||||
{
|
||||
// Indoor branch needs DataCache to look up cells; outdoor uses
|
||||
// _landblocks (no DataCache dependency).
|
||||
if (DataCache is null) return fallbackCellId;
|
||||
return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
|
||||
}
|
||||
|
||||
// Outdoor seed: use terrain grid to compute the prefixed cell id.
|
||||
// Preserves the L.2e prefix-preservation fix (always apply the matched
|
||||
// landblock's high-16 prefix even when fallbackCellId arrived bare-low-byte).
|
||||
foreach (var kvp in _landblocks)
|
||||
{
|
||||
var lb = kvp.Value;
|
||||
|
|
@ -252,9 +275,29 @@ public sealed class PhysicsEngine
|
|||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||||
{
|
||||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||||
return (fallbackCellId & 0xFFFF0000u) == 0
|
||||
? lowCellId
|
||||
: (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||||
uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||||
|
||||
// Outdoor→indoor entry: if this landcell has a cached building,
|
||||
// check whether the sphere has crossed into one of its interior
|
||||
// EnvCells via the building's portals.
|
||||
if (DataCache is not null)
|
||||
{
|
||||
var building = DataCache.GetBuilding(outdoorCellId);
|
||||
if (building is not null)
|
||||
{
|
||||
var candidates = new System.Collections.Generic.HashSet<uint>();
|
||||
CellTransit.CheckBuildingTransit(
|
||||
DataCache, building, worldPos, sphereRadius, candidates);
|
||||
if (candidates.Count > 0)
|
||||
{
|
||||
// First candidate wins — building portal containment is
|
||||
// mutually exclusive in retail (one interior cell per portal).
|
||||
foreach (var c in candidates) return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outdoorCellId;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -634,7 +677,7 @@ public sealed class PhysicsEngine
|
|||
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
|
||||
if (transition.ObjectInfo.VelocityKilled)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
|
||||
if (PhysicsDiagnostics.DumpSteepRoofEnabled)
|
||||
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
|
||||
body.Velocity = Vector3.Zero;
|
||||
}
|
||||
|
|
@ -726,7 +769,7 @@ public sealed class PhysicsEngine
|
|||
|
||||
return new ResolveResult(
|
||||
sp.CheckPos,
|
||||
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
|
||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
|
||||
onGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal);
|
||||
|
|
@ -744,7 +787,7 @@ public sealed class PhysicsEngine
|
|||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||
return new ResolveResult(
|
||||
sp.CheckPos,
|
||||
ResolveOutdoorCellId(sp.CheckPos, partialCellId),
|
||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
|
||||
partialOnGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal);
|
||||
|
|
|
|||
45
src/AcDream.Core/Physics/PortalInfo.cs
Normal file
45
src/AcDream.Core/Physics/PortalInfo.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
|
||||
/// EnvCells. Each <see cref="CellPhysics"/> carries a list of these,
|
||||
/// mirroring retail's <c>CCellStruct.portals</c> array.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="OtherCellId"/> is a low-16 cell index (combined with the
|
||||
/// owning landblock prefix at lookup time) or <c>0xFFFF</c> to mean
|
||||
/// "exit to outdoor world" (the player crosses this portal to leave
|
||||
/// the building).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="PolygonId"/> indexes the OWNING cell's
|
||||
/// <see cref="CellPhysics.PortalPolygons"/> dict (the visible-polygon
|
||||
/// table, NOT <see cref="CellPhysics.Resolved"/> which holds physics
|
||||
/// polys).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="PortalSide"/> decodes bit 2 of <see cref="Flags"/>:
|
||||
/// <c>(Flags & 2) == 0</c> → portal's polygon normal points INTO
|
||||
/// the owning cell (so dist > 0 in cell-local space means "outside
|
||||
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
|
||||
/// load-hint path for unloaded neighbours.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly struct PortalInfo
|
||||
{
|
||||
public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
|
||||
{
|
||||
OtherCellId = otherCellId;
|
||||
PolygonId = polygonId;
|
||||
Flags = flags;
|
||||
}
|
||||
|
||||
public ushort OtherCellId { get; }
|
||||
public ushort PolygonId { get; }
|
||||
public ushort Flags { get; }
|
||||
|
||||
/// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
|
||||
public bool PortalSide => (Flags & 2) == 0;
|
||||
}
|
||||
|
|
@ -715,7 +715,7 @@ public sealed class Transition
|
|||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
|
||||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||||
bool diagSteep = PhysicsDiagnostics.DumpSteepRoofEnabled;
|
||||
if (diagSteep)
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
@ -1166,6 +1166,120 @@ public sealed class Transition
|
|||
// Environment collision — outdoor terrain
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Synthesize the indoor walkable contact plane for the player's current
|
||||
/// position when the cell BSP returns OK (no wall collision).
|
||||
///
|
||||
/// <para>
|
||||
/// Routes through the retail-faithful BSP walkable-finder
|
||||
/// (<see cref="BSPQuery.FindWalkableSphere"/>) — which traverses the cell
|
||||
/// PhysicsBSP and picks the polygon closest to the foot along the up vector.
|
||||
/// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
|
||||
/// stop-gap; that scan picked the wrong floor whenever two polygons
|
||||
/// overlapped in XY at different Z (cellars, 2nd floors, balconies).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>false</c> if no walkable floor poly is found under the
|
||||
/// player. The caller falls through to outdoor terrain in that case
|
||||
/// (defensive backstop — should not normally happen inside a sealed cell).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793),
|
||||
/// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006),
|
||||
/// CPolygon::adjust_sphere_to_plane (:322032).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal bool TryFindIndoorWalkablePlane(
|
||||
CellPhysics cellPhysics,
|
||||
Vector3 localFootCenter,
|
||||
float sphereRadius,
|
||||
out System.Numerics.Plane worldPlane,
|
||||
out Vector3[] worldVertices,
|
||||
out uint hitPolyId)
|
||||
{
|
||||
worldPlane = default;
|
||||
worldVertices = System.Array.Empty<Vector3>();
|
||||
hitPolyId = 0;
|
||||
|
||||
if (cellPhysics.BSP?.Root is null) return false;
|
||||
|
||||
// Build foot sphere in cell-local space. Caller passes localFootCenter
|
||||
// already transformed into cell-local space and the resolver's
|
||||
// foot-sphere radius.
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = localFootCenter,
|
||||
Radius = sphereRadius,
|
||||
};
|
||||
|
||||
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
|
||||
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
|
||||
// "standing here, find my floor" we want the walkability slope
|
||||
// threshold FloorZ. The outer resolver may have set it to LandingZ
|
||||
// (airborne→ground transition) or another value; we must not leak our
|
||||
// change back to the resolver. try/finally so an exception inside
|
||||
// FindWalkableSphere doesn't leak the modified state.
|
||||
float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
|
||||
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
|
||||
ResolvedPolygon? hitPoly = null;
|
||||
ushort hitId = 0;
|
||||
Vector3 adjustedCenter;
|
||||
bool found;
|
||||
|
||||
try
|
||||
{
|
||||
found = BSPQuery.FindWalkableSphere(
|
||||
cellPhysics.BSP.Root,
|
||||
cellPhysics.Resolved,
|
||||
this,
|
||||
localSphere,
|
||||
INDOOR_WALKABLE_PROBE_DISTANCE,
|
||||
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
|
||||
out hitPoly,
|
||||
out hitId,
|
||||
out adjustedCenter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
|
||||
}
|
||||
|
||||
// adjustedCenter (sphere slid onto polygon plane) is intentionally
|
||||
// discarded — ValidateWalkable recomputes contact geometry from the
|
||||
// world-space plane + foot position, consistent with the outdoor terrain
|
||||
// path (SampleTerrainWalkable returns only plane + vertices, no adjusted
|
||||
// sphere). The local is held only to satisfy the out param.
|
||||
|
||||
if (!found || hitPoly is null) return false;
|
||||
|
||||
// Transform hit polygon's plane + vertices to world space. Math is
|
||||
// unchanged from the previous TryFindIndoorWalkablePlane implementation.
|
||||
var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
|
||||
worldNormal = Vector3.Normalize(worldNormal);
|
||||
var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
|
||||
float worldD = -Vector3.Dot(worldNormal, worldV0);
|
||||
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
|
||||
|
||||
worldVertices = new Vector3[hitPoly.Vertices.Length];
|
||||
for (int i = 0; i < hitPoly.Vertices.Length; i++)
|
||||
worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
|
||||
|
||||
hitPolyId = hitId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downward probe distance used by <see cref="TryFindIndoorWalkablePlane"/>
|
||||
/// when scanning for the indoor walkable contact plane. 50 cm.
|
||||
/// Larger than the +0.02f cell-origin Z-bump and larger than any realistic
|
||||
/// step riser; smaller than a full cell height so we don't reach through
|
||||
/// a thin floor into the cell above/below.
|
||||
/// </summary>
|
||||
private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
|
||||
/// Indoor BSP collision is deferred to Task 6c.
|
||||
|
|
@ -1178,13 +1292,13 @@ public sealed class Transition
|
|||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
|
||||
if (resolvedOutdoorCellId != sp.CheckCellId)
|
||||
sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
|
||||
|
||||
Vector3 footCenter = sp.GlobalSphere[0].Origin;
|
||||
float sphereRadius = sp.GlobalSphere[0].Radius;
|
||||
|
||||
uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId);
|
||||
if (resolvedOutdoorCellId != sp.CheckCellId)
|
||||
sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
|
||||
|
||||
// ── Indoor cell BSP collision ────────────────────────────────────
|
||||
// If the player is in an indoor cell (low 16 bits >= 0x0100),
|
||||
// query the CellStruct's PhysicsBSP for wall/floor/ceiling collision.
|
||||
|
|
@ -1217,6 +1331,12 @@ public sealed class Transition
|
|||
};
|
||||
}
|
||||
|
||||
// Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly
|
||||
// side-channel before the call so a missed write (no collision)
|
||||
// is greppable as "poly=n/a" in the probe line below.
|
||||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
PhysicsDiagnostics.LastBspHitPoly = null;
|
||||
|
||||
// Use the full 6-path BSP dispatcher for retail-faithful collision.
|
||||
// Use pre-resolved polygons (vertices+planes computed at cache time).
|
||||
var cellState = BSPQuery.FindCollisions(
|
||||
|
|
@ -1231,12 +1351,78 @@ public sealed class Transition
|
|||
Quaternion.Identity,
|
||||
engine); // engine needed for Path 5 step-up
|
||||
|
||||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
{
|
||||
var hit = PhysicsDiagnostics.LastBspHitPoly;
|
||||
string polyDesc = hit is null
|
||||
? "poly=n/a"
|
||||
: System.FormattableString.Invariant(
|
||||
$"n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}");
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[indoor-bsp] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ")
|
||||
+ polyDesc);
|
||||
}
|
||||
|
||||
if (cellState != TransitionState.OK)
|
||||
{
|
||||
if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
|
||||
ci.CollidedWithEnvironment = true;
|
||||
return cellState;
|
||||
}
|
||||
|
||||
// ── Synthesize indoor walkable contact plane ──────────────
|
||||
// Indoor walking Phase 2 follow-up (2026-05-19). When the BSP
|
||||
// returns OK (no wall collision), the player is standing on a
|
||||
// floor poly inside the cell. We must NOT fall through to
|
||||
// outdoor terrain (SampleTerrainWalkable) — the outdoor terrain
|
||||
// Z is below the indoor floor due to the +0.02f Z-bump applied
|
||||
// for render z-fight prevention. ValidateWalkable would then see
|
||||
// the player 0.5m above the outdoor plane → marks them as
|
||||
// airborne → walkable=False → falling animation, never recovers.
|
||||
//
|
||||
// Retail: CEnvCell::find_env_collisions returns from the cell
|
||||
// branch with the cell's walkable plane set — no fall-through
|
||||
// to terrain.
|
||||
bool walkableHit = TryFindIndoorWalkablePlane(
|
||||
cellPhysics, localCenter, sphereRadius,
|
||||
out var indoorPlane,
|
||||
out var indoorVertices,
|
||||
out uint hitPolyId);
|
||||
|
||||
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
|
||||
{
|
||||
if (walkableHit)
|
||||
{
|
||||
// dz = signed gap between foot and synthesized plane.
|
||||
// Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes)
|
||||
// gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z
|
||||
float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z;
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=HIT poly=0x{hitPolyId:X4} wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=MISS"));
|
||||
}
|
||||
}
|
||||
|
||||
if (walkableHit)
|
||||
{
|
||||
return ValidateWalkable(
|
||||
footCenter,
|
||||
sphereRadius,
|
||||
indoorPlane,
|
||||
isWater: false,
|
||||
waterDepth: 0f,
|
||||
cellId: sp.CheckCellId,
|
||||
walkableVertices: indoorVertices);
|
||||
}
|
||||
// If no walkable floor was found under the player indoors
|
||||
// (rare — cell with only walls/ceiling), fall through to
|
||||
// outdoor terrain as a defensive backstop. Indoor walking
|
||||
// will report walkable=False until the player moves over a
|
||||
// cell with a proper floor poly.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
68
src/AcDream.Core/Rendering/CameraDiagnostics.cs
Normal file
68
src/AcDream.Core/Rendering/CameraDiagnostics.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors
|
||||
/// the <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/> pattern:
|
||||
/// static fields seeded from env vars at process start, runtime-settable
|
||||
/// via property setters that the DebugPanel writes to.
|
||||
///
|
||||
/// <para>
|
||||
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CameraDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Master toggle. When true (default, after visual ship 2026-05-18)
|
||||
/// the retail-faithful <c>AcDream.App.Rendering.RetailChaseCamera</c>
|
||||
/// is the active chase camera; when false, the legacy
|
||||
/// <c>AcDream.App.Rendering.ChaseCamera</c> rigid-follow camera is.
|
||||
/// Initial state from <c>ACDREAM_RETAIL_CHASE</c> — default-on if
|
||||
/// unset, off only when explicitly set to <c>"0"</c>. The legacy
|
||||
/// camera stays available via the DebugPanel toggle pending the
|
||||
/// follow-up deletion commit.
|
||||
/// </summary>
|
||||
public static bool UseRetailChaseCamera { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") != "0";
|
||||
|
||||
/// <summary>
|
||||
/// When true (default), the camera basis follows the player's
|
||||
/// 5-frame averaged velocity vector — tilts with the terrain on
|
||||
/// hills. When false, the basis is built from a flat (yaw, 0) vector
|
||||
/// and the camera stays horizontal even on slopes. Initial state
|
||||
/// from <c>ACDREAM_CAMERA_ALIGN_SLOPE</c>; default-on if unset.
|
||||
/// </summary>
|
||||
public static bool AlignToSlope { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0";
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame translation damping rate. Retail default 0.45. Higher
|
||||
/// (→ 1.0) snaps faster; lower (→ 0.0) lags more. Formula per frame:
|
||||
/// <c>alpha = clamp(TranslationStiffness * dt * 10, 0, 1)</c>.
|
||||
/// </summary>
|
||||
public static float TranslationStiffness { get; set; } = 0.45f;
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame rotation damping rate. Independent of translation —
|
||||
/// can be tuned higher so the camera swings to look at you faster
|
||||
/// than it physically catches up. Retail default 0.45.
|
||||
/// </summary>
|
||||
public static float RotationStiffness { get; set; } = 0.45f;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse-delta low-pass window (seconds). Mouse deltas spaced
|
||||
/// closer than this are averaged with the previous delta before
|
||||
/// being fed to pitch/yaw adjustments. Smooths out jitter on
|
||||
/// high-DPI mice. Retail default 0.25.
|
||||
/// </summary>
|
||||
public static float MouseLowPassWindowSec { get; set; } = 0.25f;
|
||||
|
||||
/// <summary>
|
||||
/// Per-second rate that held-key offset adjustments
|
||||
/// (CameraZoomIn/Out, CameraRaise/Lower) integrate into the
|
||||
/// camera's Distance / Pitch. Retail default 40.0.
|
||||
/// </summary>
|
||||
public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
|
||||
}
|
||||
109
src/AcDream.Core/Rendering/RenderingDiagnostics.cs
Normal file
109
src/AcDream.Core/Rendering/RenderingDiagnostics.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
|
||||
/// rendering pipeline. Initialized from env vars at process start;
|
||||
/// flippable at runtime via the DebugPanel mirror. Log call sites read
|
||||
/// these statics so a checkbox toggle takes effect on the next frame
|
||||
/// without relaunching.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
|
||||
/// pattern. The master <see cref="IndoorAll"/> toggle is the user's
|
||||
/// common case — flipping it cascades to all five probe flags.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RenderingDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
||||
/// <c>[indoor-walk]</c> line per visible cell entity per second:
|
||||
/// entity id, world position, parent cell id, landblock visible flag,
|
||||
/// AABB-visible flag, "in visible cells" flag, drew flag.
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_WALK=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorWalkEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-lookup]</c>
|
||||
/// line per visible cell entity per second: render-data hit/miss,
|
||||
/// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_LOOKUP=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorLookupEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbMeshAdapter</c> emits two lines per EnvCell id:
|
||||
/// <c>[indoor-upload] requested</c> on first IncrementRefCount and
|
||||
/// <c>[indoor-upload] completed</c> when WB's staged drain produces
|
||||
/// its <c>ObjectMeshData</c>. Missing "completed" lines indicate WB
|
||||
/// silently returned null (hypothesis H1).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_UPLOAD=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorUploadEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-xform]</c>
|
||||
/// line per visible cell entity per second: cell-geometry SetupPart's
|
||||
/// composed world matrix translation. Disambiguates transform
|
||||
/// double-apply (hypothesis H5).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_XFORM=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorXformEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
||||
/// <c>[indoor-cull]</c> line per cell entity that gets culled, with
|
||||
/// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
|
||||
/// cull bugs (hypothesis H3).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_CULL=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorCullEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Master toggle. Reading reflects the AND of all five flags
|
||||
/// (true only when every probe is on). Writing cascades — setting
|
||||
/// to <see langword="true"/> turns ALL five flags on; setting to
|
||||
/// <see langword="false"/> turns ALL five off.
|
||||
/// </summary>
|
||||
public static bool IndoorAll
|
||||
{
|
||||
get => ProbeIndoorWalkEnabled
|
||||
&& ProbeIndoorLookupEnabled
|
||||
&& ProbeIndoorUploadEnabled
|
||||
&& ProbeIndoorXformEnabled
|
||||
&& ProbeIndoorCullEnabled;
|
||||
set
|
||||
{
|
||||
ProbeIndoorWalkEnabled = value;
|
||||
ProbeIndoorLookupEnabled = value;
|
||||
ProbeIndoorUploadEnabled = value;
|
||||
ProbeIndoorXformEnabled = value;
|
||||
ProbeIndoorCullEnabled = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for probe call sites. Returns <see langword="true"/> when
|
||||
/// the low 16 bits of <paramref name="id"/> are ≥ 0x0100 — the AC
|
||||
/// convention for EnvCell (indoor) cells, as opposed to outdoor cells
|
||||
/// in the 8×8 landblock grid (0x0001–0x0040).
|
||||
/// </summary>
|
||||
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
|
||||
}
|
||||
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal file
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
|
||||
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
|
||||
/// (currently-loaded EnvCells with resolved polygon planes), returns
|
||||
/// the nearest world-space <c>t</c> along the ray that hits any cell
|
||||
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
|
||||
/// all cells.
|
||||
///
|
||||
/// <para>
|
||||
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
|
||||
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
|
||||
/// ray-triangle intersection; one test per triangle. Cells are
|
||||
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
|
||||
/// so the ray runs in cell-local space and the resolved-polygon
|
||||
/// vertices don't need re-transformation per query.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// No BSP traversal — iterates every polygon in every cell. Cell count
|
||||
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
|
||||
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
|
||||
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CellBspRayOccluder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the nearest positive <c>t</c> such that
|
||||
/// <c>origin + t * direction</c> intersects a polygon in any cell.
|
||||
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
|
||||
/// is intersected.
|
||||
/// </summary>
|
||||
/// <param name="direction">Need not be normalized; returned <c>t</c>
|
||||
/// scales with direction length the same as a parametric ray.</param>
|
||||
public static float NearestWallT(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
IEnumerable<CellPhysics> loadedCells)
|
||||
{
|
||||
if (loadedCells is null) return float.PositiveInfinity;
|
||||
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var cell in loadedCells)
|
||||
{
|
||||
if (cell?.Resolved is null) continue;
|
||||
|
||||
// Bring the ray into cell-local space ONCE per cell.
|
||||
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
|
||||
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
|
||||
|
||||
foreach (var (_, poly) in cell.Resolved)
|
||||
{
|
||||
// Triangulate the (possibly polygonal) face into a fan.
|
||||
int n = poly.NumPoints;
|
||||
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
|
||||
continue;
|
||||
|
||||
for (int i = 1; i < n - 1; i++)
|
||||
{
|
||||
if (TryRayTriangle(
|
||||
localOrigin, localDirection,
|
||||
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
|
||||
out var t)
|
||||
&& t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestT;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Möller-Trumbore ray-triangle intersection. Returns true with
|
||||
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
|
||||
/// at a positive distance.
|
||||
/// </summary>
|
||||
private static bool TryRayTriangle(
|
||||
Vector3 origin, Vector3 direction,
|
||||
Vector3 v0, Vector3 v1, Vector3 v2,
|
||||
out float t)
|
||||
{
|
||||
const float Epsilon = 1e-7f;
|
||||
|
||||
var edge1 = v1 - v0;
|
||||
var edge2 = v2 - v0;
|
||||
var pvec = Vector3.Cross(direction, edge2);
|
||||
float det = Vector3.Dot(edge1, pvec);
|
||||
|
||||
// No two-sided handling here — picker should be permissive so
|
||||
// a wall blocks regardless of which side the camera is on.
|
||||
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
|
||||
float invDet = 1f / det;
|
||||
|
||||
var tvec = origin - v0;
|
||||
float u = Vector3.Dot(tvec, pvec) * invDet;
|
||||
if (u < 0f || u > 1f) { t = 0f; return false; }
|
||||
|
||||
var qvec = Vector3.Cross(tvec, edge1);
|
||||
float v = Vector3.Dot(direction, qvec) * invDet;
|
||||
if (v < 0f || u + v > 1f) { t = 0f; return false; }
|
||||
|
||||
t = Vector3.Dot(edge2, qvec) * invDet;
|
||||
return t > Epsilon;
|
||||
}
|
||||
}
|
||||
86
src/AcDream.Core/Selection/ScreenProjection.cs
Normal file
86
src/AcDream.Core/Selection/ScreenProjection.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Shared screen-space projection math for the target indicator and the
|
||||
/// world picker. Both call into <see cref="TryProjectSphereToScreenRect"/>
|
||||
/// so the click hit-area is guaranteed to match the visible indicator
|
||||
/// rect — "what you see is what you click".
|
||||
///
|
||||
/// <para>
|
||||
/// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at
|
||||
/// <c>0x00452e20</c>, which uses
|
||||
/// <c>Render::GetViewerBBox(selection_sphere, &corner1, &corner2)</c>
|
||||
/// to compute a camera-aligned bbox of the sphere and projects the two
|
||||
/// corner points. We use the mathematical equivalent (project center,
|
||||
/// compute screen radius analytically) — both produce identical pixel
|
||||
/// rects for a standard right-handed perspective.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ScreenProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Project a world-space sphere to a screen-space axis-aligned square
|
||||
/// rectangle.
|
||||
/// </summary>
|
||||
/// <param name="worldCenter">Sphere center in world space.</param>
|
||||
/// <param name="worldRadius">Sphere radius in world space.</param>
|
||||
/// <param name="view">View matrix (System.Numerics row-vector convention).</param>
|
||||
/// <param name="projection">Projection matrix. <c>M22 = cot(fovY/2)</c>
|
||||
/// for a standard right-handed perspective.</param>
|
||||
/// <param name="viewport">Viewport size in pixels (X = width, Y = height).</param>
|
||||
/// <param name="rectMin">Out: top-left corner of the rect in viewport pixels.</param>
|
||||
/// <param name="rectMax">Out: bottom-right corner of the rect in viewport pixels.</param>
|
||||
/// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere
|
||||
/// center — use this for nearest-first sorting when multiple rects overlap.</param>
|
||||
/// <param name="minSidePixels">Minimum side length of the rect. Distant
|
||||
/// entities clamp to this so they remain pickable / visible. 12 px
|
||||
/// matches the indicator's clamp floor.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the sphere is in front of the camera and the rect was
|
||||
/// produced; <c>false</c> if the center is behind the camera
|
||||
/// (<c>clip.W <= 0</c>) or the rect is more than a screen offset
|
||||
/// from the viewport (obviously off-screen).
|
||||
/// </returns>
|
||||
public static bool TryProjectSphereToScreenRect(
|
||||
Vector3 worldCenter, float worldRadius,
|
||||
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
|
||||
out Vector2 rectMin, out Vector2 rectMax, out float depth,
|
||||
float minSidePixels = 12f)
|
||||
{
|
||||
rectMin = default;
|
||||
rectMax = default;
|
||||
depth = 0f;
|
||||
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
|
||||
if (clip.W <= 0.001f) return false;
|
||||
|
||||
depth = clip.W;
|
||||
|
||||
float ndcX = clip.X / clip.W;
|
||||
float ndcY = clip.Y / clip.W;
|
||||
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
|
||||
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
|
||||
|
||||
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
|
||||
// the camera-space distance.
|
||||
float scaleY = projection.M22;
|
||||
if (scaleY <= 0f) return false;
|
||||
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
|
||||
|
||||
// Cull obviously-off-screen entities (more than a screen away).
|
||||
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
|
||||
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
|
||||
|
||||
// Floor at minSidePixels so distant entities still get a visible /
|
||||
// clickable rect. The picker must apply the same floor as the
|
||||
// indicator or distant clicks won't match the visible bracket.
|
||||
if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
|
||||
|
||||
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
|
||||
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -91,13 +91,20 @@ public static class WorldPicker
|
|||
uint skipServerGuid,
|
||||
float maxDistance = 50f,
|
||||
Func<uint, float>? radiusForGuid = null,
|
||||
Func<uint, float>? verticalOffsetForGuid = null)
|
||||
Func<uint, float>? verticalOffsetForGuid = null,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
const float DefaultRadius = 1.0f;
|
||||
const float DefaultVerticalOffset = 0.9f;
|
||||
|
||||
if (direction.LengthSquared() < 1e-10f) return null;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): if the caller provides
|
||||
// a cell-BSP occluder, query the nearest wall hit along the ray
|
||||
// ONCE; entities whose ray-t exceeds the wall-t sit behind a wall
|
||||
// and are skipped.
|
||||
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var entity in candidates)
|
||||
|
|
@ -150,6 +157,7 @@ public static class WorldPicker
|
|||
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
|
||||
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
||||
if (t >= maxDistance) continue;
|
||||
if (t >= wallT) continue; // wall is between camera and entity (#86)
|
||||
if (t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
|
|
@ -158,4 +166,121 @@ public static class WorldPicker
|
|||
}
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
|
||||
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
|
||||
/// projects to a screen-space rectangle through
|
||||
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
|
||||
/// rect is inflated by <paramref name="inflatePixels"/> on every side
|
||||
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
|
||||
/// hit-tested against the mouse pixel. Among rects that contain the
|
||||
/// mouse, the entity with the nearest camera-space depth wins.
|
||||
///
|
||||
/// <para>
|
||||
/// Why screen-space instead of world-space ray-sphere: the indicator
|
||||
/// draws a screen-space RECT. A world-space sphere projects to a
|
||||
/// screen CIRCLE inscribed in that rect — leaving the four rect
|
||||
/// corners as click dead zones. Per user feedback 2026-05-16, the
|
||||
/// click area must match the visible indicator extent exactly. By
|
||||
/// sharing the <see cref="ScreenProjection"/> helper with
|
||||
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
|
||||
/// cannot drift.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Resolver returning <c>null</c> skips the candidate (matches retail
|
||||
/// "no Setup → not pickable" behavior). Entities with
|
||||
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
|
||||
/// guid are also skipped.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Stage A of the picker port. Stage B (polygon refine via
|
||||
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
|
||||
/// per issue #71 — only needed if visual testing surfaces a Stage A
|
||||
/// over-pick on entities whose visible mesh is well inside the
|
||||
/// indicator rect.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="inflatePixels">Pixel inflate on each side of the
|
||||
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
|
||||
/// so the click area extends to where the visible bracket corners
|
||||
/// sit — the user perceives the inflated rect as the clickable area.</param>
|
||||
public static uint? Pick(
|
||||
float mouseX, float mouseY,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection,
|
||||
Vector2 viewport,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||
float inflatePixels = 8f,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
uint? bestGuid = null;
|
||||
float bestDepth = float.PositiveInfinity;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion.
|
||||
// Build the click ray, query the nearest wall along it, convert
|
||||
// to the same camera-space depth metric (clip.W) that
|
||||
// ScreenProjection.TryProjectSphereToScreenRect returns per
|
||||
// candidate. Candidates with depth > wallDepth sit behind a wall.
|
||||
float wallDepth = float.PositiveInfinity;
|
||||
if (cellOccluder is not null)
|
||||
{
|
||||
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||||
if (rayDir.LengthSquared() > 0f)
|
||||
{
|
||||
float wallT = cellOccluder(rayOrigin, rayDir);
|
||||
if (!float.IsPositiveInfinity(wallT))
|
||||
{
|
||||
var wallPoint = rayOrigin + rayDir * wallT;
|
||||
// ScreenProjection uses clip.W as its depth metric —
|
||||
// "camera-space depth" in the row-vector convention is
|
||||
// the W component of the homogeneous clip-space vector,
|
||||
// which equals the eye-space Z distance to the point.
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj);
|
||||
if (clip.W > 0f)
|
||||
wallDepth = clip.W;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
var sphere = sphereForEntity(entity);
|
||||
if (sphere is null) continue;
|
||||
var (center, radius) = sphere.Value;
|
||||
if (radius <= 0f) continue;
|
||||
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
center, radius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out var depth))
|
||||
continue;
|
||||
|
||||
// Inflate by inflatePixels on each side — extend hit area to
|
||||
// where the indicator brackets sit.
|
||||
float minX = rMin.X - inflatePixels;
|
||||
float minY = rMin.Y - inflatePixels;
|
||||
float maxX = rMax.X + inflatePixels;
|
||||
float maxY = rMax.Y + inflatePixels;
|
||||
|
||||
if (mouseX < minX || mouseX > maxX) continue;
|
||||
if (mouseY < minY || mouseY > maxY) continue;
|
||||
|
||||
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
|
||||
|
||||
if (depth < bestDepth)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,4 +262,14 @@ public enum InputAction
|
|||
/// <summary>Fly-camera descend (Ctrl) — only meaningful while fly camera
|
||||
/// is active. K.1b binds it to ControlLeft; K.1c may rebind.</summary>
|
||||
AcdreamFlyDown,
|
||||
|
||||
// ── AcdreamCameraCommands ─────────────────────────────
|
||||
/// <summary>Camera zoom in (held key, integrates Distance−= adjSpeed·dt). Default unbound.</summary>
|
||||
CameraZoomIn,
|
||||
/// <summary>Camera zoom out (held key, integrates Distance+= adjSpeed·dt). Default unbound.</summary>
|
||||
CameraZoomOut,
|
||||
/// <summary>Camera raise (held key, integrates Pitch+= adjSpeed·dt·0.02). Default unbound.</summary>
|
||||
CameraRaise,
|
||||
/// <summary>Camera lower (held key, integrates Pitch−= adjSpeed·dt·0.02). Default unbound.</summary>
|
||||
CameraLower,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ public sealed class DebugPanel : IPanel
|
|||
}
|
||||
|
||||
DrawPlayerInfo(renderer);
|
||||
DrawChaseCamera(renderer);
|
||||
DrawPerformance(renderer);
|
||||
DrawCompass(renderer);
|
||||
DrawHelp(renderer);
|
||||
|
|
@ -123,6 +124,33 @@ public sealed class DebugPanel : IPanel
|
|||
r.Text($"sens: {_vm.MouseSensitivity:F3}x");
|
||||
}
|
||||
|
||||
private void DrawChaseCamera(IPanelRenderer r)
|
||||
{
|
||||
if (!r.CollapsingHeader("Chase camera", defaultOpen: true)) return;
|
||||
|
||||
bool useRetail = _vm.UseRetailChaseCamera;
|
||||
bool alignSlope = _vm.CameraAlignToSlope;
|
||||
float tStiff = _vm.CameraTranslationStiffness;
|
||||
float rStiff = _vm.CameraRotationStiffness;
|
||||
float lpWindow = _vm.CameraMouseLowPassWindowSec;
|
||||
float adjSpeed = _vm.CameraAdjustmentSpeed;
|
||||
|
||||
if (r.Checkbox("Use retail chase camera (env: ACDREAM_RETAIL_CHASE)", ref useRetail))
|
||||
_vm.UseRetailChaseCamera = useRetail;
|
||||
|
||||
if (r.Checkbox("Align to slope (env: ACDREAM_CAMERA_ALIGN_SLOPE)", ref alignSlope))
|
||||
_vm.CameraAlignToSlope = alignSlope;
|
||||
|
||||
if (r.SliderFloat("Translation stiffness", ref tStiff, 0.05f, 1.0f))
|
||||
_vm.CameraTranslationStiffness = tStiff;
|
||||
if (r.SliderFloat("Rotation stiffness", ref rStiff, 0.05f, 1.0f))
|
||||
_vm.CameraRotationStiffness = rStiff;
|
||||
if (r.SliderFloat("Mouse low-pass window (s)", ref lpWindow, 0.0f, 0.5f))
|
||||
_vm.CameraMouseLowPassWindowSec = lpWindow;
|
||||
if (r.SliderFloat("Adjustment speed (units/s)", ref adjSpeed, 10f, 80f))
|
||||
_vm.CameraAdjustmentSpeed = adjSpeed;
|
||||
}
|
||||
|
||||
private void DrawPerformance(IPanelRenderer r)
|
||||
{
|
||||
if (!r.CollapsingHeader("Performance", defaultOpen: true)) return;
|
||||
|
|
@ -226,6 +254,30 @@ public sealed class DebugPanel : IPanel
|
|||
if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)",
|
||||
ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;
|
||||
|
||||
// ── Indoor rendering diagnostics (2026-05-19) ───────────────
|
||||
// Pinpoint where the EnvCell rendering chain breaks for
|
||||
// hypothesis-driven Phase 2 fix. Spec:
|
||||
// docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
|
||||
r.Separator();
|
||||
r.Text("Indoor rendering (envCell):");
|
||||
|
||||
bool probeIndoorAll = _vm.ProbeIndoorAll;
|
||||
bool probeIndoorWalk = _vm.ProbeIndoorWalk;
|
||||
bool probeIndoorLookup = _vm.ProbeIndoorLookup;
|
||||
bool probeIndoorUpload = _vm.ProbeIndoorUpload;
|
||||
bool probeIndoorXform = _vm.ProbeIndoorXform;
|
||||
bool probeIndoorCull = _vm.ProbeIndoorCull;
|
||||
|
||||
if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll;
|
||||
if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk;
|
||||
if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup;
|
||||
if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload;
|
||||
if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform;
|
||||
if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull;
|
||||
|
||||
bool probeIndoorBsp = _vm.ProbeIndoorBsp;
|
||||
if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp;
|
||||
|
||||
r.Spacing();
|
||||
|
||||
// Cycle / toggle actions live on the VM as Action handles; the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Debug;
|
||||
|
||||
|
|
@ -290,6 +291,130 @@ public sealed class DebugVM
|
|||
set => PhysicsDiagnostics.ProbeAutoWalkEnabled = value;
|
||||
}
|
||||
|
||||
// ── Indoor rendering diagnostics (2026-05-19) ───────────────────
|
||||
// Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles
|
||||
// take effect on the next render frame without relaunching.
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorWalkEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_WALK</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorWalk
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorWalkEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorLookupEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_LOOKUP</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorLookup
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorLookupEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorUploadEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_UPLOAD</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorUpload
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorUploadEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorXformEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_XFORM</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorXform
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorXformEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorXformEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorCullEnabled</c>
|
||||
/// (env var <c>ACDREAM_PROBE_INDOOR_CULL</c>).
|
||||
/// </summary>
|
||||
public bool ProbeIndoorCull
|
||||
{
|
||||
get => RenderingDiagnostics.ProbeIndoorCullEnabled;
|
||||
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). Runtime mirror of
|
||||
/// <c>PhysicsDiagnostics.ProbeIndoorBspEnabled</c> (env var
|
||||
/// <c>ACDREAM_PROBE_INDOOR_BSP</c>). Toggling here flips the
|
||||
/// <c>[indoor-bsp]</c> probe live — no relaunch required.
|
||||
/// Physics-side companion to the five render-side
|
||||
/// <c>ProbeIndoor*</c> mirrors directly above.
|
||||
/// </summary>
|
||||
public bool ProbeIndoorBsp
|
||||
{
|
||||
get => PhysicsDiagnostics.ProbeIndoorBspEnabled;
|
||||
set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
|
||||
/// five indoor probes together. No dedicated env var; set any individual
|
||||
/// probe env var or use <c>ACDREAM_PROBE_INDOOR_ALL</c> to initialize
|
||||
/// all five flags on at startup.
|
||||
/// </summary>
|
||||
public bool ProbeIndoorAll
|
||||
{
|
||||
get => RenderingDiagnostics.IndoorAll;
|
||||
set => RenderingDiagnostics.IndoorAll = value;
|
||||
}
|
||||
|
||||
// ── Chase camera tunables (forward to CameraDiagnostics) ──────────
|
||||
|
||||
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.</summary>
|
||||
public bool UseRetailChaseCamera
|
||||
{
|
||||
get => CameraDiagnostics.UseRetailChaseCamera;
|
||||
set => CameraDiagnostics.UseRetailChaseCamera = value;
|
||||
}
|
||||
|
||||
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.AlignToSlope"/>.</summary>
|
||||
public bool CameraAlignToSlope
|
||||
{
|
||||
get => CameraDiagnostics.AlignToSlope;
|
||||
set => CameraDiagnostics.AlignToSlope = value;
|
||||
}
|
||||
|
||||
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.TranslationStiffness"/>.</summary>
|
||||
public float CameraTranslationStiffness
|
||||
{
|
||||
get => CameraDiagnostics.TranslationStiffness;
|
||||
set => CameraDiagnostics.TranslationStiffness = value;
|
||||
}
|
||||
|
||||
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.RotationStiffness"/>.</summary>
|
||||
public float CameraRotationStiffness
|
||||
{
|
||||
get => CameraDiagnostics.RotationStiffness;
|
||||
set => CameraDiagnostics.RotationStiffness = value;
|
||||
}
|
||||
|
||||
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.MouseLowPassWindowSec"/>.</summary>
|
||||
public float CameraMouseLowPassWindowSec
|
||||
{
|
||||
get => CameraDiagnostics.MouseLowPassWindowSec;
|
||||
set => CameraDiagnostics.MouseLowPassWindowSec = value;
|
||||
}
|
||||
|
||||
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.CameraAdjustmentSpeed"/>.</summary>
|
||||
public float CameraAdjustmentSpeed
|
||||
{
|
||||
get => CameraDiagnostics.CameraAdjustmentSpeed;
|
||||
set => CameraDiagnostics.CameraAdjustmentSpeed = value;
|
||||
}
|
||||
|
||||
// ── Action hooks invoked by panel buttons ──────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
25
tests/AcDream.App.Tests/AcDream.App.Tests.csproj
Normal file
25
tests/AcDream.App.Tests/AcDream.App.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.App\AcDream.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
95
tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
Normal file
95
tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class CameraControllerTests
|
||||
{
|
||||
private static (CameraController ctl, ChaseCamera legacy, RetailChaseCamera retail) MakeChaseFixture()
|
||||
{
|
||||
var orbit = new OrbitCamera();
|
||||
var fly = new FlyCamera();
|
||||
var ctl = new CameraController(orbit, fly);
|
||||
var legacy = new ChaseCamera();
|
||||
var retail = new RetailChaseCamera();
|
||||
ctl.EnterChaseMode(legacy, retail);
|
||||
return (ctl, legacy, retail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChaseMode_WhenFlagOff_ActiveIsLegacy()
|
||||
{
|
||||
bool saved = CameraDiagnostics.UseRetailChaseCamera;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = false;
|
||||
var (ctl, legacy, _) = MakeChaseFixture();
|
||||
Assert.Same(legacy, ctl.Active);
|
||||
Assert.True(ctl.IsChaseMode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = saved;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChaseMode_WhenFlagOn_ActiveIsRetail()
|
||||
{
|
||||
bool saved = CameraDiagnostics.UseRetailChaseCamera;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = true;
|
||||
var (ctl, _, retail) = MakeChaseFixture();
|
||||
Assert.Same(retail, ctl.Active);
|
||||
Assert.True(ctl.IsChaseMode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = saved;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChaseMode_FlagFlipped_ActiveSwaps()
|
||||
{
|
||||
bool saved = CameraDiagnostics.UseRetailChaseCamera;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = false;
|
||||
var (ctl, legacy, retail) = MakeChaseFixture();
|
||||
Assert.Same(legacy, ctl.Active);
|
||||
|
||||
CameraDiagnostics.UseRetailChaseCamera = true;
|
||||
Assert.Same(retail, ctl.Active);
|
||||
|
||||
CameraDiagnostics.UseRetailChaseCamera = false;
|
||||
Assert.Same(legacy, ctl.Active);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = saved;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitChaseMode_ClearsBothCameras()
|
||||
{
|
||||
bool saved = CameraDiagnostics.UseRetailChaseCamera;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = false;
|
||||
var (ctl, _, _) = MakeChaseFixture();
|
||||
ctl.ExitChaseMode();
|
||||
|
||||
Assert.Null(ctl.Chase);
|
||||
Assert.Null(ctl.RetailChase);
|
||||
Assert.False(ctl.IsChaseMode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
448
tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
Normal file
448
tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class RetailChaseCameraTests
|
||||
{
|
||||
// ── Heading source ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Heading_StationaryWithSlopeAlign_FallsBackToYawVector()
|
||||
{
|
||||
var avgVel = Vector3.Zero;
|
||||
float yaw = MathF.PI / 4f; // 45°
|
||||
|
||||
var h = RetailChaseCamera.ComputeHeading(
|
||||
avgVel, yaw,
|
||||
isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
|
||||
alignToSlope: true);
|
||||
|
||||
Assert.Equal(MathF.Cos(yaw), h.X, 5);
|
||||
Assert.Equal(MathF.Sin(yaw), h.Y, 5);
|
||||
Assert.Equal(0f, h.Z, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_MovingOnFlatGround_HeadingIsHorizontalFacing()
|
||||
{
|
||||
// Player moving forward (yaw=0 = +X), on flat ground. Heading
|
||||
// should be the yaw vector — the projection onto (0,0,1)-normal
|
||||
// plane is a no-op since the base is already horizontal.
|
||||
var avgVel = new Vector3(3f, 0f, 0f);
|
||||
var h = RetailChaseCamera.ComputeHeading(
|
||||
avgVel, yaw: 0f,
|
||||
isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
|
||||
alignToSlope: true);
|
||||
Assert.Equal(1f, h.X, 5);
|
||||
Assert.Equal(0f, h.Y, 5);
|
||||
Assert.Equal(0f, h.Z, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_OnUphillSlope_TiltsWithSlope()
|
||||
{
|
||||
// Player facing +Y (yaw=π/2), walking up a slope rising in +Y.
|
||||
// Slope normal tilts back-up: (0, -0.5, 0.866) (30° rise).
|
||||
// Projection of (0,1,0) onto plane perpendicular to (0,-0.5,0.866):
|
||||
// dot = 1*(-0.5) = -0.5
|
||||
// projected = (0,1,0) - (0,-0.5,0.866)*(-0.5) = (0, 0.75, 0.433)
|
||||
// normalized → (0, 0.866, 0.5) — slope-aligned heading with +Z tilt.
|
||||
var avgVel = new Vector3(0f, 3f, 1.5f); // moving up the slope
|
||||
var normal = new Vector3(0f, -0.5f, 0.866f);
|
||||
var h = RetailChaseCamera.ComputeHeading(
|
||||
avgVel, yaw: MathF.PI / 2f,
|
||||
isOnGround: true, contactPlaneNormal: normal,
|
||||
alignToSlope: true);
|
||||
Assert.True(h.Z > 0.4f, $"expected slope-aligned +Z tilt, got Z={h.Z}");
|
||||
Assert.Equal(1f, h.Length(), 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_AirborneJumpingStraightUp_StaysHorizontal()
|
||||
{
|
||||
// Player standing still, then jumps straight up. avgVel.xy is
|
||||
// zero, the horizontal-velocity gate fires → returns the base
|
||||
// facing direction. The vertical-velocity component is ignored.
|
||||
// This is THE bug the contact-plane fix prevents: in the old
|
||||
// code, normalize((0,0,5)) → (0,0,1) → camera basis tilted up.
|
||||
var avgVel = new Vector3(0f, 0f, 5f);
|
||||
var h = RetailChaseCamera.ComputeHeading(
|
||||
avgVel, yaw: 0f,
|
||||
isOnGround: false, contactPlaneNormal: Vector3.Zero,
|
||||
alignToSlope: true);
|
||||
Assert.Equal(1f, h.X, 5);
|
||||
Assert.Equal(0f, h.Y, 5);
|
||||
Assert.Equal(0f, h.Z, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_AirborneRunningJump_StaysHorizontal()
|
||||
{
|
||||
// Running jump: horizontal velocity nonzero, vertical also
|
||||
// nonzero. Airborne path projects onto world up — strips Z
|
||||
// from the (already horizontal) base heading, no-op. Camera
|
||||
// basis stays horizontal even though player is rising.
|
||||
var avgVel = new Vector3(3f, 0f, 4f);
|
||||
var h = RetailChaseCamera.ComputeHeading(
|
||||
avgVel, yaw: 0f,
|
||||
isOnGround: false, contactPlaneNormal: Vector3.Zero,
|
||||
alignToSlope: true);
|
||||
Assert.Equal(1f, h.X, 5);
|
||||
Assert.Equal(0f, h.Y, 5);
|
||||
Assert.Equal(0f, h.Z, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_SlopeAlignDisabled_IgnoresVelocityAndContactPlane()
|
||||
{
|
||||
// Pure-vertical velocity + a tilted contact normal — neither
|
||||
// should affect the heading when alignToSlope is off.
|
||||
var avgVel = new Vector3(0f, 0f, 1f);
|
||||
var tiltedNormal = new Vector3(0f, -0.5f, 0.866f);
|
||||
|
||||
var h = RetailChaseCamera.ComputeHeading(
|
||||
avgVel, yaw: 0f,
|
||||
isOnGround: true, contactPlaneNormal: tiltedNormal,
|
||||
alignToSlope: false);
|
||||
|
||||
Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0)
|
||||
Assert.Equal(0f, h.Y, 5);
|
||||
Assert.Equal(0f, h.Z, 5);
|
||||
}
|
||||
|
||||
// ── Basis from heading ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
|
||||
{
|
||||
var (forward, right, up) = RetailChaseCamera.BuildBasis(new Vector3(1f, 0f, 0f));
|
||||
|
||||
Assert.Equal(1f, forward.Length(), 5);
|
||||
Assert.Equal(1f, right.Length(), 5);
|
||||
Assert.Equal(1f, up.Length(), 5);
|
||||
|
||||
// Orthogonal
|
||||
Assert.Equal(0f, Vector3.Dot(forward, right), 5);
|
||||
Assert.Equal(0f, Vector3.Dot(forward, up), 5);
|
||||
Assert.Equal(0f, Vector3.Dot(right, up), 5);
|
||||
|
||||
// forward = (1,0,0), world up = (0,0,1) → right = (0,-1,0), camera-up = (0,0,1)
|
||||
Assert.Equal(0f, up.X, 5);
|
||||
Assert.Equal(0f, up.Y, 5);
|
||||
Assert.True(up.Z > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Basis_NearVerticalHeading_UsesXFallbackForRight()
|
||||
{
|
||||
// forward nearly straight up (rare; airborne edge case). Must not produce
|
||||
// a zero-length right vector from cross(forward, worldUp).
|
||||
var (_, right, up) = RetailChaseCamera.BuildBasis(new Vector3(0f, 0f, 1f));
|
||||
|
||||
Assert.Equal(1f, right.Length(), 5);
|
||||
Assert.Equal(1f, up.Length(), 5);
|
||||
Assert.Equal(0f, Vector3.Dot(right, up), 5);
|
||||
}
|
||||
|
||||
// ── Velocity ring & averaging ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void VelocityRing_AveragesLastN()
|
||||
{
|
||||
var ring = new Vector3[5];
|
||||
int count = 0;
|
||||
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(3, 0, 0));
|
||||
|
||||
Assert.Equal(5, count);
|
||||
var avg = RetailChaseCamera.AverageVelocity(ring, count);
|
||||
Assert.Equal(1.8f, avg.X, 5);
|
||||
Assert.Equal(0f, avg.Y, 5);
|
||||
Assert.Equal(0f, avg.Z, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VelocityRing_FifoEvictsOldest()
|
||||
{
|
||||
var ring = new Vector3[5];
|
||||
int count = 0;
|
||||
|
||||
// Push 6 entries; oldest (the first 1,0,0) should be evicted.
|
||||
for (int i = 0; i < 5; i++)
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(10, 0, 0));
|
||||
|
||||
Assert.Equal(5, count); // still capped at 5
|
||||
// Sum of newest 5 entries: 4*(1,0,0) + (10,0,0) = (14,0,0), avg = 2.8
|
||||
var avg = RetailChaseCamera.AverageVelocity(ring, count);
|
||||
Assert.Equal(2.8f, avg.X, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VelocityRing_PartialFillUsesActualCount()
|
||||
{
|
||||
var ring = new Vector3[5];
|
||||
int count = 0;
|
||||
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
|
||||
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(4, 0, 0));
|
||||
|
||||
Assert.Equal(2, count);
|
||||
var avg = RetailChaseCamera.AverageVelocity(ring, count);
|
||||
Assert.Equal(3f, avg.X, 5); // (2+4)/2, not (2+4)/5
|
||||
}
|
||||
|
||||
// ── Damping alpha ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DampingAlpha_RetailDefault_ProducesSevenAndAHalfPercent()
|
||||
{
|
||||
// stiffness=0.45, dt=1/60 → 0.45 * (1/60) * 10 ≈ 0.075
|
||||
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f / 60f);
|
||||
Assert.Equal(0.075f, alpha, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DampingAlpha_LargeDtClampsToOne()
|
||||
{
|
||||
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f);
|
||||
Assert.Equal(1f, alpha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DampingAlpha_NegativeOrZero_ClampsToZero()
|
||||
{
|
||||
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 0f));
|
||||
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.0f, dt: 1f));
|
||||
}
|
||||
|
||||
// ── Mouse low-pass ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MouseFilter_BeyondWindow_OutputsRaw()
|
||||
{
|
||||
float lastDelta = 5f;
|
||||
float lastTime = 0f;
|
||||
float windowSec = 0.25f;
|
||||
|
||||
float result = RetailChaseCamera.FilterMouseAxis(
|
||||
raw: 10f, weight: 0.5f, nowSec: 1.0f,
|
||||
ref lastDelta, ref lastTime, windowSec);
|
||||
|
||||
// Beyond window, blended == raw, so out = raw * 0.5 + raw * 0.5 = raw.
|
||||
Assert.Equal(10f, result, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFilter_WithinWindow_AveragesWithPrevious()
|
||||
{
|
||||
float lastDelta = 10f;
|
||||
float lastTime = 0f;
|
||||
float windowSec = 0.25f;
|
||||
|
||||
float result = RetailChaseCamera.FilterMouseAxis(
|
||||
raw: 20f, weight: 0.5f, nowSec: 0.1f,
|
||||
ref lastDelta, ref lastTime, windowSec);
|
||||
|
||||
// Within window: avg = (10 + 20)/2 = 15.
|
||||
// Output: 20 * 0.5 + 15 * 0.5 = 17.5
|
||||
Assert.Equal(17.5f, result, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFilter_WeightZero_OutputsRaw()
|
||||
{
|
||||
float lastDelta = 10f;
|
||||
float lastTime = 0f;
|
||||
float windowSec = 0.25f;
|
||||
|
||||
float result = RetailChaseCamera.FilterMouseAxis(
|
||||
raw: 20f, weight: 0f, nowSec: 0.1f,
|
||||
ref lastDelta, ref lastTime, windowSec);
|
||||
|
||||
Assert.Equal(20f, result, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFilter_WeightOne_OutputsAveraged()
|
||||
{
|
||||
float lastDelta = 10f;
|
||||
float lastTime = 0f;
|
||||
float windowSec = 0.25f;
|
||||
|
||||
float result = RetailChaseCamera.FilterMouseAxis(
|
||||
raw: 20f, weight: 1f, nowSec: 0.1f,
|
||||
ref lastDelta, ref lastTime, windowSec);
|
||||
|
||||
// weight=1 → out = avg = 15
|
||||
Assert.Equal(15f, result, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFilter_UpdatesLastDeltaAndTime()
|
||||
{
|
||||
float lastDelta = 10f;
|
||||
float lastTime = 0f;
|
||||
float windowSec = 0.25f;
|
||||
|
||||
float result = RetailChaseCamera.FilterMouseAxis(
|
||||
raw: 20f, weight: 0.5f, nowSec: 0.1f,
|
||||
ref lastDelta, ref lastTime, windowSec);
|
||||
|
||||
Assert.Equal(result, lastDelta); // last is updated to output
|
||||
Assert.Equal(0.1f, lastTime, 5); // last time advances
|
||||
}
|
||||
|
||||
// ── Auto-fade translucency ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Translucency_AtFarThreshold_IsZero()
|
||||
{
|
||||
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 0.45f), 5);
|
||||
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 1.00f), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Translucency_MidwayBetweenThresholds_IsHalf()
|
||||
{
|
||||
// Midpoint between 0.20 and 0.45 = 0.325
|
||||
// t = 1 - (0.20 - 0.325) / (0.20 - 0.45)
|
||||
// = 1 - (-0.125) / (-0.25)
|
||||
// = 1 - 0.5 = 0.5
|
||||
Assert.Equal(0.5f, RetailChaseCamera.ComputeTranslucency(distance: 0.325f), 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Translucency_AtNearThreshold_IsOne()
|
||||
{
|
||||
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.20f), 5);
|
||||
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
|
||||
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
|
||||
}
|
||||
|
||||
// ── Update() integration ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FirstUpdate_SnapsToTarget()
|
||||
{
|
||||
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
|
||||
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
|
||||
|
||||
cam.Update(
|
||||
playerPosition: new Vector3(10f, 20f, 30f),
|
||||
playerYaw: 0f, // forward = +X
|
||||
playerVelocity: Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: Vector3.UnitZ, // flat
|
||||
dt: 1f / 60f);
|
||||
|
||||
// Expected target eye:
|
||||
// pivot = (10, 20, 30+1.5=31.5)
|
||||
// forward (yaw=0)= (1, 0, 0)
|
||||
// right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0)
|
||||
// up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1)
|
||||
// viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0)
|
||||
// eye = pivot + right*0 + forward*-5 + up*0
|
||||
// = (10 - 5, 20, 31.5) = (5, 20, 31.5)
|
||||
Assert.Equal(5f, cam.Position.X, 4);
|
||||
Assert.Equal(20f, cam.Position.Y, 4);
|
||||
Assert.Equal(31.5f, cam.Position.Z, 4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondUpdate_LerpsTowardTarget()
|
||||
{
|
||||
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||
float savedTranslation = CameraDiagnostics.TranslationStiffness;
|
||||
float savedRotation = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
|
||||
CameraDiagnostics.AlignToSlope = false;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
// First update at origin: dampedEye = (-5, 0, 1.5).
|
||||
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
|
||||
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
|
||||
var firstEye = cam.Position;
|
||||
|
||||
// Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5).
|
||||
// alpha = 0.45 * (1/60) * 10 = 0.075.
|
||||
// New eye = firstEye + 0.075 * (target - firstEye)
|
||||
// = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5))
|
||||
// = (-5,0,1.5) + 0.075 * (10,0,0)
|
||||
// = (-4.25, 0, 1.5)
|
||||
cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero,
|
||||
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
|
||||
|
||||
Assert.Equal(-4.25f, cam.Position.X, 3);
|
||||
Assert.Equal(0f, cam.Position.Y, 4);
|
||||
Assert.Equal(1.5f, cam.Position.Z, 4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||
CameraDiagnostics.TranslationStiffness = savedTranslation;
|
||||
CameraDiagnostics.RotationStiffness = savedRotation;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Translucency_PropertyReflectsCurrentDampedDistance()
|
||||
{
|
||||
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
|
||||
CameraDiagnostics.AlignToSlope = false;
|
||||
|
||||
// Far from pivot — translucency should be 0.
|
||||
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
|
||||
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
|
||||
Assert.Equal(0f, cam.PlayerTranslucency, 5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustDistance_ClampsToRange()
|
||||
{
|
||||
var cam = new RetailChaseCamera { Distance = 5f };
|
||||
cam.AdjustDistance(-100f);
|
||||
Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance);
|
||||
|
||||
cam.AdjustDistance(+200f);
|
||||
Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustPitch_ClampsToRange()
|
||||
{
|
||||
var cam = new RetailChaseCamera { Pitch = 0f };
|
||||
cam.AdjustPitch(-10f);
|
||||
Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch);
|
||||
|
||||
cam.AdjustPitch(+10f);
|
||||
Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
|
||||
}
|
||||
}
|
||||
217
tests/AcDream.App.Tests/RuntimeOptionsTests.cs
Normal file
217
tests/AcDream.App.Tests/RuntimeOptionsTests.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App;
|
||||
|
||||
namespace AcDream.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RuntimeOptions"/> startup-time parsing.
|
||||
/// Behavior-preservation only: every assertion locks in the exact
|
||||
/// boolean / numeric / nullability semantics from the env reads that
|
||||
/// previously lived in <c>GameWindow.cs</c>.
|
||||
/// </summary>
|
||||
public sealed class RuntimeOptionsTests
|
||||
{
|
||||
private const string AnyDatDir = "C:/Users/test/dats";
|
||||
|
||||
private static Func<string, string?> Env(Dictionary<string, string?> values)
|
||||
=> name => values.TryGetValue(name, out var v) ? v : null;
|
||||
|
||||
private static Func<string, string?> EmptyEnv() => _ => null;
|
||||
|
||||
[Fact]
|
||||
public void Defaults_AllSafeOff_WhenEnvironmentIsEmpty()
|
||||
{
|
||||
var opts = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
|
||||
|
||||
Assert.Equal(AnyDatDir, opts.DatDir);
|
||||
Assert.False(opts.LiveMode);
|
||||
Assert.Equal("127.0.0.1", opts.LiveHost);
|
||||
Assert.Equal(9000, opts.LivePort);
|
||||
Assert.Null(opts.LiveUser);
|
||||
Assert.Null(opts.LivePass);
|
||||
Assert.False(opts.DevTools);
|
||||
Assert.False(opts.DumpMoveTruth);
|
||||
Assert.False(opts.NoAudio);
|
||||
Assert.False(opts.EnableSkyPesDebug);
|
||||
Assert.Equal(-1, opts.HidePartIndex);
|
||||
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
|
||||
Assert.True(opts.RetailCloseDegrades);
|
||||
Assert.False(opts.DumpSceneryZ);
|
||||
Assert.Null(opts.LegacyStreamRadius);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveMode_Set_ExactlyByValue1()
|
||||
{
|
||||
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "1" })).LiveMode);
|
||||
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "0" })).LiveMode);
|
||||
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "true" })).LiveMode);
|
||||
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_LIVE"] = "" })).LiveMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveHostAndPort_FallBackToDefaults_WhenUnsetOrInvalid()
|
||||
{
|
||||
var withDefaults = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
|
||||
Assert.Equal("127.0.0.1", withDefaults.LiveHost);
|
||||
Assert.Equal(9000, withDefaults.LivePort);
|
||||
|
||||
var withOverrides = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_TEST_HOST"] = "play.example.com",
|
||||
["ACDREAM_TEST_PORT"] = "9123",
|
||||
}));
|
||||
Assert.Equal("play.example.com", withOverrides.LiveHost);
|
||||
Assert.Equal(9123, withOverrides.LivePort);
|
||||
|
||||
// Non-numeric port falls back to default; we don't throw at parse time.
|
||||
var withBadPort = RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_TEST_PORT"] = "abc" }));
|
||||
Assert.Equal(9000, withBadPort.LivePort);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveUserPass_NullWhenEmptyOrUnset()
|
||||
{
|
||||
var emptyValues = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_TEST_USER"] = "",
|
||||
["ACDREAM_TEST_PASS"] = "",
|
||||
}));
|
||||
Assert.Null(emptyValues.LiveUser);
|
||||
Assert.Null(emptyValues.LivePass);
|
||||
|
||||
var realValues = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_TEST_USER"] = "testaccount",
|
||||
["ACDREAM_TEST_PASS"] = "testpassword",
|
||||
}));
|
||||
Assert.Equal("testaccount", realValues.LiveUser);
|
||||
Assert.Equal("testpassword", realValues.LivePass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass()
|
||||
{
|
||||
// Live mode off → no credentials regardless of user/pass.
|
||||
var noLive = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_TEST_USER"] = "u",
|
||||
["ACDREAM_TEST_PASS"] = "p",
|
||||
}));
|
||||
Assert.False(noLive.HasLiveCredentials);
|
||||
|
||||
// Live mode on but missing user → no credentials.
|
||||
var missingUser = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_LIVE"] = "1",
|
||||
["ACDREAM_TEST_PASS"] = "p",
|
||||
}));
|
||||
Assert.False(missingUser.HasLiveCredentials);
|
||||
|
||||
// Live mode on but missing pass → no credentials.
|
||||
var missingPass = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_LIVE"] = "1",
|
||||
["ACDREAM_TEST_USER"] = "u",
|
||||
}));
|
||||
Assert.False(missingPass.HasLiveCredentials);
|
||||
|
||||
// All three present → credentials available.
|
||||
var ok = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_LIVE"] = "1",
|
||||
["ACDREAM_TEST_USER"] = "u",
|
||||
["ACDREAM_TEST_PASS"] = "p",
|
||||
}));
|
||||
Assert.True(ok.HasLiveCredentials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HidePartIndex_MinusOneWhenUnset_ParsesIntegers()
|
||||
{
|
||||
Assert.Equal(-1, RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).HidePartIndex);
|
||||
Assert.Equal(7, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_HIDE_PART"] = "7" })).HidePartIndex);
|
||||
// Invalid → fall back to -1 (preserves the int.TryParse failure semantics).
|
||||
Assert.Equal(-1, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_HIDE_PART"] = "abc" })).HidePartIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailCloseDegrades_DefaultOn_ExceptWhenValueIsExactlyZero()
|
||||
{
|
||||
// Unset → on.
|
||||
Assert.True(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RetailCloseDegrades);
|
||||
|
||||
// Exactly "0" → off. Matches the pre-refactor semantics:
|
||||
// !string.Equals(env, "0", StringComparison.Ordinal)
|
||||
Assert.False(RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "0",
|
||||
})).RetailCloseDegrades);
|
||||
|
||||
// Any other value → on (including "1", "false", "True"). The original
|
||||
// code only checked for the literal "0"; preserve that.
|
||||
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "1",
|
||||
})).RetailCloseDegrades);
|
||||
Assert.True(RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_RETAIL_CLOSE_DEGRADES"] = "false",
|
||||
})).RetailCloseDegrades);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyStreamRadius_NullWhenUnsetOrInvalid_ParsesNonNegativeIntegers()
|
||||
{
|
||||
Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).LegacyStreamRadius);
|
||||
Assert.Null(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "abc" })).LegacyStreamRadius);
|
||||
// Negative values are filtered out by the pre-refactor `sr >= 0` guard.
|
||||
Assert.Null(RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "-3" })).LegacyStreamRadius);
|
||||
|
||||
Assert.Equal(0, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "0" })).LegacyStreamRadius);
|
||||
Assert.Equal(5, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "5" })).LegacyStreamRadius);
|
||||
Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticFlags_RespectExactValueOne()
|
||||
{
|
||||
var allOn = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_DEVTOOLS"] = "1",
|
||||
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
|
||||
["ACDREAM_NO_AUDIO"] = "1",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "1",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = "1",
|
||||
}));
|
||||
Assert.True(allOn.DevTools);
|
||||
Assert.True(allOn.DumpMoveTruth);
|
||||
Assert.True(allOn.NoAudio);
|
||||
Assert.True(allOn.EnableSkyPesDebug);
|
||||
Assert.True(allOn.DumpSceneryZ);
|
||||
|
||||
// Any non-"1" value leaves them off, matching the
|
||||
// string.Equals(env, "1", StringComparison.Ordinal) check.
|
||||
var anyOther = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_DEVTOOLS"] = "true",
|
||||
["ACDREAM_DUMP_MOVE_TRUTH"] = "yes",
|
||||
["ACDREAM_NO_AUDIO"] = "2",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "on",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
|
||||
}));
|
||||
Assert.False(anyOther.DevTools);
|
||||
Assert.False(anyOther.DumpMoveTruth);
|
||||
Assert.False(anyOther.NoAudio);
|
||||
Assert.False(anyOther.EnableSkyPesDebug);
|
||||
Assert.False(anyOther.DumpSceneryZ);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RejectsNullDatDirOrEnv()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(null!, EmptyEnv()));
|
||||
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(AnyDatDir, null!));
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +371,124 @@ public sealed class AnimationSequencerTests
|
|||
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_LinkTailDoesNotBlendIntoLinkFrame0()
|
||||
{
|
||||
// Issue #61 regression: the fractional tail of a non-looping LINK
|
||||
// (the ~30 ms between the last integer frame and the wrap boundary)
|
||||
// must hold the link's END pose instead of blending into the link's
|
||||
// FRAME 0 pose. Old behaviour: BuildBlendedFrame wrapped nextIdx to
|
||||
// rangeLo unconditionally, producing a one-frame flash through the
|
||||
// link's starting pose at the link→cycle boundary. Symptoms: door
|
||||
// swing-open flap (frame 0 = closed); player run-stop twitch
|
||||
// (frame 0 = mid-stride).
|
||||
const uint Style = 0x003Du;
|
||||
const uint IdleMotion = 0x0003u;
|
||||
const uint WalkMotion = 0x0005u;
|
||||
const uint CycleAnim = 0x03000080u;
|
||||
const uint LinkAnim = 0x03000081u;
|
||||
|
||||
// Link anim: 3 frames, distinct Y so we can tell which frame is being
|
||||
// sampled. Frame 0 Y=10 (link's starting pose — e.g. closed door),
|
||||
// frame 2 Y=0 (link's end pose — e.g. open door).
|
||||
var linkAnim = new Animation();
|
||||
for (int f = 0; f < 3; f++)
|
||||
{
|
||||
var pf = new AnimationFrame(1);
|
||||
float y = 10f - 5f * f; // 10, 5, 0
|
||||
pf.Frames.Add(new Frame { Origin = new Vector3(0, y, 0), Orientation = Quaternion.Identity });
|
||||
linkAnim.PartFrames.Add(pf);
|
||||
}
|
||||
|
||||
// Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose).
|
||||
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
|
||||
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = Fixtures.MakeMtable(
|
||||
style: Style,
|
||||
motion: WalkMotion,
|
||||
cycleAnimId: CycleAnim,
|
||||
fromMotion: IdleMotion,
|
||||
toMotion: WalkMotion,
|
||||
linkAnimId: LinkAnim,
|
||||
framerate: 30f);
|
||||
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(CycleAnim, cycleAnim);
|
||||
loader.Register(LinkAnim, linkAnim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
SetCurrentMotion(seq, Style, IdleMotion);
|
||||
seq.SetCycle(Style, WalkMotion);
|
||||
|
||||
// Advance to _framePosition ≈ 2.5 — past the last integer frame (2)
|
||||
// but before maxBoundary - epsilon (≈ 3). At 30 fps, 2.5/30 = 0.0833s.
|
||||
seq.Advance(0.0833f);
|
||||
double pos = GetFramePosition(seq);
|
||||
Assert.InRange(pos, 2.4, 2.7);
|
||||
|
||||
var transforms = seq.Advance(0.0001f); // tiny extra dt to trigger blend read
|
||||
|
||||
// Pre-fix: nextIdx would wrap to rangeLo (0), so transforms[0].Origin.Y
|
||||
// would land near 0.5 × 0 + 0.5 × 10 = 5 (mid-blend with link frame 0).
|
||||
// Post-fix: nextIdx = frameIdx (2), so transforms[0].Origin.Y = 0 (held).
|
||||
Assert.True(transforms[0].Origin.Y < 1f,
|
||||
$"Link tail should hold last-frame pose Y=0; got Y={transforms[0].Origin.Y} "
|
||||
+ "(would be ~5 if nextIdx still wrapped to link frame 0)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink()
|
||||
{
|
||||
// Stop-anim asymmetry: the Humanoid motion table only authors a
|
||||
// "stop walking" link under WalkForward (low byte 0x05). Stopping
|
||||
// from WalkBackward (0x06) without a fallback returns null linkData
|
||||
// and the cycle snaps to Ready with no settle blend. Fix: when the
|
||||
// primary GetLink lookup fails, retry with WalkBackward's low byte
|
||||
// remapped to WalkForward.
|
||||
const uint Style = 0x003Du;
|
||||
const uint WalkForwardCmd = 0x0005u;
|
||||
const uint WalkBackCmd = 0x0006u;
|
||||
const uint ReadyCmd = 0x0003u;
|
||||
const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0)
|
||||
const uint LinkAnim = 0x03000091u; // stop-link (Y=7)
|
||||
|
||||
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
|
||||
var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity);
|
||||
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
// Table: Ready cycle + WalkForward→Ready link. NO WalkBackward→Ready link.
|
||||
var mt = Fixtures.MakeMtable(
|
||||
style: Style,
|
||||
motion: ReadyCmd,
|
||||
cycleAnimId: CycleAnim,
|
||||
fromMotion: WalkForwardCmd,
|
||||
toMotion: ReadyCmd,
|
||||
linkAnimId: LinkAnim,
|
||||
framerate: 30f);
|
||||
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(CycleAnim, cycleAnim);
|
||||
loader.Register(LinkAnim, linkAnim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
// Simulate "we were walking backward" — substate = WalkBackward,
|
||||
// substateSpeed = +1 (the original speedMod stored by SetCycle).
|
||||
SetCurrentMotion(seq, Style, WalkBackCmd);
|
||||
|
||||
seq.SetCycle(Style, ReadyCmd);
|
||||
|
||||
// Advance a tiny dt — should land on link frame 0 (Y=7), not the
|
||||
// cycle (Y=0). Without the fallback, linkData is null, only the
|
||||
// Ready cycle is enqueued, and we read Y=0 immediately.
|
||||
var transforms = seq.Advance(0.001f);
|
||||
Assert.Single(transforms);
|
||||
Assert.True(transforms[0].Origin.Y > 5f,
|
||||
$"Stop-from-backward should fall back to WalkForward→Ready link "
|
||||
+ $"(expect Y≈7 from link); got Y={transforms[0].Origin.Y} "
|
||||
+ "(Y=0 means linkData was null and we snapped to Ready cycle).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_NoLinkInTable_DirectCycleSwitch()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -204,4 +204,218 @@ public class BSPQueryTests
|
|||
Assert.Null(cache.GetGfxObj(0x01000001u));
|
||||
Assert.Null(cache.GetSetup(0x02000001u));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FindWalkableSphere — indoor walkable-plane finder (spec 2026-05-19)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Build a single-leaf BSP rooted at one node containing two horizontal
|
||||
/// walkable polygons (id 0 at Z=lowerZ, id 1 at Z=upperZ), both covering
|
||||
/// the unit square X[0..1] × Y[0..1]. Bounding sphere is sized to enclose
|
||||
/// both polys.
|
||||
/// </summary>
|
||||
private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved)
|
||||
BuildTwoFloorsBsp(float lowerZ, float upperZ)
|
||||
{
|
||||
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
|
||||
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
|
||||
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
|
||||
|
||||
var root = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||
};
|
||||
root.Polygons.Add(0);
|
||||
root.Polygons.Add(1);
|
||||
|
||||
Vector3[] lowerVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, lowerZ),
|
||||
new Vector3(1f, 0f, lowerZ),
|
||||
new Vector3(1f, 1f, lowerZ),
|
||||
new Vector3(0f, 1f, lowerZ),
|
||||
};
|
||||
Vector3[] upperVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, upperZ),
|
||||
new Vector3(1f, 0f, upperZ),
|
||||
new Vector3(1f, 1f, upperZ),
|
||||
new Vector3(0f, 1f, upperZ),
|
||||
};
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[0] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = lowerVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, -lowerZ),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
[1] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = upperVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, -upperZ),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
return (root, resolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a Transition with WalkableAllowance set to FloorZ — what the
|
||||
/// indoor walkable-plane synthesis uses.
|
||||
/// </summary>
|
||||
private static Transition BuildFloorZTransition()
|
||||
{
|
||||
var transition = new Transition();
|
||||
transition.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
transition.SpherePath.WalkInterp = 1.0f;
|
||||
return transition;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindWalkableSphere_TwoFloors_FootBetween_PicksLowerFloor()
|
||||
{
|
||||
// Two floors at Z=0 and Z=3. Foot sphere center at Z=0.4 (radius 0.48).
|
||||
// The sphere overlaps the lower floor (|center.Z - 0| = 0.4 < radius 0.48),
|
||||
// so find_walkable can resolve a rest position against it.
|
||||
// Upper floor at Z=3 is out of range (dist=2.6 >> radius 0.48).
|
||||
// Expect: pick lower floor (id 0).
|
||||
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
|
||||
var transition = BuildFloorZTransition();
|
||||
|
||||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0.4f), Radius = 0.48f };
|
||||
|
||||
bool found = BSPQuery.FindWalkableSphere(
|
||||
root, resolved, transition,
|
||||
sphere,
|
||||
probeDistance: 0.5f,
|
||||
up: Vector3.UnitZ,
|
||||
out var hitPoly,
|
||||
out var hitPolyId,
|
||||
out var adjustedCenter);
|
||||
|
||||
Assert.True(found);
|
||||
Assert.Equal((ushort)0, hitPolyId);
|
||||
Assert.NotNull(hitPoly);
|
||||
Assert.Equal(1f, hitPoly!.Plane.Normal.Z, precision: 3); // horizontal floor: normal.Z = 1
|
||||
// AdjustSphereToPlane moves the sphere onto the plane along the movement
|
||||
// vector. For sphere at Z=0.4, radius 0.48, downward movement -0.5, plane
|
||||
// at Z=0: iDist = (0.4-0.48)/-0.5 = 0.16; new center.Z = 0.4 - (-0.5)*0.16 = 0.48.
|
||||
Assert.Equal(0.48f, adjustedCenter.Z, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindWalkableSphere_OnlyUpperFloor_FootAbove_PicksUpperFloor()
|
||||
{
|
||||
// Two floors at Z=0 and Z=3. Foot sphere center at Z=3.4 (radius 0.48).
|
||||
// The sphere overlaps the upper floor (|center.Z - 3| = 0.4 < radius 0.48).
|
||||
// Lower floor at Z=0 is out of range (dist=3.4 >> radius 0.48).
|
||||
// Expect: pick the upper floor (id 1).
|
||||
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
|
||||
var transition = BuildFloorZTransition();
|
||||
|
||||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 3.4f), Radius = 0.48f };
|
||||
|
||||
bool found = BSPQuery.FindWalkableSphere(
|
||||
root, resolved, transition,
|
||||
sphere,
|
||||
probeDistance: 0.5f,
|
||||
up: Vector3.UnitZ,
|
||||
out var hitPoly,
|
||||
out var hitPolyId,
|
||||
out var adjustedCenter);
|
||||
|
||||
Assert.True(found);
|
||||
Assert.Equal((ushort)1, hitPolyId);
|
||||
Assert.NotNull(hitPoly);
|
||||
Assert.Equal(1f, hitPoly!.Plane.Normal.Z, precision: 3); // horizontal upper floor
|
||||
// Same math as Test 1 but offset by 3: adjustedCenter.Z = 3.0 + 0.48 = 3.48.
|
||||
Assert.Equal(3.48f, adjustedCenter.Z, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindWalkableSphere_NoWalkableInProbeRange_ReturnsFalse()
|
||||
{
|
||||
// Two floors at Z=0 and Z=3. Foot at Z=10 with radius 0.48 — out of
|
||||
// sphere-overlap range for both polygons (|10-0|=10 >> 0.48, |10-3|=7 >> 0.48).
|
||||
// find_walkable requires the sphere to overlap the polygon plane; neither
|
||||
// floor is within overlap range, so no hit is found.
|
||||
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
|
||||
var transition = BuildFloorZTransition();
|
||||
|
||||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 10f), Radius = 0.48f };
|
||||
|
||||
bool found = BSPQuery.FindWalkableSphere(
|
||||
root, resolved, transition,
|
||||
sphere,
|
||||
probeDistance: 0.5f,
|
||||
up: Vector3.UnitZ,
|
||||
out var hitPoly,
|
||||
out var hitPolyId,
|
||||
out var adjustedCenter);
|
||||
|
||||
Assert.False(found);
|
||||
Assert.Null(hitPoly);
|
||||
Assert.Equal((ushort)0, hitPolyId);
|
||||
Assert.Equal(sphere.Origin, adjustedCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindWalkableSphere_SteepPoly_RejectedByWalkableAllowance()
|
||||
{
|
||||
// One polygon with a steep normal (Z component ≈ 0.5 < FloorZ ≈ 0.6642).
|
||||
// WalkableHitsSphere checks dp = dot(up, normal) > WalkableAllowance;
|
||||
// dp = 0.5 <= FloorZ → not walkable. Sphere overlaps the plane so
|
||||
// PolygonHitsSpherePrecise would pass, but the walkability gate fires first.
|
||||
var center = new Vector3(0.5f, 0.5f, 0f);
|
||||
var root = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = center, Radius = 2f },
|
||||
};
|
||||
root.Polygons.Add(0);
|
||||
|
||||
// Plane tilted: normal has Z = 0.5 (60° slope). Build from two orthogonal verts.
|
||||
var steepNormal = Vector3.Normalize(new Vector3(0f, MathF.Sqrt(0.75f), 0.5f));
|
||||
// Vertices lying on the plane through the origin.
|
||||
float rise = MathF.Sqrt(0.75f) / 0.5f; // how much Y-displacement equals 1 unit Z-rise
|
||||
Vector3[] verts =
|
||||
{
|
||||
new Vector3(0f, 0f, 0f),
|
||||
new Vector3(1f, 0f, 0f),
|
||||
new Vector3(1f, 1f, rise),
|
||||
new Vector3(0f, 1f, rise),
|
||||
};
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[0] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new Plane(steepNormal, -Vector3.Dot(steepNormal, verts[0])),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
var transition = BuildFloorZTransition();
|
||||
// Sphere overlapping the tilted plane at the origin side.
|
||||
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0.3f), Radius = 0.48f };
|
||||
|
||||
bool found = BSPQuery.FindWalkableSphere(
|
||||
root, resolved, transition,
|
||||
sphere,
|
||||
probeDistance: 0.5f,
|
||||
up: Vector3.UnitZ,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellPhysicsPortalWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void NewFields_HaveSensibleDefaults()
|
||||
{
|
||||
var cp = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
|
||||
Assert.Null(cp.CellBSP);
|
||||
Assert.Empty(cp.Portals);
|
||||
Assert.Null(cp.PortalPolygons);
|
||||
Assert.Empty(cp.VisibleCellIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFields_AcceptInitValues()
|
||||
{
|
||||
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
|
||||
|
||||
var cp = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
|
||||
Portals = new[] { portal },
|
||||
VisibleCellIds = new System.Collections.Generic.HashSet<uint> { 0xA9B40101 },
|
||||
};
|
||||
|
||||
Assert.Single(cp.Portals);
|
||||
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
|
||||
Assert.Contains(0xA9B40101u, cp.VisibleCellIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellPhysics_PortalsRoundTrip()
|
||||
{
|
||||
var portals = new[]
|
||||
{
|
||||
new PortalInfo(otherCellId: 0x0101, polygonId: 7, flags: 0),
|
||||
new PortalInfo(otherCellId: 0xFFFF, polygonId: 8, flags: 2),
|
||||
};
|
||||
|
||||
var cp = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
|
||||
Portals = portals,
|
||||
};
|
||||
|
||||
Assert.Equal(2, cp.Portals.Count);
|
||||
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
|
||||
Assert.True(cp.Portals[0].PortalSide);
|
||||
Assert.Equal((ushort)0xFFFF, cp.Portals[1].OtherCellId);
|
||||
Assert.False(cp.Portals[1].PortalSide);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellTransitAddAllOutsideCellsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SphereWellInsideCell_AddsOneCell()
|
||||
{
|
||||
// Player at world (12, 12, 0) in landblock 0xA9B40000 → cell (0,0).
|
||||
// Landblock origin: 0xA9 = 169 → world X = 169*192 = 32448.
|
||||
// 0xB4 = 180 → world Y = 180*192 = 34560.
|
||||
// Player needs to be in cell (0,0) RELATIVE to landblock origin:
|
||||
// world X = 32448 + 12 = 32460
|
||||
// world Y = 34560 + 12 = 34572
|
||||
var candidates = new HashSet<uint>();
|
||||
CellTransit.AddAllOutsideCells(
|
||||
worldSphereCenter: new Vector3(32460f, 34572f, 0f),
|
||||
sphereRadius: 0.5f,
|
||||
currentCellId: 0xA9B40001u,
|
||||
candidates);
|
||||
|
||||
Assert.Single(candidates);
|
||||
Assert.Contains(0xA9B40001u, candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SphereAtCellEastBoundary_AddsTwoCells()
|
||||
{
|
||||
// Player at world (32448 + 23.6, 34560 + 12, 0) — near +X edge of cell (0,0).
|
||||
// Sphere reach to localX = 23.6 + 0.5 = 24.1 → cell (1,0) added.
|
||||
var candidates = new HashSet<uint>();
|
||||
CellTransit.AddAllOutsideCells(
|
||||
worldSphereCenter: new Vector3(32448f + 23.6f, 34560f + 12f, 0f),
|
||||
sphereRadius: 0.5f,
|
||||
currentCellId: 0xA9B40001u,
|
||||
candidates);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
Assert.Contains(0xA9B40001u, candidates);
|
||||
// Cell (1,0): low-16 id = 1 * 8 + 0 + 1 = 9 → 0x0009.
|
||||
Assert.Contains(0xA9B40009u, candidates);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellTransitCheckBuildingTransitTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildingPortalWithUnloadedCellBSP_NoCandidateAdded()
|
||||
{
|
||||
// Verifies the null-CellBSP guard: when the destination interior cell
|
||||
// is cached but its CellBSP isn't yet loaded (or is structurally absent),
|
||||
// CheckBuildingTransit must NOT add the cell to candidates — even though
|
||||
// PointInsideCellBsp(null, _) returns true.
|
||||
//
|
||||
// Happy-path (CellBSP present, sphere inside) requires a synthetic
|
||||
// CellBSPTree which is non-trivial to construct from DatReaderWriter
|
||||
// types. Deferred to visual verification.
|
||||
|
||||
// Building at world origin. One portal to interior cell 0xA9B40100.
|
||||
var building = new BuildingPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new[]
|
||||
{
|
||||
new BldPortalInfo(
|
||||
otherCellId: 0xA9B40100u,
|
||||
otherPortalId: 0,
|
||||
flags: 0),
|
||||
},
|
||||
};
|
||||
|
||||
// Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true,
|
||||
// but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this
|
||||
// cell is skipped.
|
||||
var interiorCell = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
|
||||
|
||||
var candidates = new HashSet<uint>();
|
||||
CellTransit.CheckBuildingTransit(
|
||||
cache, building,
|
||||
worldSphereCenter: new Vector3(0, 0, 0),
|
||||
sphereRadius: 0.5f,
|
||||
candidates);
|
||||
|
||||
// CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null)
|
||||
// skips this cell. No candidate added.
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
|
||||
// A second test that uses a synthetic CellBSP whose Root.Type == BSPNodeType.Leaf
|
||||
// (which PointInsideCellBsp short-circuits as "inside") would verify the
|
||||
// happy path. Constructing a CellBSPTree by hand from DatReaderWriter
|
||||
// types is awkward; deferred to integration testing at visual-verify time.
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellTransitFindCellListTests
|
||||
{
|
||||
[Fact]
|
||||
public void IndoorSeed_NoCacheEntry_ReturnsFallback()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
// Indoor seed but cell not cached → FindCellList early-returns the fallback.
|
||||
uint result = CellTransit.FindCellList(
|
||||
cache,
|
||||
worldSphereCenter: Vector3.Zero,
|
||||
sphereRadius: 0.5f,
|
||||
currentCellId: 0xA9B40100u);
|
||||
|
||||
Assert.Equal(0xA9B40100u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorSeed_Returns_FallbackWhenNoCellBSPs()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
// Outdoor seed: AddAllOutsideCells adds landcell candidates, but they
|
||||
// have no CellPhysics (only EnvCells get cached) → containment loop
|
||||
// finds no winner → fall back.
|
||||
uint result = CellTransit.FindCellList(
|
||||
cache,
|
||||
worldSphereCenter: new Vector3(12f, 12f, 0f),
|
||||
sphereRadius: 0.5f,
|
||||
currentCellId: 0xA9B40001u);
|
||||
|
||||
Assert.Equal(0xA9B40001u, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class CellTransitFindTransitCellsSphereTests
|
||||
{
|
||||
private static CellPhysics MakeCellWithPortalAtRightWall(
|
||||
Matrix4x4 worldTransform, uint otherCellId, ushort flags)
|
||||
{
|
||||
// Portal poly at local x=2.5 (right wall), normal +X.
|
||||
var portalPolyA = new ResolvedPolygon
|
||||
{
|
||||
Vertices = new[]
|
||||
{
|
||||
new Vector3(2.5f, -2.5f, 0f),
|
||||
new Vector3(2.5f, 2.5f, 0f),
|
||||
new Vector3(2.5f, 2.5f, 5f),
|
||||
new Vector3(2.5f, -2.5f, 5f),
|
||||
},
|
||||
Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5
|
||||
NumPoints = 4,
|
||||
SidesType = DatReaderWriter.Enums.CullMode.None,
|
||||
};
|
||||
|
||||
Matrix4x4.Invert(worldTransform, out var inv);
|
||||
return new CellPhysics
|
||||
{
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inv,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [10] = portalPolyA },
|
||||
Portals = new[]
|
||||
{
|
||||
new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SphereInsideCellA_NearPortal_AddsCellB()
|
||||
{
|
||||
var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
|
||||
|
||||
var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
|
||||
Matrix4x4.Invert(cellBT, out var cellBInv);
|
||||
var cellB = new CellPhysics
|
||||
{
|
||||
WorldTransform = cellBT,
|
||||
InverseWorldTransform = cellBInv,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
|
||||
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
|
||||
|
||||
// Sphere center near portal (local x=2.0, radius=0.5 → reaches x=2.5 = portal plane).
|
||||
var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
|
||||
|
||||
var candidates = new HashSet<uint>();
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, cellA, currentCellId: 0xA9B40100u,
|
||||
worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside);
|
||||
|
||||
Assert.Contains(0xA9B40101u, candidates);
|
||||
Assert.False(exitOutside);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SphereInsideCellA_FarFromPortal_DoesNotAddCellB()
|
||||
{
|
||||
var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
|
||||
|
||||
// Sphere far from portal (local x=-1.0, reach to x=-0.5 — nowhere near portal at x=2.5).
|
||||
var worldSphereCenter = new Vector3(-1.0f, 0f, 2.5f);
|
||||
|
||||
var candidates = new HashSet<uint>();
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, cellA, currentCellId: 0xA9B40100u,
|
||||
worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside);
|
||||
|
||||
Assert.DoesNotContain(0xA9B40101u, candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside()
|
||||
{
|
||||
var exitCell = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0);
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.RegisterCellStructForTest(0xA9B40100u, exitCell);
|
||||
|
||||
var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
|
||||
var candidates = new HashSet<uint>();
|
||||
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, exitCell, currentCellId: 0xA9B40100u,
|
||||
worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside);
|
||||
|
||||
Assert.True(exitOutside);
|
||||
}
|
||||
}
|
||||
291
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
291
tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="Transition.TryFindIndoorWalkablePlane"/>.
|
||||
///
|
||||
/// Indoor walking Phase 2 follow-up (2026-05-19): the helper synthesizes
|
||||
/// a walkable contact plane from cell floor polys so the resolver does not
|
||||
/// fall through to outdoor terrain when the player is standing indoors.
|
||||
///
|
||||
/// Task 3 (2026-05-19): refactored to route through BSPQuery.FindWalkableSphere.
|
||||
/// Fixtures now include a PhysicsBSPTree with a Leaf node listing all polygon ids,
|
||||
/// and calls pass sphereRadius explicitly. PointInPolygonXY tests removed since
|
||||
/// that helper was deleted (it was the dead linear-scan body).
|
||||
/// </summary>
|
||||
public class IndoorWalkablePlaneTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Build a BSP Leaf node that lists the given polygon ids, with a bounding
|
||||
/// sphere large enough to always contain the test geometry.
|
||||
/// </summary>
|
||||
private static PhysicsBSPTree BuildLeafBsp(IEnumerable<ushort> polyIds,
|
||||
Vector3 center, float radius)
|
||||
{
|
||||
var node = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||
};
|
||||
foreach (var id in polyIds)
|
||||
node.Polygons.Add(id);
|
||||
return new PhysicsBSPTree { Root = node };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CellPhysics with a single upward-facing floor polygon
|
||||
/// (a 10×10 square in the XY plane at local Z=0), plus identity transforms
|
||||
/// and a BSP leaf that covers all polygons.
|
||||
/// </summary>
|
||||
private static CellPhysics BuildCellWithFloor(float floorZ = 0f)
|
||||
{
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5f, -5f, floorZ),
|
||||
new Vector3( 5f, -5f, floorZ),
|
||||
new Vector3( 5f, 5f, floorZ),
|
||||
new Vector3(-5f, 5f, floorZ),
|
||||
};
|
||||
var normal = new Vector3(0f, 0f, 1f); // straight up
|
||||
float D = -Vector3.Dot(normal, verts[0]); // = -floorZ
|
||||
|
||||
var floorPoly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new Plane(normal, D),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
|
||||
var bsp = BuildLeafBsp(new ushort[] { 0 }, new Vector3(0f, 0f, floorZ), 10f);
|
||||
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = resolved,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TryFindIndoorWalkablePlane
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
|
||||
{
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var transition = new Transition();
|
||||
// Foot sphere centre at Z=0.4, radius=0.48 → overlaps floor at Z=0.
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.True(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
|
||||
{
|
||||
var cell = BuildCellWithFloor(floorZ: 0f);
|
||||
var transition = new Transition();
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
|
||||
transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out var plane, out _, out _);
|
||||
|
||||
// The floor's normal must point up (Z close to 1).
|
||||
Assert.True(plane.Normal.Z > 0.99f,
|
||||
$"Expected plane.Normal.Z > 0.99, got {plane.Normal.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneAtFloorZ()
|
||||
{
|
||||
const float floorZ = 2.5f;
|
||||
var cell = BuildCellWithFloor(floorZ);
|
||||
var transition = new Transition();
|
||||
// Foot sphere overlaps floor: centre at floorZ + 0.4, radius=0.48 → dist=0.4 < 0.48.
|
||||
var localFoot = new Vector3(0f, 0f, floorZ + 0.4f);
|
||||
|
||||
transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out var plane, out _, out _);
|
||||
|
||||
// With identity transform and an upward normal, plane.D = -floorZ.
|
||||
// The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1).
|
||||
Assert.True(MathF.Abs(plane.D - (-floorZ)) < 1e-4f,
|
||||
$"Expected plane.D ≈ {-floorZ}, got {plane.D}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse()
|
||||
{
|
||||
var cell = BuildCellWithFloor();
|
||||
var transition = new Transition();
|
||||
// XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
|
||||
var localFoot = new Vector3(20f, 20f, 0.4f);
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse()
|
||||
{
|
||||
// CellPhysics without a BSP → BSP?.Root is null → early return false.
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_WallPolyInBsp_ReturnsFalse()
|
||||
{
|
||||
// A polygon with a horizontal normal (Z = 0) is a wall, not a floor.
|
||||
// walkable_hits_sphere rejects it: dp = dot(UnitZ, (0,1,0)) = 0 <= FloorZ.
|
||||
// Regression coverage for the previous NoWalkablePolys_ReturnsFalse intent
|
||||
// (the renamed NoBsp_ReturnsFalse only covers the null-BSP early-return).
|
||||
Vector3[] wallVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, 0f),
|
||||
new Vector3(1f, 0f, 0f),
|
||||
new Vector3(1f, 0f, 1f),
|
||||
new Vector3(0f, 0f, 1f),
|
||||
};
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[0] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = wallVerts,
|
||||
Plane = new Plane(new Vector3(0f, 1f, 0f), 0f), // wall facing +Y
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
var center = new Vector3(0.5f, 0f, 0.5f);
|
||||
var bsp = BuildLeafBsp(new ushort[] { 0 }, center, 2f);
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = resolved,
|
||||
};
|
||||
|
||||
var transition = new Transition();
|
||||
transition.SpherePath.WalkInterp = 1.0f;
|
||||
|
||||
// Foot sphere positioned to overlap the wall's plane (|Y - 0| = 0 < radius 0.48).
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell,
|
||||
localFootCenter: new Vector3(0.5f, 0f, 0.5f),
|
||||
sphereRadius: 0.48f,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse()
|
||||
{
|
||||
// BSP leaf exists but references no polygons → FindWalkableSphere returns false.
|
||||
var bsp = BuildLeafBsp(System.Array.Empty<ushort>(), Vector3.Zero, 10f);
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f,
|
||||
out _, out _, out _);
|
||||
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_WithWorldTranslation_PlaneInWorldSpace()
|
||||
{
|
||||
// Cell is translated 100 units in X and 200 units in Y.
|
||||
var translation = Matrix4x4.CreateTranslation(100f, 200f, 94f);
|
||||
Matrix4x4.Invert(translation, out var inv);
|
||||
|
||||
var localVerts = new[]
|
||||
{
|
||||
new Vector3(-5f, -5f, 0f),
|
||||
new Vector3( 5f, -5f, 0f),
|
||||
new Vector3( 5f, 5f, 0f),
|
||||
new Vector3(-5f, 5f, 0f),
|
||||
};
|
||||
var floorPoly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = localVerts,
|
||||
Plane = new Plane(new Vector3(0f, 0f, 1f), 0f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = floorPoly };
|
||||
var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f);
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
WorldTransform = translation,
|
||||
InverseWorldTransform = inv,
|
||||
Resolved = resolved,
|
||||
};
|
||||
|
||||
// The player's local foot sphere centre at (0,0,0.4) overlaps the floor at Z=0.
|
||||
var localFoot = new Vector3(0f, 0f, 0.4f);
|
||||
var transition = new Transition();
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cell, localFoot, sphereRadius: 0.48f,
|
||||
out var plane, out var worldVerts, out _);
|
||||
|
||||
Assert.True(found);
|
||||
// World normal should still be (0,0,1).
|
||||
Assert.True(plane.Normal.Z > 0.99f);
|
||||
// World vertex[0] should be at local (-5,-5,0) + translation = (95, 195, 94).
|
||||
Assert.True(MathF.Abs(worldVerts[0].X - 95f) < 1e-3f);
|
||||
Assert.True(MathF.Abs(worldVerts[0].Y - 195f) < 1e-3f);
|
||||
Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
|
||||
$"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}");
|
||||
}
|
||||
}
|
||||
|
|
@ -207,7 +207,10 @@ public class PhysicsEngineTests
|
|||
|
||||
Assert.True(result.IsOnGround);
|
||||
Assert.InRange(result.Position.X, 24.9f, 25.1f);
|
||||
Assert.Equal(0x0009u, result.CellId);
|
||||
// Phase D fix: ResolveOutdoorCellId now always applies the matched
|
||||
// landblock's high-16 prefix — 0xA9B4 prefix from the registered
|
||||
// landblock (0xA9B4FFFF) is now included in the returned CellId.
|
||||
Assert.Equal(0xA9B40009u, result.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -228,7 +231,10 @@ public class PhysicsEngineTests
|
|||
|
||||
Assert.True(result.IsOnGround);
|
||||
Assert.InRange(result.Position.X, 97.9f, 98.1f);
|
||||
Assert.Equal(0x0025u, result.CellId);
|
||||
// Phase D fix: ResolveOutdoorCellId now always applies the matched
|
||||
// landblock's high-16 prefix — 0xA9B4 prefix from the registered
|
||||
// landblock (0xA9B4FFFF) is now included in the returned CellId.
|
||||
Assert.Equal(0xA9B40025u, result.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
35
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs
Normal file
35
tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class PortalInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void PortalSide_FlagsBit2Clear_ReturnsTrue()
|
||||
{
|
||||
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
|
||||
Assert.True(portal.PortalSide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalSide_FlagsBit2Set_ReturnsFalse()
|
||||
{
|
||||
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 2);
|
||||
Assert.False(portal.PortalSide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalSide_OtherBitsSet_FollowsOnlyBit2()
|
||||
{
|
||||
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0xFF & ~2);
|
||||
Assert.True(portal.PortalSide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherCellId_StoredAsLowSixteenBits()
|
||||
{
|
||||
var portal = new PortalInfo(otherCellId: 0xFFFF, polygonId: 5, flags: 0);
|
||||
Assert.Equal((ushort)0xFFFF, portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
44
tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs
Normal file
44
tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class ResolveCellIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveCellId_FallbackZero_ReturnsZero()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0u);
|
||||
Assert.Equal(0u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCellId_NoLandblock_OutdoorSeed_ReturnsFallback()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
// Outdoor seed with no landblock added → AddAllOutsideCells produces
|
||||
// candidates but none have a CellBSP → falls back to input.
|
||||
uint result = engine.ResolveCellId(
|
||||
new Vector3(100, 100, 0),
|
||||
sphereRadius: 0.5f,
|
||||
fallbackCellId: 0xA9B40001u);
|
||||
|
||||
Assert.Equal(0xA9B40001u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCellId_NoDataCache_ReturnsFallback()
|
||||
{
|
||||
// Build a PhysicsEngine without setting DataCache.
|
||||
var engine = new PhysicsEngine { DataCache = null };
|
||||
uint result = engine.ResolveCellId(
|
||||
new Vector3(100, 100, 0),
|
||||
sphereRadius: 0.5f,
|
||||
fallbackCellId: 0xA9B40100u); // indoor seed
|
||||
// Indoor branch falls back when DataCache is null.
|
||||
Assert.Equal(0xA9B40100u, result);
|
||||
}
|
||||
}
|
||||
111
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
Normal file
111
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class TransitionTypesTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance()
|
||||
{
|
||||
// Build a CellPhysics with two horizontal walkable polygons at
|
||||
// local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1].
|
||||
// Foot sphere at local Z=0.4 → sphere overlaps the Z=0 polygon
|
||||
// (|0.4| < radius 0.48); Z=3 is out of range. Expect the lower poly
|
||||
// to be returned. Sentinel WalkableAllowance value must be preserved
|
||||
// across the call.
|
||||
|
||||
var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f);
|
||||
|
||||
var transition = new Transition();
|
||||
const float sentinelAllowance = 0.42f;
|
||||
transition.SpherePath.WalkableAllowance = sentinelAllowance;
|
||||
transition.SpherePath.WalkInterp = 1.0f;
|
||||
|
||||
bool found = transition.TryFindIndoorWalkablePlane(
|
||||
cellPhysics,
|
||||
localFootCenter: new Vector3(0.5f, 0.5f, 0.4f),
|
||||
sphereRadius: 0.48f,
|
||||
out var worldPlane,
|
||||
out var worldVertices,
|
||||
out var hitPolyId);
|
||||
|
||||
Assert.True(found);
|
||||
// Lower polygon's local plane Normal.Z = 1.0; identity world transform
|
||||
// means world Normal.Z is also 1.0.
|
||||
Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3);
|
||||
// World vertices match the lower polygon (Z=0 in world space, identity transform).
|
||||
Assert.Equal(4, worldVertices.Length);
|
||||
Assert.Equal(0f, worldVertices[0].Z, precision: 3);
|
||||
// hitPolyId is the dictionary key — lower polygon was inserted as key 0.
|
||||
Assert.Equal(0u, hitPolyId);
|
||||
// WalkableAllowance must be restored to the sentinel.
|
||||
Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal CellPhysics with two horizontal walkable polygons at
|
||||
/// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
|
||||
/// </summary>
|
||||
private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ)
|
||||
{
|
||||
Vector3[] lowerVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, lowerZ),
|
||||
new Vector3(1f, 0f, lowerZ),
|
||||
new Vector3(1f, 1f, lowerZ),
|
||||
new Vector3(0f, 1f, lowerZ),
|
||||
};
|
||||
Vector3[] upperVerts =
|
||||
{
|
||||
new Vector3(0f, 0f, upperZ),
|
||||
new Vector3(1f, 0f, upperZ),
|
||||
new Vector3(1f, 1f, upperZ),
|
||||
new Vector3(0f, 1f, upperZ),
|
||||
};
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[0] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = lowerVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, -lowerZ),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
[1] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = upperVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, -upperZ),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
},
|
||||
};
|
||||
|
||||
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
|
||||
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
|
||||
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
|
||||
|
||||
var root = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere { Origin = center, Radius = radius },
|
||||
};
|
||||
root.Polygons.Add(0);
|
||||
root.Polygons.Add(1);
|
||||
|
||||
var bsp = new PhysicsBSPTree { Root = root };
|
||||
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = bsp,
|
||||
Resolved = resolved,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs
Normal file
49
tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using AcDream.Core.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering;
|
||||
|
||||
public class CameraDiagnosticsTests
|
||||
{
|
||||
// NOTE: These tests assume the env vars ACDREAM_RETAIL_CHASE and
|
||||
// ACDREAM_CAMERA_ALIGN_SLOPE are NOT set when running the test
|
||||
// suite. Static class is initialised on first access; values reflect
|
||||
// the env state at that time.
|
||||
|
||||
[Fact]
|
||||
public void Defaults_AreRetailValues()
|
||||
{
|
||||
// Reset to defaults explicitly (test isolation; another test may
|
||||
// have flipped these earlier in the run).
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
CameraDiagnostics.MouseLowPassWindowSec = 0.25f;
|
||||
CameraDiagnostics.CameraAdjustmentSpeed = 40.0f;
|
||||
CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.UseRetailChaseCamera = true;
|
||||
|
||||
Assert.Equal(0.45f, CameraDiagnostics.TranslationStiffness);
|
||||
Assert.Equal(0.45f, CameraDiagnostics.RotationStiffness);
|
||||
Assert.Equal(0.25f, CameraDiagnostics.MouseLowPassWindowSec);
|
||||
Assert.Equal(40.0f, CameraDiagnostics.CameraAdjustmentSpeed);
|
||||
Assert.True(CameraDiagnostics.AlignToSlope);
|
||||
// 2026-05-18 ship: retail chase camera is the default. The
|
||||
// legacy camera remains opt-in via the DebugPanel toggle until
|
||||
// the follow-up deletion commit.
|
||||
Assert.True(CameraDiagnostics.UseRetailChaseCamera);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setters_PersistRuntimeChanges()
|
||||
{
|
||||
CameraDiagnostics.TranslationStiffness = 0.8f;
|
||||
CameraDiagnostics.UseRetailChaseCamera = false;
|
||||
|
||||
Assert.Equal(0.8f, CameraDiagnostics.TranslationStiffness);
|
||||
Assert.False(CameraDiagnostics.UseRetailChaseCamera);
|
||||
|
||||
// Reset so other tests aren't poisoned.
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.UseRetailChaseCamera = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
using AcDream.Core.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering;
|
||||
|
||||
public sealed class RenderingDiagnosticsTests
|
||||
{
|
||||
// Each flag-mutating test snapshots the IndoorAll state on entry and
|
||||
// restores it via try/finally. RenderingDiagnostics is a process-wide
|
||||
// static (env-var-initialized); without restoration a mutated state
|
||||
// leaks into other tests + into parallel test runs. Mirrors the
|
||||
// PhysicsDiagnosticsTests pattern at line 30-49.
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_True_TurnsAllFlagsOn()
|
||||
{
|
||||
bool initial = RenderingDiagnostics.IndoorAll;
|
||||
try
|
||||
{
|
||||
// Reset all flags off first to make the test deterministic
|
||||
// regardless of env-var state on the test runner.
|
||||
RenderingDiagnostics.ProbeIndoorWalkEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorLookupEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorUploadEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorXformEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorCullEnabled = false;
|
||||
|
||||
RenderingDiagnostics.IndoorAll = true;
|
||||
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled);
|
||||
Assert.True(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = initial;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_False_TurnsAllFlagsOff()
|
||||
{
|
||||
bool initial = RenderingDiagnostics.IndoorAll;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = true; // start from all-on
|
||||
RenderingDiagnostics.IndoorAll = false;
|
||||
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled);
|
||||
Assert.False(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = initial;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_OneOff_ReadsAsFalse()
|
||||
{
|
||||
bool initial = RenderingDiagnostics.IndoorAll;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = true;
|
||||
RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off
|
||||
Assert.False(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = initial;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid
|
||||
[InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix
|
||||
[InlineData(0x00000100ul, true)] // indoor cell minimum
|
||||
[InlineData(0x00000105ul, true)] // typical Holtburg Inn interior
|
||||
[InlineData(0xA9B40105ul, true)] // indoor with landblock prefix
|
||||
[InlineData(0xA9B401FFul, true)] // indoor near top of range
|
||||
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using DatReaderWriter.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class CellBspRayOccluderTests
|
||||
{
|
||||
// Build a CellPhysics with a single triangular poly at world-Y=10.
|
||||
// Triangle vertices in local space, world transform = identity.
|
||||
// Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation).
|
||||
private static CellPhysics MakeWallCell()
|
||||
{
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5, 10, 0),
|
||||
new Vector3( 5, 10, 0),
|
||||
new Vector3( 0, 10, 5),
|
||||
};
|
||||
var poly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||||
NumPoints = 3,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
|
||||
Resolved = new() { [0] = poly },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
|
||||
{
|
||||
var cell = MakeWallCell();
|
||||
var origin = new Vector3(0, 0, 1);
|
||||
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||||
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
|
||||
{
|
||||
var cell = MakeWallCell();
|
||||
var origin = new Vector3(0, 0, 1);
|
||||
var direction = -Vector3.UnitY; // travels AWAY from the wall
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||||
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
|
||||
{
|
||||
var origin = Vector3.Zero;
|
||||
var direction = Vector3.UnitY;
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
|
||||
Assert.True(float.IsPositiveInfinity(t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_TwoCells_ReturnsNearer()
|
||||
{
|
||||
var nearCell = MakeWallCell(); // wall at Y=10
|
||||
var farCell = MakeWallCell();
|
||||
// Move farCell's transform to push it to Y=20.
|
||||
farCell = new CellPhysics
|
||||
{
|
||||
BSP = null,
|
||||
Resolved = nearCell.Resolved,
|
||||
WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0),
|
||||
InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0),
|
||||
};
|
||||
|
||||
var origin = new Vector3(0, 0, 1);
|
||||
var direction = Vector3.UnitY;
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
|
||||
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
|
||||
}
|
||||
}
|
||||
64
tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs
Normal file
64
tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class ScreenProjectionTests
|
||||
{
|
||||
// Standard right-handed perspective + identity view. Sphere centered
|
||||
// at z=-10 in front of camera (System.Numerics
|
||||
// CreatePerspectiveFieldOfView is right-handed; camera at origin
|
||||
// looks down -Z).
|
||||
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f /*fovY 90°*/, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800, 600);
|
||||
return (view, proj, viewport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProject_SphereInFront_ReturnsSquareRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
|
||||
new Vector3(0, 0, -10), worldRadius: 1f,
|
||||
view, proj, viewport,
|
||||
out var rMin, out var rMax, out var depth,
|
||||
minSidePixels: 0f);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(rMax.X - rMin.X, rMax.Y - rMin.Y, precision: 3);
|
||||
Assert.InRange((rMin.X + rMax.X) * 0.5f, 399f, 401f);
|
||||
Assert.InRange((rMin.Y + rMax.Y) * 0.5f, 299f, 301f);
|
||||
Assert.True(depth > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProject_SphereBehindCamera_ReturnsFalse()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
|
||||
new Vector3(0, 0, +10) /* behind RH camera at origin */,
|
||||
worldRadius: 1f,
|
||||
view, proj, viewport,
|
||||
out _, out _, out _,
|
||||
minSidePixels: 0f);
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProject_FarSphereClampsToMinSide()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
|
||||
new Vector3(0, 0, -90) /* very far */, worldRadius: 0.01f /* tiny */,
|
||||
view, proj, viewport,
|
||||
out var rMin, out var rMax, out _,
|
||||
minSidePixels: 12f);
|
||||
Assert.True(ok);
|
||||
Assert.True(rMax.X - rMin.X >= 12f);
|
||||
Assert.True(rMax.Y - rMin.Y >= 12f);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class WorldPickerCellOcclusionTests
|
||||
{
|
||||
private static CellPhysics MakeWallAtY10()
|
||||
{
|
||||
// A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space
|
||||
// because WorldTransform = Identity). The occluder triangulates it as a fan:
|
||||
// tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10.
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5, 10, -5),
|
||||
new Vector3( 5, 10, -5),
|
||||
new Vector3( 5, 10, 5),
|
||||
new Vector3(-5, 10, 5),
|
||||
};
|
||||
var poly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = null,
|
||||
Resolved = new() { [0] = poly },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
|
||||
{
|
||||
Id = guid,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = pos,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a quad wall at Z=-10 in front of the camera (identity view,
|
||||
/// camera looking down -Z). The wall spans X=-5..5, Y=-5..5 at Z=-10 —
|
||||
/// large enough to cover the center-pixel ray. An entity at Z=-20 sits
|
||||
/// behind it.
|
||||
///
|
||||
/// Wall normal direction doesn't affect Möller-Trumbore (the occluder
|
||||
/// is two-sided), but the Plane is stored for completeness. For a plane
|
||||
/// at z=-10 with outward normal (0,0,+1): (0,0,1)·(x,y,-10) + D = 0
|
||||
/// → D = 10.
|
||||
/// </summary>
|
||||
private static CellPhysics MakeWallAtZNeg10()
|
||||
{
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5, -5, -10),
|
||||
new Vector3( 5, -5, -10),
|
||||
new Vector3( 5, 5, -10),
|
||||
new Vector3(-5, 5, -10),
|
||||
};
|
||||
var poly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(new Vector3(0, 0, 1), 10f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = null,
|
||||
Resolved = new() { [0] = poly },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Screen-rect overload + cell-BSP occlusion
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Production path exercised by GameWindow.PickAndStoreSelection.
|
||||
/// Camera at origin looking down -Z (identity view). Entity at Z=-20
|
||||
/// projects to the center of the viewport. A wall at Z=-10 sits between
|
||||
/// camera and entity; with cellOccluder wired up the entity must be
|
||||
/// occluded → null result.
|
||||
///
|
||||
/// This test specifically covers the clip.W depth-conversion math in
|
||||
/// WorldPicker.Pick's screen-rect overload (issue #86).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp()
|
||||
{
|
||||
// Use the same camera convention as WorldPickerRectOverloadTests.StdCam():
|
||||
// identity view, 90-degree FoV, 800×600 viewport. Center pixel = (400,300).
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800f, 600f);
|
||||
|
||||
var wall = MakeWallAtZNeg10();
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
|
||||
|
||||
// Entity is dead-ahead: center of viewport.
|
||||
var result = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
|
||||
inflatePixels: 8f,
|
||||
cellOccluder: (origin, direction) =>
|
||||
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same camera and entity as Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp,
|
||||
/// but with a null cellOccluder. Verifies that the no-occluder path still
|
||||
/// resolves the entity to a hit (the new parameter is a pure no-op when null).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pick_ScreenRect_NoWall_HitsEntity()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800f, 600f);
|
||||
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
|
||||
inflatePixels: 8f,
|
||||
cellOccluder: null);
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Ray-sphere overload (legacy path)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
|
||||
{
|
||||
var wall = MakeWallAtY10();
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: Vector3.UnitY,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
cellOccluder: (origin, direction) =>
|
||||
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RaySphere_NoWall_HitsEntity()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: Vector3.UnitY,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
cellOccluder: null); // null occluder = no occlusion
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class WorldPickerRectOverloadTests
|
||||
{
|
||||
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800, 600);
|
||||
return (view, proj, viewport);
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
|
||||
{
|
||||
Id = serverGuid == 0u ? 1u : serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 50f, mouseY: 50f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var near = MakeEntity(0x10001u, new Vector3(0, 0, -8));
|
||||
var far = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { far, near } /* deliberately reversed */,
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_NullResolverSkipsCandidates()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e1 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
var e2 = MakeEntity(0x10002u, new Vector3(0, 0, -20));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e1, e2 },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => x.ServerGuid == 0x10001u
|
||||
? ((Vector3, float)?)null
|
||||
: ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_RespectsSkipServerGuid()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var player = MakeEntity(0x5000000Au, new Vector3(0, 0, -10));
|
||||
var npc = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { player, npc },
|
||||
skipServerGuid: 0x5000000Au,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_InflateExpandsClickableArea()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
|
||||
uint? withoutInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(withoutInflate);
|
||||
|
||||
uint? withInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 250f);
|
||||
Assert.Equal(0x10001u, withInflate);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.UI.Abstractions.Panels.Debug;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
|
||||
|
|
@ -285,4 +286,26 @@ public sealed class DebugVMTests
|
|||
Assert.Equal(1, weatherHits);
|
||||
Assert.Equal(1, wireHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics()
|
||||
{
|
||||
var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled;
|
||||
try
|
||||
{
|
||||
var vm = NewVm();
|
||||
|
||||
vm.ProbeIndoorBsp = true;
|
||||
Assert.True(PhysicsDiagnostics.ProbeIndoorBspEnabled);
|
||||
Assert.True(vm.ProbeIndoorBsp);
|
||||
|
||||
vm.ProbeIndoorBsp = false;
|
||||
Assert.False(PhysicsDiagnostics.ProbeIndoorBspEnabled);
|
||||
Assert.False(vm.ProbeIndoorBsp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = originalEnabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue