Compare commits

...

2 commits

Author SHA1 Message Date
Erik
96a425a9a5 fix #108-residual (root cause): terrain drew DOUBLE-SIDED - port retail landPolysDraw eye-side gate as terrain backface cull
The cellar-ascent grass window was the UNDERSIDE of the z~94 grade
sheet. Retail terrain is single-sided: ACRender::landPolysDraw
(0x006b7040) draws each land triangle ONLY when the camera is on the
POSITIVE (upper) side of its plane (Plane::which_side2 vs
Render::FrameCurrent, zFightTerrainAdjust bias) - a below-grade eye
gets NO terrain, so retail shows sky through the cellar door.

We inherited WB's frame-global cull DISABLE (WB GameScene.cs:841 - an
editor camera goes underground by design) and TerrainModernRenderer.Draw
set no cull state of its own -> terrain rasterized both sides. From a
below-grade eye every aperture sight-ray RISES, so the only 'terrain'
it can see is the grade sheet's underside - which painted the exit-door
aperture (the landscape slice's 2D NDC clip planes (nx,ny,0,dw) have no
depth axis and cannot exclude between-eye-and-portal geometry) and slid
off the door exactly as the eye crossed grade. Membership/viewer was
exonerated by the harness in the previous commit.

Fix: TerrainModernRenderer.Draw owns its cull state (the 7th
self-contained-GL-state instance): Enable(CullFace) + CullFace(Back) +
FrontFace(Ccw), set -> draw -> restore the frame-global CW + cull-off
baseline. GL backface culling evaluates retail's per-triangle eye-side
predicate at rasterization; no shader change.

Pins:
- LandblockMeshTests.Build_AllTriangles_WindCounterClockwiseInWorldXY:
  every emitted triangle CCW in world XY across both FSplitNESW split
  directions - the winding invariant culling depends on.
- TerrainCullOrientationTests: under the production camera convention
  (LookAt up=+Z, Numerics perspective) an up-facing triangle winds CCW
  in window space from above (kept) and CW from below (culled) - guards
  FrontFace inversion, which would blank terrain from above.

Oracle note: retail's through-portal clip has NO portal-face near plane
(PView::GetClip / Render::set_view install edge planes only); nearer-
than-portal exclusion comes from the eye-side cull + cell-level
admission. No register row: this PORTS the retail mechanism, retiring
an undocumented WB-heritage deviation.

Gate pending: cellar climb (grass window gone) + outdoor sanity glance
(terrain intact from above).

Suites: App 263+1skip / Core 1443+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:31 +02:00
Erik
007af1391c #108-residual apparatus: vertical cellar-ascent viewer harness - membership/viewer layer EXONERATED
The handoff's 'eye-below-grade membership demote' diagnosis is REFUTED.
The harness drives the production stack headlessly per step of the
A9B4 corner-building cellar ascent (0x0174 -> 0x0175 -> 0x0171, path
fitted from the cellar-up live captures): FindCellList on the
foot-sphere center for the player pick + the PhysicsCameraCollisionProbe
SweepEye chain mirrored verbatim (AdjustPosition at pivot ->
ResolveWithTransition IsViewer|PathClipped|FreeRotate|PerfectClip ->
both fallbacks) with per-step branch attribution.

Result: 0 outdoor/null viewer resolutions while the eye is below grade,
0 sweep failures, 0 fallback branches, across boom distance {2.61, 5}
x damping lag {0, 0.3 m}. The viewer enters the main-floor room at eye
z 94.01 - exactly as the head pops above grade (the stairwell portal
sits AT grade), matching the user's report wording. The root is
INTERIOR for the whole grass window; #108-residual is render-side
(fix in the next commit). Tests stay as the healthy-layer
characterization pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:06 +02:00
5 changed files with 524 additions and 19 deletions

View file

