Commit graph

612 commits

Author SHA1 Message Date
Erik
c897a179fa Revert "fix(render): Phase A8 — mark-and-punch BEFORE indoor draw (correct WB order)"
This reverts commit b76f6d112e.
2026-05-26 09:38:36 +02:00
Erik
b76f6d112e fix(render): Phase A8 — mark-and-punch BEFORE indoor draw (correct WB order)
Second visual verification surfaced three depth-ordering bugs all from
one cause: the IndoorOnly dispatcher Draw ran BEFORE MarkAndPunch, so
the far-depth punch (gl_FragDepth = 1.0 at stencil=1 portal silhouettes)
overwrote any indoor depth that had been written there. Result:

  • Closed doors leaked outside terrain — door mesh wrote depth 0.6 at
    the portal silhouette, then the punch overwrote it to 1.0, then
    terrain at 0.99 won the depth test.
  • Walls between rooms leaked the far-side door/window opening —
    same mechanism: wall depth at the far-portal silhouette destroyed
    by the punch.
  • Animated character body bled to terrain where it overlapped a
    portal silhouette on screen — same mechanism: character depth
    destroyed by the punch.

Re-reading WB's RenderInsideOut (VisibilityManager.cs:73-239) confirms
the correct order is mark-and-punch FIRST, then indoor cells. Indoor
geometry drawn AFTER the punch wins the depth test against 1.0 and
correctly occludes the subsequent stencil-gated outdoor pass.

The swap is a single block move; MarkAndPunch was already correctly
leaving the GL state stencil-disabled for the indoor pass to follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:18:13 +02:00
Erik
a2ad5c1ac4 fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass
Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.

Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
  it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
  OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
  mirror the new partition logic

Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities

Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:55:45 +02:00
Erik
41c2e67cd8 feat(render): Phase A8 — wire stencil pipeline into render frame
Replaces the pre-A8 "terrain-always + depth-clear-when-inside" pattern
with WB's stencil-aware ordering when cameraInsideCell:

  1. Upload portal triangle mesh from VisibleCellIds → LoadedCell.
  2. Draw indoor entities (EntitySet.IndoorOnly) — stencil OFF.
  3. Mark portal stencil + punch far depth (MarkAndPunch).
  4. Draw terrain — stencil-gated to portal silhouettes.
  5. Draw outdoor entities (EntitySet.OutdoorOnly) — stencil-gated.
  6. DisableStencil before particles/weather/UI.

Outdoor path unchanged (EntitySet.All, no stencil work).

Adds CellVisibility.TryGetCell(uint) for the VisibleCellIds → LoadedCell
materialization. Removes the now-redundant DepthBufferBit Clear that
was the old approximation.