@ -3701,27 +3701,47 @@ Unverified. The likely culprits, ranked by suspected probability:
--- ---
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual] ## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [FIX SHIPPED 2026-06-12 · awaiting cellar visual gate]
**Status:** REOPENED (narrowed) — the broad symptom is GONE (T5 + **Status:** FIX SHIPPED (desk-pinned) — root cause found 2026-06-12; the
re-gate #2: "Yes, but…"), but a residual remains in ONE window: during cellar-ascent visual gate is pending.
the cellar ASCENT, while the eye is still below ground level, the
upstairs exit-door opening is covered with grass — "like the ground **ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was
level rose to the top of the door … as soon as my head pops up it falls the UNDERSIDE of the grade sheet.** Two steps:
back to ground level" (user, re-gate 2026-06-11). The original 1. The membership/viewer re-diagnosis below is **REFUTED** by the vertical
BR-2-era diagnosis stands: grass-sweep frames render through the cellar-ascent harness (`Issue108CellarAscentViewerReplayTests`, dat-backed
OUTDOOR root (membership/viewer-cell flips outdoor mid-cellar), and the A9B4 corner-building cellar 0x0174→0x0175→0x0171, production
#117 depth-gated punch then correctly refuses to punch the aperture FindCellList pick + the camera probe chain mirrored verbatim): 0
where terrain depth is NEARER than the door fan (eye below grade ⇒ the outdoor/null viewer resolutions while the eye is below grade, 0 sweep
visible front-facing terrain can sit between the eye and the door in failures, 0 fallback branches across boom distance {2.61, 5} × damping
depth). The punch must STAY depth-gated (DO-NOT-RETRY) — the fix is on lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the
the membership/viewer side (why is the root outdoor while the eye is in head pops above grade (the stairwell portal sits at grade), matching the
the cellar stairwell below grade?). Apparatus shape: a vertical user's wording. The root is INTERIOR the whole window.
cellar-ascent variant of the #118 exit-walk harness (drive the eye up 2. Retail terrain is SINGLE-SIDED: `ACRender::landPolysDraw` (0x006b7040)
the stair path; log root resolution + the punch's mark-pass outcome per draws each land triangle ONLY when the camera is on the POSITIVE (upper)
step). Prior history below. side of its plane (`Plane::which_side2` vs `Render::FrameCurrent`). A
below-grade eye gets NO terrain — through the door retail shows sky.
WB renders the world with face culling DISABLED frame-globally (WB
`GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw`
set no cull state of its own → terrain drew double-sided. From a
below-grade eye every aperture sight-ray RISES, so the only "terrain" it
can see is the underside of the z≈94 grade sheet — which painted the
whole exit-door aperture (the landscape slice's 2D NDC clip planes
`(nx,ny,0,dw)` have no depth axis and cannot exclude it) and slid down
off the door exactly as the eye crossed grade.
**Fix: port the landPolysDraw eye-side gate as terrain backface culling**
`TerrainModernRenderer.Draw` now owns Enable(CullFace) + Cull(Back) +
FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL-
state rule). Pins: `LandblockMeshTests.Build_AllTriangles_WindCounter-
ClockwiseInWorldXY` (every emitted triangle CCW in world XY — cull-safe
winding) + `TerrainCullOrientationTests` (above-eye ⇒ CCW window winding
kept / below-eye ⇒ CW culled under the production camera convention).
**Gate:** climb out of the corner-building cellar — the grass window over
the exit door must be gone (sky/world through the door instead); plus a
general outdoor sanity glance (terrain intact from above — a wrong
FrontFace would blank it).
**Severity:** MEDIUM **Severity:** MEDIUM
**Component:** ~~render / indoor PView~~**physics / membership** (cellar-transition root flip) **Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED
During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor
shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the

View file

@ -283,6 +283,27 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// when wired, else the no-clip fallback (count 0 = ungated terrain). // when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2(); BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
// evaluates the same per-triangle eye-side predicate at rasterization.
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.BindVertexArray(_globalVao); _gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit); _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect( _gl.MultiDrawElementsIndirect(
@ -292,6 +313,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
(uint)sizeof(DrawElementsIndirectCommand)); (uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0); _gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
} }
public void Dispose() public void Dispose()

View file

@ -0,0 +1,82 @@
using System;
using System.Numerics;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #108-residual orientation pin: TerrainModernRenderer culls terrain back
/// faces with FrontFace(Ccw) — the GL port of retail's single-sided terrain
/// (ACRender::landPolysDraw 0x006b7040: a land triangle draws ONLY when the
/// camera is on the POSITIVE side of its plane via Plane::which_side2).
///
/// The FrontFace choice rests on one mapping fact: under the production
/// camera convention (Matrix4x4.CreateLookAt with up = world +Z, Numerics
/// CreatePerspectiveFieldOfView — RetailChaseCamera.cs:203 / :52), an
/// UP-FACING terrain triangle that LandblockMesh emits CCW in world XY
/// rasterizes
/// · CCW in NDC/window space when the eye is ABOVE its plane (kept), and
/// · CW when the eye is BELOW (culled — retail draws nothing there: from
/// a below-grade cellar eye the door aperture shows sky, never grass).
/// This test pins that mapping in pure CPU math so a projection-convention
/// change (handedness, Y-flip) can't silently invert the cull and either
/// resurrect the #108 grass window or cull terrain from above.
/// </summary>
public class TerrainCullOrientationTests
{
// An up-facing triangle, CCW in world XY viewed from above — the exact
// emission convention pinned by LandblockMeshTests (crossZ > 0).
private static readonly Vector3[] Triangle =
{
new(-1f, 10f, 94f),
new( 1f, 10f, 94f),
new( 1f, 12f, 94f),
};
private static float NdcSignedArea2(Vector3 eye, Vector3 forward)
{
// The production camera shape: look-at with world-Z up
// (RetailChaseCamera.cs:203), Numerics perspective with the retail
// znear 0.1 (RetailChaseCamera.cs:52).
var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
var viewProj = view * proj;
Span<Vector2> ndc = stackalloc Vector2[3];
for (int i = 0; i < 3; i++)
{
var c = Vector4.Transform(new Vector4(Triangle[i], 1f), viewProj);
Assert.True(c.W > 1e-3f, "test triangle must be in front of the eye");
ndc[i] = new Vector2(c.X / c.W, c.Y / c.W);
}
// Twice the signed area: > 0 = CCW in NDC (GL window space keeps the
// orientation — NDC y up maps to window y up, no flip).
return (ndc[1].X - ndc[0].X) * (ndc[2].Y - ndc[0].Y)
- (ndc[1].Y - ndc[0].Y) * (ndc[2].X - ndc[0].X);
}
[Fact]
public void EyeAboveTerrainPlane_WindsCcw_FrontFaceKept()
{
// Eye above grade looking forward-down at the triangle (the normal
// outdoor view). Retail: which_side2 = POSITIVE → drawn.
float area = NdcSignedArea2(new Vector3(0f, 5f, 96.5f), new Vector3(0f, 1f, -0.3f));
Assert.True(area > 0f,
$"above-plane eye must see the terrain triangle CCW (area2={area}) — " +
"FrontFace(Ccw)+Cull(Back) would otherwise cull terrain from above");
}
[Fact]
public void EyeBelowTerrainPlane_WindsCw_BackfaceCulled()
{
// Eye below grade (the cellar-stairwell window) looking up-forward at
// the underside. Retail: which_side2 = NEGATIVE → not drawn at all —
// the #108 grass that covered the exit door was exactly this
// underside rasterizing when culling was left disabled.
float area = NdcSignedArea2(new Vector3(0f, 5f, 92.5f), new Vector3(0f, 1f, 0.2f));
Assert.True(area < 0f,
$"below-plane eye must see the terrain triangle CW (area2={area}) — " +
"it must backface-cull like retail's which_side2 eye-side gate");
}
}

View file