Retail oracle: PView::DrawCells at
acclient_2013_pseudo_c.txt:432709 ("outside_view.view_count > 0" gate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:29:45 +02:00
Erik
dcf69a1feb feat(render): Phase A8 — WbDrawDispatcher.EntitySet partition
Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.

Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:20:45 +02:00
Erik
a1c393ee14 hardening(render): Phase A8 — IndoorCellStencilPipeline robustness
Three small improvements from Task 5 code review:
- MarkAndPunch now enables DepthTest explicitly (was relying on
  GameWindow's startup enable; this makes the method self-contained).
- Uniform location fields marked readonly (set once in ctor).
- AllocateVbo gets a comment noting that mid-session reallocation is
  safe because the VAO bakes the VBO association at ConfigureVao time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:16:42 +02:00
Erik
3973596468 feat(render): Phase A8 — IndoorCellStencilPipeline + PortalMeshBuilder
The pipeline class owns the portal_stencil shader + a dynamic VBO/VAO
for per-frame portal triangle uploads. MarkAndPunch runs WB's two-step
stencil setup (mark portals = 1, then write gl_FragDepth=1.0 into
stencil=1 regions). EnableOutdoorPass switches to read-only stencil
for the subsequent terrain + outdoor-entity passes.

PortalMeshBuilder.BuildTriangles is the pure-math triangle-fan
extractor — unit-testable without a GL context. Only exit portals
(OtherCellId == 0xFFFF) are emitted; inner portals are skipped to
prevent outdoor geometry from bleeding into adjacent rooms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:02:14 +02:00
Erik
f3d7b13664 docs(render): Phase A8 — document portal_stencil.vert pos.w omission
WB's PortalStencil.vert has a pos.w clamp for the camera-coplanar-with-
portal degenerate. We exclude it per spec (matches retail intent), but
the file should note the omission so future readers don't wonder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:58:26 +02:00
Erik
344034bcd3 fix(render): Phase A8 — remove over-engineered shader guards (Task 4)
Removes the pos.w clamp in portal_stencil.vert and the FragColor
declaration in portal_stencil.frag added in 2d31d49. Both were
speculative defensive code not in the spec or the WB reference. The
shaders now match the spec verbatim (except the locally-conventional
`core` profile qualifier which is correct).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:55:15 +02:00
Erik
2d31d490d1 feat(render): Phase A8 — portal_stencil vert/frag shaders
Minimal pair for the indoor-cell stencil pipeline (#78). Vert transforms
world-space portal polygon vertices through uViewProjection; includes a
near-zero pos.w guard for coplanar-camera robustness (matches WB pattern).
Frag either passes through gl_FragCoord.z or writes gl_FragDepth=1.0
based on uWriteFarDepth; FragColor declared but suppressed via ColorMask
on the CPU side.

Matches WorldBuilder's PortalStencil.vert/.frag at
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/.
Uses #version 430 core consistent with acdream's mesh_modern shaders.
Deployed to bin/ via existing Rendering\Shaders\*.* .csproj glob.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:52:55 +02:00
Erik
d834188a4e feat(render): Phase A8 — populate LoadedCell.PortalPolygons
BuildLoadedCell now reads the full portal polygon vertices from
cellStruct.Polygons[portal.PolygonId].VertexIds and stores them in
local-space on the LoadedCell. Empty arrays for unresolved polygons.
Same source as the ClipPlane block; no new dat read.

Unit test covers the data-class invariant (parallel indexing) since
the full integration is exercised only at runtime with live dat data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:24 +02:00
Erik
fee878f292 feat(render): Phase A8 — LoadedCell.PortalPolygons field
First slice of the indoor-cell visibility culling pipeline (#78). Adds
PortalPolygons: List<Vector3[]> to LoadedCell, parallel-indexed to the
existing Portals + ClipPlanes lists. Empty arrays for portals whose
polygon could not be resolved. Field is populated in Task 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:39:31 +02:00
Erik
a64e6f20da refactor: #100 — remove hiddenTerrainCells / BuildingTerrainCells plumbing
Retired in favour of Task 1's retail-faithful terrain shader Z nudge.
Pure removal — ~50 LOC of dead surface area across:

  - src/AcDream.Core/Terrain/LandblockMesh.cs (drop parameter +
    cell-collapse block)
  - src/AcDream.Core/World/LoadedLandblock.cs (drop field)
  - src/AcDream.Core/World/LandblockLoader.cs (drop method + call)
  - src/AcDream.App/Rendering/GameWindow.cs (3 sites)
  - src/AcDream.App/Streaming/GpuWorldState.cs (6 ctor sites)
  - src/AcDream.App/Streaming/LandblockStreamer.cs (1 ctor site)
  - tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs (drop test)
  - tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs (drop test)

No retail anchor — the deleted mechanism never had one; this commit
rolls our code back to the actual retail behaviour established in
the prior commit's shader nudge.

ISSUES.md #100 moved to Recently closed.

Cross-ref:
  docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
  docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:37:53 +02:00
Erik
f48c74aa8b fix(render): #100 — render terrain 1 cm below physical Z (retail zFightTerrainAdjust)
Subtract 0.01 from every terrain vertex Z in the modern terrain vertex
shader, matching retail's per-draw nudge applied inside
ACRender::landPolysDraw(arg2=2). Coplanar building floors now always win
the depth test against the rendered terrain, so the visual "ground at
the building floor" reads as the building's floor, not as Z-fighting.

Constant 0.01f bit-equals retail's float literal 0.00999999978 when
rounded to single precision.

Render-only — physics reads the un-nudged heightmap via
TerrainSurface.SampleZ / SampleZFromHeightmap. The same render-vs-
physics split is already established for EnvCell render lift
(+0.02m at GameWindow.cs around the cell-mesh draw).

Retail anchors:
  docs/research/named-retail/acclient_2013_pseudo_c.txt:1120769
  docs/research/named-retail/acclient_2013_pseudo_c.txt:702254

Cross-ref:
  docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
  docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md

Followed by Task 2 (delete the hiddenTerrainCells / BuildingTerrainCells
plumbing). Visible result of this commit alone: building floors stop
Z-fighting, but the 24m x 24m transparent rectangles persist until the
plumbing is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:24:28 +02:00
Erik
5240d654df fix(physics): #101 — suppress mesh-aabb-fallback for phantom GfxObj stabs
The 10 stair-step cyls (entities 0x40B5008A..0x40B50095 in Holtburg
cells 0xA9B40159/A) are synthesized by the mesh-aabb-fallback path
from the visual mesh AABB of GfxObj 0x0100081A — which has
HasPhysics=False and no PhysicsBSP. Retail's CPartArray::InitParts
emits no collision in this case; acdream now matches that by
consulting PhysicsDataCache.IsPhantomGfxObjSource (added in the
previous commit) and skipping synthesis when the predicate fires.

The actual staircase collision is on entity 0x40B50089 (GfxObj
0x01000C16, hasPhys=True, BSP radius 2.645m) — same staircase BSP
that retail uses. After this fix, only that BSP fires; the
phantoms are gone.

Visual verification pending (next step in plan); the BSP dump
from ACDREAM_DUMP_GFXOBJS=0x01000C16 will confirm whether
0x01000C16 has walkable inclined polys for the climb to actually
land. If not, a follow-up issue is needed; the cyl phantom is
closed either way.

Also updates PhysicsDataCache.cs XML doc line reference from
6116 to 6127 (drifted by the 11-line isPhantomGfxObj block
inserted above the guarded if).

Refs docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:51:45 +02:00
Erik
ca9341c2cb feat(phys): A6.P4 Task 7 — RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart
Closes the M1.5 "doors don't block in production" bug (alongside the
foundation fix at 3b7dc46). Server-spawned entities (doors, NPCs,
chests, items) now register one ShadowEntry per collision shape —
matching retail's CPhysicsObj-with-CPartArray model
(acclient_2013_pseudo_c.txt:286236) — instead of one Cylinder
approximation per entity.

Before:
  RegisterLiveEntityCollision picked ONE shape via a CylSphere → Radius
  → Sphere cascade, registered as a single Cylinder. Doors got a
  14 cm × 20 cm cylinder from setup.Radius — far too narrow to span
  the doorway gap. Players could walk through closed doors.

After:
  - ShadowShapeBuilder.FromSetup emits N shapes:
    • one Cylinder per CylSphere
    • one Cylinder per Sphere (only when no CylSpheres — retail
      convention)
    • one BSP shape per Part with a non-null PhysicsBSP
  - Caller substitutes the real BoundingSphere.Radius from
    PhysicsDataCache for BSP shapes (pure builder's 2.0 placeholder
    is tightened to the actual cached value).
  - setup.Radius fallback preserved: if the builder produces zero
    shapes but Radius > 0, register a Radius-based Cylinder so simple
    decorative props don't silently lose collision.
  - ShadowObjects.RegisterMultiPart adds N rows, all sharing
    entity.Id so the existing UpdatePhysicsState (ETHEREAL flip on
    door Use) propagates to every part without changes.

Door 0x020019FF (Holtburg cottage) now registers:
  - Cylinder r=0.10 h=0.20 (from the single Sphere)
  - BSP from Part 0 = GfxObj 0x010044B5, the 6-face 1.925 m × 0.261 m
    × 2.490 m two-sided slab confirmed by
    DoorSetupGfxObjInspectionTests
  Parts 1 + 2 (GfxObj 0x010044B6, the visual leaves) are visual-only
  in the dat by retail design and correctly skipped.

Test impact: 53/53 pass in the shape / registry / door /
cellar-replay scope. App-layer 41/41 pass.

Visual verification needed: launch the client, walk into a closed
Holtburg cottage door from outside (dead center AND ~50 cm
off-center), then walk into it from inside. Door should block all
three approaches. Use the door (click + Use) → door swings open →
walking through passes (ETHEREAL flip via existing SetState path).

Foundation fix dependency:
  3b7dc46 fix(phys): GetNearbyObjects dedup-by-entityId silently
                     drops multi-part shadows
Without 3b7dc46 in place, the BSP shape registered here would be
dropped by GetNearbyObjects's dedup. They land together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:52:36 +02:00
Erik
35b37dfb5f chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments
Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
2026-05-23 15:11:49 +02:00
Erik
3e6f6ec858 chore(O-T7): code-review housekeeping after WB extraction
Five small post-cleanup items from T7 code review:

I1: Removed dead `datDir` parameter from WbMeshAdapter ctor (parameter
    was unused after _wbDats removal; ArgumentNullException.ThrowIfNull
    was misleading). Updated call sites in GameWindow.cs and
    WbMeshAdapterTests.cs.

I2: Updated stale GameWindow.cs comment that still described
    WbMeshAdapter as opening its own dat handles. Now reflects Phase O
    state: shared DatCollection via DatCollectionAdapter.

I3: Documented thread-safety contract on RenderStateCache (render-thread
    only — required for the mutable-static GL sentinel pattern).

M1: Added comment on IDatReaderWriter's write-path methods noting they
    are preserved for verbatim compatibility but unused in acdream.

M3: Added comment on Chorizite.Core PackageReference in Core.csproj
    explaining the previously-transitive dependency.

Also excluded SplitFormulaDivergenceTest.cs from the test build via
<Compile Remove>: this N.5b one-time data-collection test referenced
WorldBuilder.Shared types directly; after Phase O-T7 dropped that
project reference it no longer compiles. The sweep data it produced
already informed the N.5b Path-C decision and the file is retained
in the tree for historical reference.

Build green; tests green (1146 + 8 pre-existing failures baseline
maintained).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:29:06 +02:00
Erik
dc722e70bd feat(O-T7): drop WB project references; complete extraction
End of Phase O extraction. Final cleanup:

- Dropped <ProjectReference> entries to WorldBuilder.Shared and
  Chorizite.OpenGLSDLBackend from both AcDream.App.csproj and
  AcDream.Core.csproj.
- Added Chorizite.Core NuGet PackageReference to AcDream.Core.csproj
  (needed by Core.Rendering.Wb.TextureHelpers for TextureFormat enum;
  previously transitive through the WB project ref).
- Added BCnEncoder.Net.ImageSharp (1.1.2) + SixLabors.ImageSharp (3.1.12)
  as direct PackageReferences to AcDream.App.csproj — previously transitive
  via Chorizite.OpenGLSDLBackend project; used directly by ObjectMeshManager.

Item A (BaseObjectRenderManager static fields):
- Inlined CurrentAtlas/CurrentVAO/CurrentIBO into a new RenderStateCache.cs
  static class (AcDream.App.Rendering.Wb namespace) — the 4 consumers
  (ManagedGLIndexBuffer, ManagedGLTexture, ManagedGLTextureArray, ParticleBatcher)
  all reference RenderStateCache.* instead of BaseObjectRenderManager.*.
- Dropped using Chorizite.OpenGLSDLBackend.Lib from all 4 consumers and from
  WbDrawDispatcher (which had it only as a dead import).

Item B (ActiveParticleEmitter.ObjectLandblock):
- ObjectLandblock? erased to object?; WorldBuilder.Shared.Models.ObjectId? erased
  to ulong? — both fields are stored but never read by any consumer in our codebase.
- Dropped both WB using directives from ActiveParticleEmitter.cs.

Item C (IDatReaderWriter / IDatDatabase):
- Verbatim copy of both interfaces into IDatReaderWriter.cs in
  AcDream.App.Rendering.Wb namespace — DatCollectionAdapter and ObjectMeshManager
  already live in that namespace, so no using changes needed.
- Dropped using WorldBuilder.Shared.Services from DatCollectionAdapter.cs and
  ObjectMeshManager.cs.

Additional extractions required by the reference drop:
- GeometryUtils.cs: verbatim copy of WorldBuilder.Shared.Lib.GeometryUtils
  (float-precision overloads only; Vector3d double-precision overloads omitted —
  ObjectMeshManager uses only the float versions).
- Dropped using WorldBuilder.Shared.Lib from ObjectMeshManager.cs.

WbMeshAdapter.cs cleanup (spec O-D12):
- Deleted _wbDats (DefaultDatReaderWriter) field + ctor init + Dispose call.
- Deleted the [indoor-upload] NULL_RESULT diagnostic block (lines ~205-262) —
  its Phase 2 cell-resolution investigation is complete; its _wbDats.ResolveId
  dependency goes with this commit.
- Deleted _pendingEnvCellRequests field + isPendingEnvCell tracking in Tick().
- Simplified Tick() to a clean drain loop.

Deleted SplitFormulaDivergenceTest.cs — one-time N.5b data-collection sweep;
job done.

Verified acceptance criteria:
- Zero <ProjectReference> to WorldBuilder.* / Chorizite.OpenGLSDLBackend.* in any csproj.
- Zero 'using WorldBuilder.*' / 'using Chorizite.OpenGLSDLBackend.*' in src/.
- DefaultDatReaderWriter referenced in zero places in src/ (comments only).

Build green (0 warnings, 0 errors).
Tests: 1154 total (-1 from deleted SplitFormulaDivergenceTest), 1146 pass,
8 pre-existing failures (unchanged from baseline — physics/input tests
unrelated to this change).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:17:33 +02:00
Erik
a9ccc5acf5 fix(O-T4): thread-safety lock in DatDatabaseWrapper + drop unused using
Code-review findings on T4:

1. Added lock(_lock) around _db.TryGet and TryGetFileBytes in
   DatDatabaseWrapper, matching WB's DefaultDatDatabase pattern.
   ObjectMeshManager.PrepareMeshDataAsync runs on the thread pool, so
   concurrent dat access through the adapter must be serialized — our
   underlying DatCollection is not documented as thread-safe.

2. Removed unused `using WorldBuilder.Shared.Models;` from WbMeshAdapter.cs
   (its only purpose was TerrainEntry, which moved to AcDream.Core in T2).

Build green; tests green (1147 passing, 8 pre-existing failures baseline).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:43 +02:00
Erik
c0326523ac fix(O-T4): address spec-review findings — InstanceData + using cleanups
Four fixes from T4 spec review:

1. Extracted InstanceData.cs (14-line struct) verbatim to
   src/AcDream.App/Rendering/Wb/InstanceData.cs (per O-D1).

2. ObjectMeshManager.cs: replaced `using Chorizite.OpenGLSDLBackend.Lib;`
   with `using AcDream.Core.Rendering.Wb;` (TextureHelpers comes from
   our T2 Core extraction; InstanceData comes from new T4 cleanup).

3. EmbeddedResourceReader.GetEmbeddedResource promoted from `internal`
   to `public` per O-D9 intent (the type promotion only changed the
   class signature in T3; this finishes the spec).

4. OpenGLGraphicsDevice.cs: removed stale T3 interim comment at
   lines 142-145 — T4 resolved the ParticleBatcher construction
   via post-ctor assignment in WbMeshAdapter.cs:78.

Build green; tests green (1147 passing, 8 pre-existing failures
baseline maintained).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:50:05 +02:00
Erik
d16d8cd4e5 feat(O-T4): extract ObjectMeshManager + mesh pipeline closure into AcDream.App.Rendering.Wb
Phase O Task 4: extract the WB mesh pipeline (ObjectMeshManager + 7 support files)
from references/WorldBuilder into src/AcDream.App/Rendering/Wb/ and bridge dat I/O
through our DatCollection via a thin DatCollectionAdapter.

O-D7 adapter path taken: ObjectMeshManager has 26 _dats.X call sites (threshold 20),
so a DatCollectionAdapter : IDatReaderWriter is introduced rather than refactoring
ObjectMeshManager's internal dat access directly.

Files added (verbatim copies, namespace-only changes):
- ObjectMeshManager.cs — mesh pipeline hub; IDatReaderWriter field satisfied by adapter
- GlobalMeshBuffer.cs — single global VAO/VBO/IBO manager
- EdgeLineBuilder.cs — wireframe edge geometry from CellStruct polygons
- ModernRenderData.cs — ModernBatchData + LandblockMdiCommand structs
- TextureAtlasManager.cs — texture array grouping by (Width, Height, Format)
- ParticleBatcher.cs — GPU particle batching; T4 interim uses BaseObjectRenderManager
  static fields from Chorizite.OpenGLSDLBackend.Lib (stays until T7)
- ParticleEmitterRenderer.cs — per-emitter particle lifecycle + rendering
- ActiveParticleEmitter.cs — wrapper holding renderer + part index + local offset
- DatCollectionAdapter.cs — NEW: bridges DatCollection → IDatReaderWriter; implements
  ResolveId() via DatDatabase.TypeFromId + Tree.TryGetFile in HighRes→Portal→Language→Cell
  order matching DefaultDatReaderWriter; DatDatabaseWrapper wraps DatDatabase as IDatDatabase

WbMeshAdapter.cs changes (T4 Step 6):
- _graphicsDevice switched from Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice to
  extracted AcDream.App.Rendering.Wb.OpenGLGraphicsDevice
- ParticleBatcher = new ParticleBatcher(_graphicsDevice) restored (T3 had null! placeholder)
- ObjectMeshManager now constructed with new DatCollectionAdapter(dats) instead of _wbDats
- _wbDats field + its construction + disposal + [indoor-upload] NULL_RESULT diagnostic block
  left intact — T7 cleanup removes these once WorldBuilder project ref is dropped

EmbeddedResourceReader.cs: replaced assembly manifest lookup (wrong prefix for our assembly)
with disk-based lookup mapping "Shaders.Particle.vert" → Rendering/Shaders/wb_particle.vert;
consistent with all other acdream shaders.

wb_particle.vert / wb_particle.frag: WB particle shaders copied verbatim with wb_ prefix
to distinguish from acdream's own particle.vert.

OpenGLGraphicsDevice.cs: ParticleBatcher property type updated to extracted ParticleBatcher;
setter changed from private to internal so WbMeshAdapter (same assembly) can assign post-ctor.

Build: green (0 errors, 0 warnings in AcDream.App).
Tests: 1147+8 baseline maintained (8 pre-existing failures unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:37:55 +02:00
Erik
4cc38805b5 feat(O-T3): extract GL infrastructure to AcDream.App
Phase O Task 3 — verbatim-copy GL infra from Chorizite.OpenGLSDLBackend
into src/AcDream.App/Rendering/Wb/ (namespace AcDream.App.Rendering.Wb).

18 files extracted (all namespace-changed; no algorithm changes):
  OpenGLGraphicsDevice, ManagedGLTexture, ManagedGLTextureArray,
  ManagedGLVertexBuffer, ManagedGLIndexBuffer, ManagedGLVertexArray,
  ManagedGLFrameBuffer, ManagedGLUniformBuffer, GLSLShader, GLHelpers,
  GLStateScope, GpuMemoryTracker, SceneData, DebugRenderSettings,
  TextureParameters, TextureFormatExtensions, BufferUsageExtensions,
  EmbeddedResourceReader.

3 internals promoted to public (O-D9):
  EmbeddedResourceReader, TextureFormatExtensions, BufferUsageExtensions.

SixLabors.ImageSharp not reachable: TextureHelpers was placed in
AcDream.Core (no GL/ImageSharp dep); only the GL types went to App.

TextureHelpers.GetCompressedLayerSize added to AcDream.Core.Rendering.Wb
(was in Chorizite.OpenGLSDLBackend.Lib.TextureHelpers; uses
Chorizite.Core.Render.Enums.TextureFormat which Core gets transitively
via the still-present WB project refs).

T3/T4 boundary interims:
  - WbMeshAdapter._graphicsDevice stays Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice
    (T4 will swap it when ObjectMeshManager is extracted).
  - OpenGLGraphicsDevice.ParticleBatcher deferred to null! (T4 extracts
    ParticleBatcher alongside ObjectMeshManager; can't pass `this` of our
    new type to the WB-original ctor before T4).
  - ManagedGLTextureArray uses our TextureHelpers via explicit alias.
  - IUniformBuffer is in Chorizite.Core.dll under Chorizite.OpenGLSDLBackend
    namespace (unusual packaging); resolved via type alias.
  - AcDream.App.csproj gets explicit Chorizite.Core 0.0.18 PackageReference
    (IUniformBuffer + other Chorizite.Core types now used directly in App).

Build green. Test baseline 1147+8 maintained (1902 passing, 8 pre-existing
MotionInterpreterTests failures unrelated to T3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:00:31 +02:00
Erik
23ab17362a fix(physics): #92 — seed resolver with server cell id at player-mode entry
EnterPlayerModeNow computed the initial cellId from landblock prefix
+ hardcoded low byte 0x0001 (outdoor sentinel) and passed only the
low 16 bits to PhysicsEngine.Resolve. When the server places the
player INSIDE a building (spawn cell id e.g. 0xA9B4015A indoor), the
sentinel forced the outdoor seed branch — for the first several ticks
CheckBuildingTransit hadn't yet picked up the interior cell (it
depends on the sphere overlapping the destination cell's BSP), the
player was classified outdoor, indoor BSP queries didn't run, and
exterior walls were passable until enough inward motion finally
promoted them.

User-visible symptom: "logged in inside the inn, ran out through the
exterior wall; ran back in and the walls now block."

Fix: use spawn.Position.LandblockId (the server's authoritative full
cell id with landblock prefix) when available; fall back to the old
sentinel only if the spawn record is missing (defensive — shouldn't
fire in live play since OnLiveEntitySpawnedLocked writes _lastSpawnByGuid
before EnterPlayerModeNow can possibly run).

1147 + 8 baseline maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:17:37 +02:00
Erik
700abad94c fix(physics): skip Setup CylSphere/Sphere shadows for landblock stabs (A1.6)
ISSUES #83 Phase A1.6. Phase A1 gated the mesh-AABB-fallback path on
!_isLandblockStab, but Setup-derived CylSphere/Sphere/Radius-fallback
registrations (lines 5910-6005) still ran for stabs. A landblock stab
whose source is a Setup (0x02xxxxxx) with defined CylSpheres got BOTH
per-part BSP shadows AND a CylSphere shadow with id=entity.Id,
producing an invisible collision cylinder at the stab origin
alongside the correct BSP walls. User reported this as "thin air" hits
outside specific Holtburg buildings.

Retail's CBuildingObj uses BSP exclusively. Setup CylSphere/Sphere
data is for scenery (trees with trunk cylinders) and creatures.

Fix: extend A1's _isLandblockStab gate to wrap the Setup-derived
registration block (cylsphere, sphere, radius-fallback). One AND
clause on the outer `if (setup is not null)`.

Probe evidence (launch-a15-verify.utf8.log):
- 0xC0A9B45D (6 hits in outdoor cell 0xA9B40029) — Setup CylSphere on
  stab. src=0x020002FC. Pre-A1.5 it would also have been registered
  in adjacent landcells.
- 0xC0A9B463 (2 hits) — Setup Sphere on stab. src=0x020000E6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:00:47 +02:00
Erik
4d3bf6fe37 fix(physics): scope interior cell shadows to ParentCellId (Phase A1.5)
ISSUES #83 Phase A1.5. ShadowObjectRegistry.Register() assigned each
entity to the outdoor landcell grid (8x8 cells, 24m square) based on
its XY position. For interior EnvCell statics (fireplace, furniture,
sign) hydrated by BuildInteriorEntitiesForStreaming with
ParentCellId = envCellId (a high-cellId interior cell like
0xA9B40121), this meant the shadow got stamped into the OUTDOOR
landcell whose XY they overlapped (e.g., 0xA9B40029).

When the player was OUTSIDE the building in 0xA9B40029, the indoor
chair/fireplace shadow fired collisions in "thin air" outdoors. The
user reported this on Holtburg cottage exteriors after the Phase A1
landblock-stab fallback fix.

Fix: add optional cellScope parameter to Register(). When non-zero
(passed as entity.ParentCellId ?? 0u from the 5 entity-loop call
sites in GameWindow), skip the XY-based landcell loop and register
the shadow ONLY in that cell. Live server-spawn registration at
GameWindow.cs:3137 keeps the XY-based behavior (live entities move
between cells).

Probe evidence (launch-a1-verify.utf8.log, post-A1 capture):
- 71 hits on 0x40B50054 (interior static) in OUTDOOR cell 0xA9B40029.
- 47 hits on 0xA9B47C00 (other Holtburg cottage BSP — legitimate).
- 31 hits on 0x40B50048 / 15 on 0x40B50018 (interior statics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:54:29 +02:00
Erik
5f2b545979 fix(physics): skip mesh-AABB-fallback cylinder for landblock stabs
ISSUES #83 Phase A1. Landblock stabs (entity.Id 0xC0XXYY00+n per
LandblockLoader.cs:55) were being registered with TWO collision
shadows: the correct per-part BSP at `entity.Id*256 + partIdx`, AND a
redundant mesh-AABB-fallback cylinder at `entity.Id`. The fallback
clamped to 1.5m radius, centered at the building's mesh origin,
producing user-reported "thin air" collisions inside cottages and
within 2m of building exteriors.

The fallback was originally designed for canopy-only-BSP procedural
scenery (0x80XXYY00+n) — trees whose BSP covers the canopy but not
the trunk. Landblock stabs have full BSP coverage and don't need it.

Probe evidence (launch-thinair capture):
- 0xC0A9B479 cylinder fallback (Holtburg cottage): 104 hits in a
  short capture session, all inside the cottage main room
  (cell=0xA9B4013F), ~2m from the building's mesh origin.
- 0xA9B47900 BSP (the actual cottage walls): 52 legitimate hits.

Fix: one new bool _isLandblockStab + one clause in the existing
mesh-AABB-fallback gate.

Spec: docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:42:13 +02:00
Erik
702b30a63e refactor(physics): Phase 2 — code-review polish on BuildingPhysics commit
Five reviewer-flagged items addressed:

- Fix #1: GameWindow building-loop now reuses TerrainSurface.ComputeOutdoorCellId
  instead of re-deriving the row-major cell-index formula. DRY win; no risk
  of the two formulas drifting.
- Fix #2: BuildingPhysics.ExactMatch decoder now references
  DatReaderWriter.Enums.PortalFlags.ExactMatch instead of magic 0x0001.
- Fix #3: ExactMatch XML doc clarified as "reserved per retail's
  CBldPortal::exact_match; not currently consumed by CheckBuildingTransit".
- Fix #4: CheckBuildingTransit docstring now explicitly documents the
  retail divergence — retail's sphere_intersects_cell (radius-aware) vs.
  our PointInsideCellBsp (radius-less). The sphereRadius parameter is
  reserved for the future sphere_intersects_cell port. Practical effect
  noted: entry fires ~sphereRadius (~0.48m) deeper than retail.
- Fix #5: Test method `SphereInsideBuildingPortalDestination_AddsInteriorCell`
  renamed to `BuildingPortalWithUnloadedCellBSP_NoCandidateAdded` — the
  test asserts Empty(candidates), not that the cell is added. Comment
  updated.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:01:44 +02:00
Erik
069534a372 feat(physics): Phase 2 — BuildingPhysics + CheckBuildingTransit
Closes the outdoor→indoor entry path. New BuildingPhysics type holds
the per-SortCell BldPortal list + building world transform; PhysicsDataCache
caches it (CacheBuilding + GetBuilding); CellTransit.CheckBuildingTransit
tests each portal's destination cell via PointInsideCellBsp.

PhysicsEngine.ResolveCellId's outdoor branch now hooks CheckBuildingTransit
after the terrain-grid lookup: if the matched landcell has a cached
building stab, check whether the sphere has crossed into one of its
interior EnvCells before returning.

GameWindow at landblock-load time iterates LandBlockInfo.Buildings and
caches each via PhysicsDataCache.CacheBuilding. The landcell-id derivation
uses retail's row-major cell-index formula (gridX * 8 + gridY + 1).

Polish items from Subagent B/C reviews folded in:
- visited HashSet in FindCellList's BFS (avoids O(N^2) re-enqueue)
- ResolveCellId_NoDataCache_ReturnsFallback test (closes coverage gap)
- DataCache-asymmetry comment in PhysicsEngine.ResolveCellId
- Replaced misleading FindCellList outdoor-branch TODO with explicit
  note that ResolveCellId bypasses this branch — wired in ResolveCellId
  directly.
- Removed unused 'using DatReaderWriter.Types;' from CellTransit.cs
- 2 new CellTransitFindCellListTests integration tests
- 1 new CellTransitCheckBuildingTransitTests test (null-CellBSP guard
  case; happy path deferred to visual verification).

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:34:38 +02:00
Erik
1969c55823 feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics
Adds PortalInfo struct and extends CellPhysics with CellBSP (third BSP
for point-in-cell tests, typed CellBSPTree from DatReaderWriter),
Portals (from envCell.CellPortals), PortalPolygons (resolved
cellStruct.Polygons — portals reference visible polys, not
PhysicsPolygons), and VisibleCellIds (populated for future use;
envCell.VisibleCells is List<UInt16>, not Dictionary).

Deletes CellPhysics.LocalAabbMin/Max and PhysicsDataCache.TryFindContainingCell
— Phase D's AABB shortcut is gone. CacheCellStruct's AABB compute
removed; the [cell-cache] diagnostic updated with portal/visible counts
instead.

CacheCellStruct signature gains an EnvCell parameter (one call site in
GameWindow.cs:5384 updated). ResolveOutdoorCellId drops the
TryFindContainingCell call; portal-graph CellTransit replaces it next.

ResolveOutdoorCellIdTests object initializers had the deleted AABB
properties stripped temporarily so the build stays green; the file gets
replaced wholesale in the next commit (CellTransit integration). Those
2 AABB-containment tests continue to fail (they were pre-broken on this
branch); no new failures introduced.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:52:20 +02:00
Erik
3764867566 fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
WorldPicker.Pick previously had no occlusion test — any entity along
the click ray within maxDistance was a candidate, including ones
behind walls. Adds the CellBspRayOccluder static helper that
Möller-Trumbore-tests the click ray against every polygon in every
currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
Both Pick overloads gate candidate selection by that wall-t (legacy
ray-sphere via world-space `t`, screen-rect via camera-space clip.W
depth — matching ScreenProjection.TryProjectSphereToScreenRect's
convention).

PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
caller can iterate without needing the private cache dictionary.
CellPhysics.BSP/PhysicsPolygons/Vertices relaxed from required to
nullable so test fixtures can construct a CellPhysics from Resolved
alone without a real DAT BSP object. GameWindow snapshots the loaded
cell physics on each Pick call and passes the occluder callback.

Closes #86.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:41:56 +02:00
Erik
b838eccb38 feat(wb): ConsoleErrorLogger + cause report — H1 swallowed-exception confirmed
Phase 2 diagnostic chain identified the EXACT cause of 26/123 Holtburg
cells silently failing in WB's PrepareEnvCellMeshData:
ArgumentOutOfRangeException thrown from Setup.Unpack inside
DatReaderWriter when WB calls TryGet<Setup>(stab.Id, ...) on a stab id
whose prefix is GfxObj (0x01xxxxxx), not Setup (0x02xxxxxx).
DatReaderWriter finds the file in Portal's tree (GfxObjs and Setups
share tree-lookups), attempts to parse GfxObj bytes as Setup format,
throws OOR. Exception bubbles to PrepareMeshData's outer try/catch
which silently swallows + returns null. Entire cell fails to upload.

This commit lands the diagnostic infrastructure that surfaced the bug:

- WbMeshAdapter: replaced NullLogger<ObjectMeshManager> with a small
  Console-backed ConsoleErrorLogger<T> private class. Filters to
  LogLevel.Error+. WB's existing _logger.LogError(ex, ...) at the
  swallow site now writes [wb-error] lines with type + message + top 5
  stack frames. Bridges WB's intentional log point to acdream's console.
- WbMeshAdapter: extended [indoor-upload] NULL_RESULT probe with
  reader-divergence diagnostic (ourCellDb.TryGet, wbResolveId.Count,
  wbSelectedType, wbDbIsPortal, wbDbTryGet<EnvCell>, hadRenderData).
  Made it possible to rule out cache-hits and reader-divergence as
  causes before identifying the real one.
- Cause report at docs/research/2026-05-19-indoor-cell-rendering-cause.md
  documents the full chain: 55 ArgumentOutOfRangeException stack traces
  captured in one launch, all from PrepareEnvCellMeshData line 1223.

The fix itself (1-line guard at WB's TryGet<Setup> call site) is applied
to references/WorldBuilder/.../ObjectMeshManager.cs — which is a git
submodule. Will be committed separately to the WB submodule after
visual verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:00:18 +02:00
Erik
914638819d feat(wb): extend NULL_RESULT probe with reader-divergence diagnostic
Phase 2 Task 1's continuation logged [indoor-upload] NULL_RESULT
when WB's PrepareMeshDataAsync returned null. Extend the line to
include two cross-checks:

  ourCellDb.TryGet=<bool>    — acdream's DatCollection.Cell.TryGet<EnvCell>
  wbResolveId.Count=<int>    — WB's DefaultDatReaderWriter.ResolveId().Count

This narrows the cause among WB's null-return paths (ResolveId empty
vs TryGet<EnvCell> failure vs wrong type). Best-effort: both calls
wrapped in try/catch so diagnostic failures don't propagate.

Capture: 55 NULL_RESULTs across multiple landblocks ALL show
ourCellDb.TryGet=True + wbResolveId.Count=1. Both readers find the
cells in their indices, but WB's downstream PrepareMeshData logic
still returns null. Divergence is downstream of ResolveId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:36:37 +02:00
Erik
011a5e43f4 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>
2026-05-19 12:25:31 +02:00
Erik
9b948b6ad5 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 the ShouldEmitIndoorProbe instance helper added in
Task 6 (now consumed — no longer dead code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:54:45 +02:00
Erik
36a29ceff5 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
IndoorProbeState, a nested class wrapping _lastIndoorProbeFrame dictionary
and _indoorProbeFrameCounter (bumped at top of Draw()). WalkEntitiesInto
accepts a new optional IndoorProbeState? parameter (null = probes off,
default) so the test-friendly WalkEntities overload is unaffected. The
ShouldEmitIndoorProbe instance helper is also retained for Task 7 use.

Disambiguates hypothesis H3 (cull bug -- cell entity dropped before draw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:48:27 +02:00
Erik
1dd20ddd40 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>
2026-05-19 11:37:58 +02:00
Erik
1024ba34e0 fix(lighting): trigger indoor ambient on PLAYER cell, not camera cell
User report: third-person chase camera enters interiors before the
player body does, so the camera-based cameraInsideCell flag was
flipping the scene to indoor lighting prematurely (ambient drops to
0.2 white before the player has actually crossed the doorway).

Retail keys lighting off the PLAYER's cell. CellManager::ChangePosition
@ 0x004559B0 reads CObjCell::seen_outside on the player's current
cell — never on the camera. Match that semantics.

- CellVisibility.IsInsideAnyCell(Vector3): new non-caching brute-force
  scan that's safe to call alongside ComputeVisibility(cameraPos)
  without thrashing the camera cell cache.
- GameWindow render loop: derive playerInsideCell from the player's
  Position when in player mode, otherwise fall back to cameraInsideCell
  (orbit/fly debug camera).
- UpdateSunFromSky now takes playerInsideCell. The sky-render and
  depth-buffer-clear decisions still use cameraInsideCell — those are
  legitimately camera-POV concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:38:48 +02:00
Erik
a54cd7bef6 fix(lighting): match retail indoor ambient (0.20 neutral, not 0.10/0.09/0.08 warm)
Indoor cells rendered "almost black" because the hardcoded ambient at
GameWindow.cs:8342-8345 was an early-2026 guess (0.10, 0.09, 0.08 — half
retail brightness, warm-tinted) rather than the retail value. The named
retail decomp (acclient.pdb, Sept 2013 EoR build) shows
CellManager::ChangePosition @ 0x004559B0 calls
SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF) whenever the player's
CObjCell::seen_outside flag is 0 — a flat 0.20 white floor, not a
dungeon-tone warm color.

Investigation also confirmed:
- EnvCell.dat does NOT carry inline lights — CEnvCell::UnPack reads
  numVisibleCells where Binary Ninja's heuristic decomp inferred
  "num_lights". Retail's CObjCell.light_list is populated at runtime via
  add_light() calls from neighbouring cell light registrations + per-cell
  static-object Setup.Lights, NOT from the dat byte stream.
- Setup.Lights from indoor static objects (entity.SourceGfxObjOrSetupId
  prefix 0x02xxxxxx) DO flow through LightInfoLoader.Load (line 5765)
  and reach LightManager via LightingHookSink. The wire is intact; the
  per-frame Tick + UBO upload chain (line 6865-6867) is intact.
- Retail's particle system does NOT emit lights from particles themselves.
  The light comes from the owning Setup's LightInfo records.

Pre-existing failures in DispatcherToMovementIntegrationTests, BSPStepUpTests,
and MotionInterpreterTests are on the branch already and unrelated to this
change (verified by stashing + retesting).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:14:25 +02:00
Erik
b7e954e50b fix(camera): retail-faithful jump-tracking via contact-plane projection
Original symptom: jumping made the camera swing around the player
vertically — the basis tilted up/down with the player's Z velocity.

Root cause: ComputeHeading used the raw 3D velocity vector as the
heading direction. During a jump, velocity has a substantial Z
component (vy ≈ jump speed), and `normalize((vx, vy, vz))` produced
a heading pointing up. The basis tilted accordingly and the camera
went under/over the player.

Retail's actual ALIGN_WITH_PLANE algorithm (decomp at
acclient_2013_pseudo_c.txt:95644-95795) is different:

  1. Velocity is only used as a gate. If |vx| AND |vy| > epsilon
     (player is moving in XY), proceed; otherwise fall back to the
     LOOK_IN_DIRECTION path (player's facing direction unchanged).
  2. The base heading is `localtoglobalvec(player, (0, 1, 0))` —
     the player's local +Y axis in world space, which in our
     convention is `(cos yaw, sin yaw, 0)`.
  3. Pick a surface normal:
       grounded:  contact_plane.N
       airborne:  (0, 0, 1)  [world up]
  4. Project the base heading onto the plane perpendicular to that
     normal:  projected = forward - normal * dot(forward, normal).
  5. Normalize. Fall back to the base if projection collapses.

Behaviorally:
  * Standing jump (vx≈0, vy≈0):  gate fails → base heading. Camera
    doesn't move with the jump.
  * Running jump (vx, vy, vz all nonzero, airborne):  projects onto
    world up → no-op since base is already horizontal. Camera basis
    stays horizontal; player visibly rises in frame.
  * Walking uphill (grounded, slope normal tilted):  projection
    adds a Z component matching the slope angle. Camera basis tilts
    with the terrain.
  * Walking on flat ground:  projection is a no-op. Camera basis
    horizontal.

Surface changes:
  * RetailChaseCamera.ComputeHeading gains `isOnGround` and
    `contactPlaneNormal` parameters.
  * RetailChaseCamera.Update gains the same two parameters and
    threads them through.
  * GameWindow's two Update call sites pass `result.IsOnGround` and
    `_playerController.ContactPlane.Normal` (already exposed on
    PlayerMovementController — no plumbing change there).
  * Tests: 2 existing heading tests reshaped (Moving* and Uphill);
    2 new tests added (AirborneJumping straight-up + running-jump);
    1 renamed (SlopeAlignDisabled). Net 25 → 27 tests in
    RetailChaseCameraTests; full AcDream.App.Tests: 39 → 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:32:50 +02:00
Erik
8f30e13317 feat(camera): wire RetailChaseCamera through GameWindow
GameWindow now constructs both ChaseCamera + RetailChaseCamera at
player-mode entry, updates both per frame (legacy with isOnGround,
retail with BodyVelocity), and routes mouse/wheel/held-key input to
whichever the CameraDiagnostics flag selects. Mouse-Y goes through
RetailChaseCamera.FilterMouseDelta before AdjustPitch when retail is
active; legacy path is unchanged. Held-key bindings (CameraZoomIn/Out,
CameraRaise/Lower; default-unbound) integrate distance/pitch at
CameraDiagnostics.CameraAdjustmentSpeed per second.

Default behavior: ACDREAM_RETAIL_CHASE unset -> legacy camera as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:17:49 +02:00
Erik
91086adbac feat(camera): InputAction + DebugVM surface for retail chase camera
Four new InputAction entries for held-key offset integration
(CameraZoomIn/Out, CameraRaise/Lower; default unbound). Six new
DebugVM mirror properties forwarding to CameraDiagnostics so the
upcoming "Chase camera" DebugPanel section can drive them live.

Also folds in four small cleanups from the Task 4 code review:
- Both CameraDiagnostics-mutating tests in CameraControllerTests now
  use try/finally save/restore (consistency with Task-3 follow-up B)
- Drop unused `using System.Numerics` from CameraControllerTests
- Reword the XML doc on CameraController.Active to explain WHY both
  cameras are held simultaneously (flag flip takes effect on the
  next Active access without re-entry) rather than restating the
  getter logic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:04:34 +02:00
Erik
e5a5916679 feat(camera): CameraController carries both legacy + retail chase cams
EnterChaseMode now takes (ChaseCamera, RetailChaseCamera); Active
consults CameraDiagnostics.UseRetailChaseCamera to pick which to
expose. Flag flip at runtime swaps cameras instantly (both are kept
warm). GameWindow's two EnterChaseMode call sites get a temporary
stub RetailChaseCamera; Task 7 wires proper construction +
per-frame updates.

Also folds in two minor cleanups from the Task 3 code review:
- Update() discards the unused `right` axis from BuildBasis (no
  caller in the chase-cam math; viewer_offset.X is always 0)
- The three CameraDiagnostics-mutating integration tests now
  save and restore the static state in try/finally to avoid
  ordering-dependent contamination

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:56:24 +02:00
Erik
0c1403f2e6 feat(camera): wire RetailChaseCamera Update() + tunables + state
Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint
that composes the math primitives into a renderable View matrix +
PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward
unit vector, first-frame snap flag, mouse-filter shared state.
Public surface: Distance/Pitch/YawOffset/PivotHeight tunables,
AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View +
Position + PlayerTranslucency outputs. 5 new integration tests, all
pass; total RetailChaseCamera test count 25.

Also folds in two minor cleanups from the Task 2 code review:
- AverageVelocity uses ring.Length instead of hardcoded 5
- Basis_NearVerticalHeading test asserts orthogonality of right & up

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:44:13 +02:00
Erik
8ebd33dc8f feat(camera): add RetailChaseCamera math primitives
Seven pure-math helpers in the new RetailChaseCamera class:
ComputeHeading (slope-align with flat fallback), BuildBasis (heading
→ orthonormal frame, near-vertical fallback), PushVelocity +
AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's
stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency
(linear ramp 0.20..0.45 m). 20 tests, all pass. State machine + Update()
land in the next commit.

Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:36:24 +02:00
Erik
3be700020b fix(physics): close #77 — auto-walk honors ACE CanCharge bit; zero velocity in turn-in-place
Two related close-range bugs reported in #77 share a root in
PlayerMovementController.DriveServerAutoWalk + BeginServerAutoWalk:

1. **Walk-vs-run misclassification.** 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) 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.** 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 — the "runs and slides away, runs back, picks up"
   symptom in #77 bug B.

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 case (a) initial-turn
  with stationary body; fixes (b) overshoot recovery and (c) settling
  cases.
- `GameWindow.OnLiveMotionUpdated`: passes
  `update.MotionState.CanCharge` through; [autowalk-begin] trace
  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.

Visual-verified at Holtburg 2026-05-18: walk-range NPC click walks +
fires Use, walk-range F-key pickup walks + no overshoot, far-range
(8-10 m) pickup still runs. Test baseline unchanged (8 Core pre-existing
failures, 0 net-new failures across Core/Net/UI/App suites).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:33:33 +02:00
Erik
0b25df53df refactor(app): extract LiveSessionController for network-side session lifecycle (Step 2)
Step 2 of the extraction sequence in docs/architecture/code-structure.md
§4. Lifts DNS resolution + WorldSession instantiation + wireEvents
callback + per-frame Tick + Dispose out of GameWindow.TryStartLiveSession
into a dedicated AcDream.App.Net.LiveSessionController.

What moves:
  - DNS resolution (IPAddress.TryParse + Dns.GetHostAddresses fallback,
    IPv4 preferred) → LiveSessionController.ResolveEndpoint
  - WorldSession instantiation → LiveSessionController.CreateAndWire
  - "live: connecting to ..." console line → CreateAndWire
  - Try/catch around the setup phase → CreateAndWire (separate from the
    Connect/EnterWorld try block that stays in GameWindow)
  - Per-frame _liveSession?.Tick() → _liveSessionController?.Tick()
  - OnClosing _liveSession?.Dispose() → _liveSessionController?.Dispose()

What stays in GameWindow:
  - The 25+ event subscriptions (extracted into a new private
    WireLiveSessionEvents method that the controller invokes via callback)
  - The Connect → CharacterList → EnterWorld → post-EnterWorld setup dance
    (touches Chat, _playerServerGuid, _vitalsVm, _worldState, _settingsStore,
    _settingsVm, _playerModeAutoEntry; moving these would balloon Step 2's
    scope and risk surface)
  - All 60+ outbound _liveSession.Send* call sites (touch the field by
    name; LiveSessionController.Session is the controller-side mirror)

The _liveSession field remains as a convenience handle synced with
_liveSessionController.Session; it tracks the same WorldSession instance.

Behavior preservation:
  - Same DNS-resolution sequence, same "live: connecting to ..." line,
    same wiring-vs-Connect ordering as pre-refactor.
  - Same 25+ event subscriptions in the same order, byte-for-byte.
  - Same Connect/EnterWorld error handling (the catch block stays in
    GameWindow because it disposes _combatChatTranslator which is also
    a GameWindow field).

Closes #76.

One subtle nullable-flow fix the compiler required: the chat-bus
lambda's `var liveSession = _liveSession;` capture became
`var liveSession = session;` (the non-null parameter) so the compiler
can prove non-null inside the lambda body. Both pointed to the same
WorldSession instance; only the static analysis changed.

Verification:
  - dotnet build green
  - dotnet test: AcDream.App.Tests 10/10, Core.Net.Tests 294/294,
    UI.Abstractions.Tests 419/419 — all green. Core.Tests 1073/1081
    (same 8 pre-existing physics failures as baseline; unrelated).
  - Live ACE session against +Acdream verified end-to-end:
    * Connection + handshake + EnterWorld
    * Door double-click → OnLiveMotionUpdated round-trip
      (cmd=0x000B open / cmd=0x000C close)
    * NPC double-click → outbound Use
    * Ground item F-key pickup × 4 successful (Amaranth, Comfrey,
      Damiana, Dragonsblood)
    * Spawn stream + chat channels + remote-entity motion all healthy
    * Clean OnClosing → controller.Dispose

Walking-range auto-walk + pickup-overshoot bugs observed during
verification are pre-existing (filed as #77); they live in
PlayerMovementController.DriveServerAutoWalk / threshold logic
which this refactor did not touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:15:57 +02:00
Erik
32423c2ba2 refactor(physics): promote ACDREAM_DUMP_STEEP_ROOF into PhysicsDiagnostics
First application of CLAUDE.md's new Code Structure Rules §5
("Runtime probes belong in diagnostic owner classes"). Migrates the
four ACDREAM_DUMP_STEEP_ROOF call-site env reads into a single
PhysicsDiagnostics.DumpSteepRoofEnabled property initialized from the
env var at type init, with a runtime setter that follows the existing
ProbeResolveEnabled / ProbeCellEnabled / ProbeBuildingEnabled pattern.

Sites migrated:
  - AcDream.Core/Physics/PhysicsEngine.cs:637 (KILL-VELOCITY-APPLIED log)
  - AcDream.Core/Physics/TransitionTypes.cs:718 (PHASE3-RESET log)
  - AcDream.App/Input/PlayerMovementController.cs:1117 (FRAME log)
  - AcDream.App/Input/PlayerMovementController.cs:1199 (per-frame bounce log)

Behavior-preservation only. ACDREAM_DUMP_STEEP_ROOF=1 still produces
identical [steep-roof] log output. The class-comment in
PhysicsDiagnostics already anticipated this migration
("Future slices may fold the older ACDREAM_DUMP_* env vars into this
class for unified runtime toggling").

Not yet wired to a DebugVM checkbox — runtime toggling is available
via the property setter for future debugging sessions, but exposing
it on the panel is a 30-second future cut, not in scope here.

Build: green.
Tests: same pass/fail profile as before this commit (8 pre-existing
Core failures unrelated to physics-diagnostics; App.Tests / Core.Net.Tests
/ UI.Abstractions.Tests all green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:20:00 +02:00
Erik
eda936dc4d refactor(app): extract typed RuntimeOptions for startup env vars (Step 1)
Lifts 13 startup-time environment variables out of GameWindow.cs into a
single typed AcDream.App.RuntimeOptions record read once in Program.cs.
Behavior-preservation only — no live behavior change, no visual change.
Verified end-to-end against ACE on 127.0.0.1:9000: full M1 demo loop
(walk Holtburg, click door, click NPC, portal entry) plus DEVTOOLS
ImGui panels load cleanly.

Why: GameWindow.cs is 10,304 LOC and scattered Environment.GetEnvironmentVariable
calls were one of the structural smells called out in the new
"Code Structure Rules" doc. Typed options is the safest cut to make
first because the substitution is mechanical and parsing semantics
get pinned by unit tests.

What lands:
  - CLAUDE.md: removed stale R1→R8 execution-phases line, replaced with
    pointers to the milestones doc + strategic roadmap (the actual
    source of truth). Tightened the "check ALL FOUR references"
    section to describe WB as the production rendering base, not
    just a reference. New "Code Structure Rules" section (6 rules)
    captures the discipline we're committing to.
  - docs/architecture/acdream-architecture.md: removed dangling link
    to the deleted memory/project_ui_architecture.md.
  - docs/architecture/code-structure.md (NEW, 376 LOC): rationale for
    the 6 rules + 6-step extraction sequence
    (RuntimeOptions → LiveSessionController → LiveEntityRuntime →
    SelectionInteractionController → RenderFrameOrchestrator →
    GameEntity aggregation). This PR is Step 1.
  - src/AcDream.App/RuntimeOptions.cs (NEW, 100 LOC): typed record
    with FromEnvironment(string) factory and Parse(datDir, env)
    overload for testability. Covers ACDREAM_LIVE, _TEST_HOST/PORT/
    USER/PASS, _DEVTOOLS, _DUMP_MOVE_TRUTH, _NO_AUDIO,
    _ENABLE_SKY_PES, _HIDE_PART, _RETAIL_CLOSE_DEGRADES,
    _DUMP_SCENERY_Z, _STREAM_RADIUS.
  - src/AcDream.App/Program.cs: builds RuntimeOptions once, passes
    to GameWindow.
  - src/AcDream.App/Rendering/GameWindow.cs: ctor takes RuntimeOptions;
    7 startup-cached env-var fields become expression-bodied
    properties or direct _options.X reads; TryStartLiveSession,
    audio init, legacy stream-radius branch all route through
    _options.
  - tests/AcDream.App.Tests/ (NEW project, 10 unit tests + csproj):
    pins parser semantics — default-off bools, the literal "0"
    gate for RETAIL_CLOSE_DEGRADES, the >=0 guard for
    STREAM_RADIUS, null-vs-empty for user/pass, exact-"1" check
    for diagnostic flags. Registered in AcDream.slnx.

Out of scope (per code-structure.md §4):
  - Per-call-site ACDREAM_DUMP_* / _REMOTE_VEL_DIAG diagnostic reads
    sprinkled through GameWindow (~40 sites). Rule 5 in CLAUDE.md
    commits us to migrating these opportunistically as larger
    extractions land, not in a bulk pass.
  - AcDream.Core's project-reference to Chorizite.OpenGLSDLBackend.
    Only the stateless .Lib namespace is used; tightening the project
    reference is documented as future work in code-structure.md §2.

Build: green.
Tests: AcDream.App.Tests 10/10 ✓, Core.Net.Tests 294/294 ✓,
       UI.Abstractions.Tests 419/419 ✓,
       AcDream.Core.Tests 1073/1081 (8 pre-existing failures verified
       against pre-refactor baseline by stash-and-rerun).

Visual verification: full M1 demo loop against ACE +Acdream login
including DEVTOOLS panel host load.

Next: Step 2 — extract LiveSessionController per code-structure.md §4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:16:55 +02:00
Erik
d640ed74e1 feat(retail): Phase B.6 — server-driven auto-walk done right
Closes #63, #69, #74, #75. Replaces 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 is
architectural — auto-walk drives the body directly from the
server-supplied path data, no player-input synthesis, no spurious
wire-packet transitions, no grace-period band-aid.

Architectural change (closes #75):
  PlayerMovementController.ApplyAutoWalkOverlay → DriveServerAutoWalk.
  - Steps Yaw toward target at retail-faithful turn rates.
  - Computes desired forward velocity from path runRate.
  - Calls _motion.DoMotion(WalkForward, speed) directly for the
    motion-interpreter state (drives animation cycle).
  - Sets _body.set_local_velocity directly when grounded.
  - Returns true to gate the user-input motion + velocity section
    in Update so user-input flow doesn't overwrite auto-walk
    velocity or motion state.
  Mirrors retail's MovementManager::PerformMovement case 6 (decomp
  0x00524440) which never touches the user-input pipeline during
  server-controlled auto-walk.

Wire-layer guard at GameWindow.cs:6419 retained as a SEMANTIC
statement (`if (result.MotionStateChanged && !IsServerAutoWalking)`):
user-MoveToState packets are for user-driven motion intent. During
server-controlled auto-walk, the motion-state transitions caused by
the animation override (RunForward / WalkForward / TurnLeft /
TurnRight cycles) must not leak as user-cancellation packets. This
is NOT the deleted 500ms grace-period band-aid; it's the wire-layer
expressing the user-vs-server motion split.

Animation plumbed for auto-walk phases (closes #69):
  - Moving forward → WalkForward (speed=1.0) / RunForward (speed=runRate)
  - Turn-first phase → TurnLeft / TurnRight (sign of yawStep)
  - Aligned-but-pre-step / arrival → no override (idle)
Driven via _autoWalkMovingForwardThisFrame + _autoWalkTurnDirectionThisFrame
fields set in DriveServerAutoWalk and read in the MovementResult
construction at the bottom of Update. UpdatePlayerAnimation picks up
the localAnimCmd as the highest-priority animation source.

Walk/run threshold = 1.0m, retail-observed. ACE's wire-default of
15.0f is too generous; ACE's own physics layer uses 1.0f at
MovementParameters.cs:50 (with the 15.0f line commented out) and
Creature.cs:312 notes "default 15 distance seems too far". The
formula matches retail's MovementParameters::get_command at decomp
0x0052aa00: running = (initialDist - distance_to_object) >=
threshold, evaluated ONCE at chain start and held for the rest of
the auto-walk (matches retail "runs all the way / walks all the way"
behaviour). Wire-supplied threshold is ignored.

Pickup gate (IsPickupableTarget) now uses BF_STUCK
(acclient.h:6435, bit 0x4) to discriminate immovable scenery from
real pickup items that share a Misc ItemType. Sign (pwd=0x14 with
BF_STUCK) → blocked; spell component (pwd=0x10, no BF_STUCK) →
allowed. ACE's PutItemInContainer (Player_Inventory.cs:831-836)
responds with WeenieError.Stuck (0x29) on stuck items so the gate
prevents wasted wire packets + a UX dead-end.

R-key dispatch by target type. UseCurrentSelection's top-level
IsUseableTarget gate was wrong (blocked USEABLE_NO=1 items that
ARE pickupable). Reordered:
  1. Creature → SendUse
  2. Pickupable → SendPickUp
  3. Useable → SendUse
  4. Otherwise → "cannot be used" toast
Each handler keeps its own gate. Matches retail's per-action
server-side validation.

AP cadence revert (closes #74). With the MoveToChain race fixed,
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):
  Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed.
  Interval elapsed (>= 1 sec):    send if cell or position frame changed.
Adds _lastSentContactPlane field + ApproxPlaneEqual helper +
PlayerMovementController.ContactPlane public accessor. Extended
NotePositionSent(Vector3, uint, Plane, float) — both outbound sites
(MoveToState + AP) pass _playerController.ContactPlane.
Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on
cell/plane changes, 0 Hz airborne.

CLAUDE.md updated with no-workarounds rule (commit `da126f9` on
the worktree branch). Saved as feedback memory at
memory/feedback_no_workarounds.md.

Tests: build green; Core.Net 294/294; Core 1073/1081 (baseline,
8 pre-existing Physics failures unchanged). Visual-verified
end-to-end on 2026-05-16 for far/near Use + PickUp on NPCs,
doors, items, spell components, signs (correctly blocked), corpses,
turn-first animation, run/walk thresholds, idle quiet, smooth-
motion 1Hz.

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>
2026-05-16 16:14:44 +02:00