@ -0,0 +1,344 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #108-residual vertical exit-walk harness (2026-06-12): the cellar-ascent
/// grass window. Climbing out of the Holtburg corner-building cellar
/// (0xA9B40174 room, floor z≈90 → 0x0175 staircase/lip → 0x0171 main floor at
/// z=94 = outdoor grade), the upstairs exit door is covered with grass until
/// the eye pops above grade. Punch/seal are exonerated (BR-2 experiment +
/// #117); the grass requires the frame to render through the OUTDOOR root —
/// i.e. the VIEWER-CELL resolution demotes to outdoor/null while the eye is
/// still below terrain grade inside the stairwell.
///
/// This harness drives the PRODUCTION viewer-resolution stack headlessly per
/// step of a kinematic ascent (the #118 HouseExitWalkReplayTests pattern,
/// turned vertical):
/// player cell — CellTransit.FindCellList on the foot-sphere center (the
/// production controller pick),
/// viewer cell — the PhysicsCameraCollisionProbe.SweepEye chain mirrored
/// verbatim (CameraCornerSealReplayTests provenance):
/// AdjustPosition at the head pivot → ResolveWithTransition
/// (IsViewer|PathClipped|FreeRotate|PerfectClip, 0.3 m
/// viewer_sphere) → fallback 1 AdjustPosition at the sought
/// eye → fallback 2 (player_pos, cell 0).
/// Each step records WHICH branch produced the viewer cell, so a demote
/// self-attributes:
/// A. sweep Ok=false → fallback chain (AdjustPosition's SeenOutside
/// fall-through is an XY-only grid snap — no Z test — so an in-dirt
/// below-grade eye can return an OUTDOOR cell with found=true);
/// B. sweep end-cell pick demotes (exterior-portal straddle + containment
/// miss at the stopped eye);
/// C. the start-cell AdjustPosition at the pivot demotes;
/// D. all healthy here → the bug is upstream (App camera damping /
/// GameWindow TryGetCell consumption).
///
/// Ascent path: fitted from the live captures (cellar-up-capture*.jsonl band
/// centroids, analyze_108_stairline.py): stairs at x≈153.9 ascending +Y,
/// z = 90.0 (y≤5.7) → 0.836·(y5.73)+90.25 (stairs) → lip 93.25→94 over
/// y 9.3→10.4 → main floor 94.0. The boom (retail defaults: distance 2.61,
/// pitch 0.291, pivot feet+1.5) trails SOUTH into the stairwell — mid-stairs
/// the desired eye sits beyond the cellar's south wall (y≈4.87) and above its
/// ceiling: in no-cell dirt below grade. Stub terrain (1000) — the membership
/// pick never reads terrain height (XY-column only), which is exactly the
/// mechanism under test.
///
/// ── RESULT (2026-06-12): the MEMBERSHIP/VIEWER LAYER IS EXONERATED ──────
/// 0 grass-window steps, 0 sweep failures, 0 fallback branches across boom
/// distance {2.61, 5.0} × damping lag {0, 0.3 m}. The viewer resolves
/// 0x0174 → 0x0175 (eye z 93.65, below grade) → 0x0171 at eye z 94.01 —
/// the viewer enters the main-floor room EXACTLY as the head pops above
/// grade (the stairwell portal sits at grade), matching the user's wording.
/// The handoff's "it is MEMBERSHIP/VIEWER-side" diagnosis is therefore
/// REFUTED for the current pipeline; #108-residual is RENDER-side: the
/// landscape slice clips terrain by 2D NDC planes only ((nx,ny,0,dw) —
/// ClipFrame.cs:178, terrain_modern.vert:173), so terrain BETWEEN the eye
/// and the exit portal (the grade sheet at z≈94, which from a below-grade
/// eye projects into the aperture band at y 9.817) paints the doorway.
/// These tests stay as the characterization pin for the healthy layer.
/// </summary>
public class Issue108CellarAscentViewerReplayTests
{
private readonly ITestOutputHelper _out;
public Issue108CellarAscentViewerReplayTests(ITestOutputHelper output) => _out = output;
private const float ViewerSphereRadius = 0.3f; // retail viewer_sphere (acclient :93314)
private const float PivotHeight = 1.5f; // RetailChaseCamera.PivotHeight
private const float FootRadius = 0.48f; // player foot sphere
private const float BoomDistance = 2.61f; // retail viewer_offset length
private const float BoomPitch = 0.291f; // retail default pitch (16.7°)
private const float GradeZ = 94.0f; // cottage floor == door sill ≈ outdoor terrain grade
private const uint Lb = 0xA9B40000u; // ConformanceDats.HoltburgLandblock
private const uint CellarRoom = Lb | 0x0174u; // floor z≈90.0
private const uint MainFloor = Lb | 0x0171u; // z=94.0
// ── fixture ─────────────────────────────────────────────────────────
private static (PhysicsEngine engine, PhysicsDataCache cache,
Dictionary<uint, AcDream.Core.World.Cells.EnvCell> envCells)
BuildEngine(DatCollection dats)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var envCells = new Dictionary<uint, AcDream.Core.World.Cells.EnvCell>();
// Full A9B4 interior set (Issue112MembershipTests.LoadLandblockInteriors
// pattern) — the ascent's pick walk may reach cells outside the corner
// building's 0x016F-0x0175 range.
for (uint low = 0x0100u; low <= 0x01FFu; low++)
{
try { envCells[Lb | low] = ConformanceDats.LoadEnvCell(dats, cache, Lb | low); }
catch { }
}
// Buildings exactly as production registers them (Issue112MembershipTests.
// RegisterBuildings provenance): portals → BldPortalInfo with sign-extended
// OtherPortalId; landcell id from the building Frame.Origin (retail
// row-major grid).
var lbInfo = dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(Lb | 0xFFFEu);
Assert.NotNull(lbInfo);
foreach (var building in lbInfo!.Buildings)
{
if (building.Portals.Count == 0) continue;
var portals = new List<BldPortalInfo>(building.Portals.Count);
foreach (var bp in building.Portals)
portals.Add(new BldPortalInfo(
otherCellId: Lb | (uint)bp.OtherCellId,
otherPortalId: unchecked((short)bp.OtherPortalId),
flags: (ushort)bp.Flags));
var transform =
Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) *
Matrix4x4.CreateTranslation(building.Frame.Origin);
int gridX = (int)(building.Frame.Origin.X / 24f);
int gridY = (int)(building.Frame.Origin.Y / 24f);
uint landcellLow = (uint)(gridX * 8 + gridY + 1);
cache.CacheBuilding(Lb | landcellLow, portals, transform);
}
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(Lb, new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
return (engine, cache, envCells);
}
// ── the probe mirror (PhysicsCameraCollisionProbe.SweepEye, verbatim) ──
private enum ViewerBranch { Sweep, AdjustFallback, NullFallback }
private sealed record ViewerResolve(
Vector3 Eye, uint ViewerCellId, ViewerBranch Branch,
uint StartCell, bool PivotAdjustFound, ResolveResult Sweep);
private static ViewerResolve ResolveViewer(
PhysicsEngine engine, Vector3 pivot, Vector3 desiredEye, uint cellId, Vector3 playerPos)
{
// update_viewer (pc:92775): no player cell → snap to player, viewer_cell null.
if (cellId == 0u)
return new ViewerResolve(playerPos, 0u, ViewerBranch.NullFallback, 0u, false, default);
uint startCell = cellId;
bool pivotFound = false;
if ((cellId & 0xFFFFu) >= 0x0100u)
{
var (pivotCell, found) = engine.AdjustPosition(cellId, pivot);
pivotFound = found;
if (found) startCell = pivotCell;
}
Vector3 begin = pivot - new Vector3(0f, 0f, ViewerSphereRadius);
Vector3 end = desiredEye - new Vector3(0f, 0f, ViewerSphereRadius);
var r = engine.ResolveWithTransition(
currentPos: begin,
targetPos: end,
cellId: startCell,
sphereRadius: ViewerSphereRadius,
sphereHeight: 0f,
stepUpHeight: 0f,
stepDownHeight: 0f,
isOnGround: false,
body: null,
moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped
| ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip,
movingEntityId: 0);
Vector3 eye = r.Position + new Vector3(0f, 0f, ViewerSphereRadius);
if (r.Ok)
return new ViewerResolve(eye, r.CellId, ViewerBranch.Sweep, startCell, pivotFound, r);
var (eyeCell, eyeFound) = engine.AdjustPosition(cellId, desiredEye);
if (eyeFound)
return new ViewerResolve(desiredEye, eyeCell, ViewerBranch.AdjustFallback, startCell, pivotFound, r);
return new ViewerResolve(playerPos, 0u, ViewerBranch.NullFallback, startCell, pivotFound, r);
}
// ── the ascent ──────────────────────────────────────────────────────
/// <summary>Stair-line feet Z for a path y (fitted from the capture bands).</summary>
private static float FeetZ(float y)
{
if (y < 5.73f) return 90.0f;
if (y < 9.30f) return MathF.Min(90.25f + 0.836f * (y - 5.73f), 93.25f);
if (y < 10.40f) return 93.25f + (y - 9.30f) * (0.75f / 1.10f);
return 94.0f;
}
private sealed record Step(
int Index, Vector3 Feet, uint PlayerCell,
ViewerResolve Viewer, uint EyeContainedIn, bool EyeBelowGrade)
{
public bool ViewerOutdoorOrNull =>
Viewer.ViewerCellId == 0u || (Viewer.ViewerCellId & 0xFFFFu) < 0x0100u;
}
private List<Step>? RunAscent(float boomDistance, float pathLagMeters)
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var (engine, _, envCells) = BuildEngine(dats);
const float yStart = 5.2f, yEnd = 16.0f;
const float stepLen = 0.02f; // 2 cm/frame ≈ 1.2 m/s at 60 Hz
var fwd = new Vector3(0f, 1f, 0f); // facing up the stairs / at the exit door
float cosP = MathF.Cos(BoomPitch), sinP = MathF.Sin(BoomPitch);
// Stairs run at x≈153.9; past the lip the real walk line bends to the
// exit-door approach at x≈155 (corner-seal capture S1: player
// (154.93, 16.45)) — walking straight north at 153.9 ends in the wall
// beside the 0x0170 doorway, which a live player cannot do.
static float FeetX(float y) =>
y <= 10.4f ? 153.9f
: y >= 14.0f ? 155.0f
: 153.9f + (y - 10.4f) / (14.0f - 10.4f) * (155.0f - 153.9f);
var steps = new List<Step>();
uint playerCell = CellarRoom;
int count = (int)MathF.Round((yEnd - yStart) / stepLen);
for (int i = 0; i <= count; i++)
{
float y = yStart + i * stepLen;
var feet = new Vector3(FeetX(y), y, FeetZ(y));
// production controller pick: foot-sphere CENTER, seeded with the carried cell
playerCell = CellTransit.FindCellList(
engine.DataCache!, feet + new Vector3(0f, 0f, FootRadius), FootRadius, playerCell);
// boom target — optionally computed from a lagged path point to model the
// exponential damping trail (≈0.27 m at climb speed; 0 = converged target)
float yBoom = MathF.Max(yStart, y - pathLagMeters);
var boomFeet = new Vector3(FeetX(yBoom), yBoom, FeetZ(yBoom));
var pivot = feet + new Vector3(0f, 0f, PivotHeight);
var boomPivot = boomFeet + new Vector3(0f, 0f, PivotHeight);
var desiredEye = boomPivot - fwd * (boomDistance * cosP)
+ new Vector3(0f, 0f, boomDistance * sinP);
var viewer = ResolveViewer(engine, pivot, desiredEye, playerCell, feet);
uint containedIn = 0u;
foreach (var (id, env) in envCells)
if (env.PointInCell(viewer.Eye)) { containedIn = id; break; }
steps.Add(new Step(i, feet, playerCell, viewer,
containedIn, viewer.Eye.Z < GradeZ - 0.05f));
}
return steps;
}
private void DumpStep(Step s)
{
var v = s.Viewer;
string line = FormattableString.Invariant(
$"step={s.Index,3} feet=({s.Feet.X:F2},{s.Feet.Y:F2},{s.Feet.Z:F2}) pCell=0x{s.PlayerCell & 0xFFFFu:X4} start=0x{v.StartCell & 0xFFFFu:X4}{(v.PivotAdjustFound ? "" : "!")} branch={v.Branch} ok={v.Sweep.Ok} eye=({v.Eye.X:F2},{v.Eye.Y:F2},{v.Eye.Z:F2}) viewer=0x{v.ViewerCellId & 0xFFFFu:X4} eyeIn=0x{s.EyeContainedIn & 0xFFFFu:X4} belowGrade={(s.EyeBelowGrade ? "Y" : "n")}");
if (s.EyeBelowGrade && s.ViewerOutdoorOrNull) line += " << GRASS-WINDOW";
_out.WriteLine(line);
}
// ── diagnostics + pins ──────────────────────────────────────────────
/// <summary>
/// Full per-step table of the ascent at retail boom defaults (converged
/// boom, no lag). Read this first — the GRASS-WINDOW marks name the steps
/// where the production stack resolves an outdoor/null viewer with the eye
/// below grade, and the branch column attributes the demote site.
/// </summary>
[Fact]
public void Diagnostic_CellarAscent_PerStepTable()
{
var steps = RunAscent(BoomDistance, pathLagMeters: 0f);
if (steps is null) return;
uint lastPlayer = 0; uint lastViewer = 0xFFFFFFFFu; var lastBranch = (ViewerBranch)(-1);
int suspicious = 0;
foreach (var s in steps)
{
bool grass = s.EyeBelowGrade && s.ViewerOutdoorOrNull;
if (grass) suspicious++;
if (s.PlayerCell != lastPlayer || s.Viewer.ViewerCellId != lastViewer
|| s.Viewer.Branch != lastBranch || grass || s.Index % 50 == 0)
DumpStep(s);
lastPlayer = s.PlayerCell; lastViewer = s.Viewer.ViewerCellId; lastBranch = s.Viewer.Branch;
}
_out.WriteLine(FormattableString.Invariant(
$"--- {suspicious}/{steps.Count} steps in the grass window (viewer outdoor/null while eye below grade) ---"));
}
/// <summary>Boom-distance + damping-lag sweep: how wide is the window across poses?</summary>
[Fact]
public void Diagnostic_CellarAscent_PoseSweep()
{
foreach (float dist in new[] { 2.61f, 5.0f })
foreach (float lag in new[] { 0f, 0.30f })
{
var steps = RunAscent(dist, lag);
if (steps is null) return;
int grass = steps.FindAll(s => s.EyeBelowGrade && s.ViewerOutdoorOrNull).Count;
int okFalse = steps.FindAll(s => !s.Viewer.Sweep.Ok).Count;
int fb = steps.FindAll(s => s.Viewer.Branch != ViewerBranch.Sweep).Count;
_out.WriteLine(FormattableString.Invariant(
$"dist={dist:F2} lag={lag:F2}: grassWindow={grass}/{steps.Count} sweepOkFalse={okFalse} fallbackBranch={fb}"));
}
}
/// <summary>
/// THE PIN: while the eye is below terrain grade on the cellar ascent, the
/// viewer must resolve INTERIOR — an outdoor/null viewer cell roots the
/// frame at the landscape and sweeps grass across the exit door (#108).
/// Retail's viewer rides the stairwell cells here (the cellar camera works
/// in retail); below grade inside the building footprint there is no
/// legitimate outdoor viewer.
/// </summary>
[Fact]
public void CellarAscent_ViewerStaysInterior_WhileEyeBelowGrade()
{
var steps = RunAscent(BoomDistance, pathLagMeters: 0f);
if (steps is null) return;
var failures = steps.FindAll(s => s.EyeBelowGrade && s.ViewerOutdoorOrNull);
if (failures.Count > 0)
{
_out.WriteLine($"--- {failures.Count} grass-window steps ---");
foreach (var s in failures) DumpStep(s);
}
Assert.True(failures.Count == 0,
$"{failures.Count}/{steps.Count} ascent steps resolve an outdoor/null viewer cell while the eye " +
"is below grade — the #108 grass window (see output for the branch attribution)");
}
}

View file

@ -169,6 +169,41 @@ public class LandblockMeshTests
Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}"); Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}");
} }
[Fact]
public void Build_AllTriangles_WindCounterClockwiseInWorldXY()
{
// #108-residual winding pin: TerrainModernRenderer enables backface
// culling with FrontFace(Ccw) — the GL port of retail's single-sided
// terrain (ACRender::landPolysDraw 0x006b7040 draws a land triangle
// only when the eye is on the POSITIVE side of its plane). That cull
// is only correct if EVERY emitted triangle winds the same way:
// counter-clockwise in world XY viewed from above (+Z toward the
// viewer), i.e. cross2D(v1-v0, v2-v0) > 0. Varied heights + several
// landblock coords exercise both FSplitNESW split directions across
// the 64 cells. A future emission-order change that flips any
// triangle would silently punch terrain holes under culling.
var block = BuildFlatLandBlock();
for (int i = 0; i < 81; i++)
block.Height[i] = (byte)((i * 37) % 64); // varied, deterministic slopes
foreach (var (lbx, lby) in new[] { (0u, 0u), (0xA9u, 0xB4u), (3u, 7u) })
{
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, lbx, lby, IdentityHeightTable, MakeContext(), cache);
for (int t = 0; t < mesh.Indices.Length; t += 3)
{
var p0 = mesh.Vertices[mesh.Indices[t + 0]].Position;
var p1 = mesh.Vertices[mesh.Indices[t + 1]].Position;
var p2 = mesh.Vertices[mesh.Indices[t + 2]].Position;
float crossZ = (p1.X - p0.X) * (p2.Y - p0.Y) - (p1.Y - p0.Y) * (p2.X - p0.X);
Assert.True(crossZ > 0f,
$"lb=({lbx},{lby}) triangle {t / 3} winds CW in world XY (crossZ={crossZ}) — " +
"backface culling in TerrainModernRenderer would cull its TOP side");
}
}
}
[Fact] [Fact]
public void Build_HeightmapPackedAsXMajor_NotYMajor() public void Build_HeightmapPackedAsXMajor_NotYMajor()
{ {