acdream/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
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

690 lines
32 KiB
C#

using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// L.2a slice 1 (2026-05-12) — runtime-toggleable physics probe flags.
/// Initialized from env vars at process start; flippable at runtime via
/// the DebugPanel mirror (or by direct assignment). Log call sites read
/// these statics so a checkbox toggle takes effect on the next resolve
/// without relaunching.
///
/// <para>
/// L.2d slice 1 (2026-05-13) adds <see cref="ProbeBuildingEnabled"/> +
/// the <see cref="LastBspHitPoly"/> diagnostic side-channel. Future
/// slices may fold the older <c>ACDREAM_DUMP_*</c> env vars into this
/// class for unified runtime toggling. Until then, those older flags
/// remain sticky-at-startup per their original implementation.
/// </para>
/// </summary>
public static class PhysicsDiagnostics
{
/// <summary>
/// When true, <see cref="PhysicsEngine.ResolveWithTransition"/> emits
/// one structured <c>[resolve]</c> line per call: input + target +
/// output position/cell, grounded state, contact-plane status,
/// collision-normal validity, walkable polygon status, moving entity
/// id. Initial state from <c>ACDREAM_PROBE_RESOLVE=1</c>.
/// </summary>
public static bool ProbeResolveEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
/// <summary>
/// When true, every change to <c>PlayerMovementController.CellId</c>
/// emits one <c>[cell-transit]</c> line: old → new cell, current
/// world position, reason tag (<c>resolver</c> / <c>teleport</c>).
/// Initial state from <c>ACDREAM_PROBE_CELL=1</c>.
/// </summary>
public static bool ProbeCellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
/// <summary>
/// L.2d slice 1 (2026-05-13). When true, every BSP-shadow-entry hit
/// attributed by <c>TransitionTypes.FindObjCollisions</c> emits a
/// multi-line <c>[resolve-bldg]</c> entry: which part (partIdx vs 0),
/// physics-BSP root radius vs visual AABB radius, world-space entity
/// origin, and the specific hit polygon's vertices in both
/// object-local and world space. Designed to distinguish the three
/// L.2d hypotheses (wrong BSP loaded / over-registered parts /
/// BSPQuery flaw) from a single Holtburg-doorway capture.
///
/// <para>
/// Also gates a one-time <c>[entity-source]</c> log line at every
/// <c>ShadowObjects.Register(...)</c> call site in <c>GameWindow</c>
/// — makes <c>entityId=0xA9B479</c> in a probe line greppable to its
/// source registration within the same log file.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_BUILDING=1</c>. Mirrorable
/// via <c>DebugVM.ProbeBuilding</c> when <c>ACDREAM_DEVTOOLS=1</c>.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeBuildingEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_BUILDING") == "1";
/// <summary>
/// L.2d slice 1 (2026-05-13). Diagnostic side-channel: the
/// <see cref="ResolvedPolygon"/> that <see cref="BSPQuery"/>
/// recorded for the most recent collision-normal write.
/// <see cref="TransitionTypes.FindObjCollisions"/> clears this to
/// <see langword="null"/> before each shadow-entry test and reads it
/// back after, so emitting the <c>[resolve-bldg]</c> probe line can
/// reference the actual hit poly without plumbing an out-param
/// through BSPQuery's recursive private methods.
///
/// <para>
/// Written by <see cref="BSPQuery"/> only when
/// <see cref="ProbeBuildingEnabled"/> is true, so this stays
/// zero-cost in normal play. Cylinder collisions leave this
/// <see langword="null"/> — the probe line emits
/// <c>hitPoly: n/a (cylinder)</c> in that case.
/// </para>
///
/// <para>
/// Not threadsafe — physics runs on a single thread. If that
/// changes, this needs <c>[ThreadStatic]</c> or rethink. Deviation
/// from spec component 4 (which described an out-param); the
/// side-channel keeps BSPQuery's signature stable and the diagnostic
/// path off the production code surface.
/// </para>
/// </summary>
public static ResolvedPolygon? LastBspHitPoly { get; set; }
/// <summary>
/// B.6 slice 1 (2026-05-14) — baseline trace for the local-player
/// server-initiated auto-walk path (issue #63). When true, the
/// following events emit one-line <c>[autowalk-*]</c> logs:
/// <list type="bullet">
/// <item><description><c>[autowalk-out]</c> on every <c>SendUse</c>
/// / <c>SendPickUp</c> the local player issues — these are the
/// packets that may trigger ACE's server-side <c>CreateMoveToChain</c>
/// when the target is out of <c>WithinUseRadius</c>.</description></item>
/// <item><description><c>[autowalk-mt]</c> on every inbound
/// <c>UpdateMotion</c> for the local player — captures the
/// <c>MovementType + MoveToPath + speed/runRate</c> ACE sends.</description></item>
/// <item><description><c>[autowalk-up]</c> on every inbound
/// <c>UpdatePosition</c> for the local player — answers "what's
/// ACE's broadcast cadence during auto-walk?"</description></item>
/// </list>
/// Initial state from <c>ACDREAM_PROBE_AUTOWALK=1</c>.
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-14-phase-b6-design.md</c>
/// §"Required investigation".
/// </para>
/// </summary>
public static bool ProbeAutoWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_AUTOWALK") == "1";
/// <summary>
/// 2026-05-16. Logs one line per `IsUseableTarget` call that takes
/// the null-useability fallback path (creature pass / BF_DOOR pass /
/// BF_LIFESTONE pass / etc.). Used to measure how often ACE's seed
/// DB ships entities without `_useability` set — settles whether
/// the fallback is live code or theoretical defense.
///
/// <para>
/// Retail has NO fallback; null/zero useability blocks Use entirely
/// (acclient_2013_pseudo_c.txt:402923 ItemHolder::UseObject —
/// IsUseable==0 falls through to "cannot be used" branch). Our
/// fallback exists because ACE genuinely sends null for many seed
/// weenies. The probe quantifies "many".
/// </para>
///
/// <para>Toggle via env var <c>ACDREAM_PROBE_USEABILITY_FALLBACK=1</c>
/// or DebugPanel checkbox.</para>
/// </summary>
public static bool ProbeUseabilityFallbackEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1";
/// <summary>
/// L.4-diag (2026-04-30) → promoted into <see cref="PhysicsDiagnostics"/>
/// 2026-05-16 per CLAUDE.md "Code Structure Rules" §5 (diagnostic owner
/// classes, not per-call-site env reads). Gates the <c>[steep-roof]</c>
/// trace family that fires from four sites during the rooftop-bounce
/// investigation:
/// <list type="bullet">
/// <item><description><c>PhysicsEngine.ResolveWithTransition</c> —
/// <c>[steep-roof] KILL-VELOCITY-APPLIED</c> when retail-faithful
/// <c>kill_velocity</c> zeroes the body's velocity on steep-slope
/// impact.</description></item>
/// <item><description><c>TransitionTypes</c> (<c>FindEnvCollisions</c>
/// post-step) — per-frame plane-normal trace on the active
/// <see cref="CollisionInfo"/>.</description></item>
/// <item><description><c>PlayerMovementController</c> — two sites
/// emitting <c>[steep-roof]</c> + the per-frame bounce trace when
/// the post-collision velocity disagrees with retail.</description></item>
/// </list>
/// Initial state from <c>ACDREAM_DUMP_STEEP_ROOF=1</c>. Runtime-toggleable
/// via the property setter; not yet wired to a DebugPanel checkbox (open
/// follow-up if a debugging session calls for it).
/// </summary>
public static bool DumpSteepRoofEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). When true, emits one
/// <c>[indoor-bsp]</c> line per <see cref="BSPQuery.FindCollisions"/>
/// call made from <see cref="Transition.FindEnvCollisions"/>'s indoor
/// cell-BSP branch. Captures the cell id, sphere local position,
/// resulting <see cref="TransitionState"/>, and the hit poly's id,
/// local-normal, and side-type — pinpoints why indoor collision
/// returns spurious collisions (#84) and helps cross-check the
/// outdoor-in approach path (#85).
///
/// <para>
/// While true, this also un-gates the diagnostic
/// <see cref="LastBspHitPoly"/> side-channel inside
/// <see cref="BSPQuery"/> — see the OR'd condition at every poly
/// write site. Zero-cost when off.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
/// Runtime-toggleable via DebugPanel.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeIndoorBspEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
/// <summary>
/// Indoor walking Phase D follow-up (2026-05-19). When true, emits one
/// <c>[cell-cache]</c> line each time <see cref="PhysicsDataCache.CacheCellStruct"/>
/// caches a new EnvCell. Reports per-cell polygon counts and BSP root
/// structure so the caller can cross-reference with <c>[indoor-bsp]</c>
/// lines to distinguish between:
/// <list type="bullet">
/// <item><description>Empty data (physicsPolyCount=0 or resolvedCount=0)
/// — candidate (a)/(c) in the poly=n/a investigation.</description></item>
/// <item><description>Non-zero polygon counts but bspRootPolyCount=0 at
/// root + tree has children — correct structure for non-leaf root,
/// leaves hold the poly refs; not a bug.</description></item>
/// <item><description>Non-zero polygon counts but bspRootPolyCount=0 at
/// root AND root is a leaf (bspRootHasChildren=false) — BSP leaf with
/// zero poly refs, candidate (b)/(d).</description></item>
/// </list>
/// This diagnostic fires at most once per EnvCell (cache is no-op after
/// first population). It does NOT have a DebugPanel mirror yet — this is
/// a one-shot capture tool, not a persistent toggle. Promote to full
/// infrastructure after the root cause is identified.
///
/// <para>Initial state from <c>ACDREAM_PROBE_CELL_CACHE=1</c>.</para>
/// </summary>
public static bool ProbeCellCacheEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1";
/// <summary>
/// ContactPlane retention spike (2026-05-20). When true, every write to
/// <c>CollisionInfo.ContactPlane{,Valid,CellId,IsWater}</c> and
/// <c>LastKnownContactPlane{,Valid,CellId,IsWater}</c> emits one
/// <c>[cp-write]</c> line: field, old → new value, caller method (walked
/// from the stack), and source line. Maps the per-frame lifecycle of the
/// contact plane to confirm/refute the hypothesis that
/// <c>FindEnvCollisions</c> indoor branch is rewriting CP every frame
/// instead of retaining it across frames.
///
/// <para>
/// Only logs when the value actually changes (suppresses no-op writes to
/// reduce log volume). Initial state from
/// <c>ACDREAM_PROBE_CONTACT_PLANE=1</c>. Spike-only — remove once the fix
/// lands and the diagnostic value is captured.
/// </para>
/// </summary>
public static bool ProbeContactPlaneEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CONTACT_PLANE") == "1";
/// <summary>
/// Indoor walking ISSUES #83 H-disambiguation spike (2026-05-21).
/// When true, two diagnostic emissions activate:
/// <list type="bullet">
/// <item><description>One <c>[walk-miss]</c> line per
/// <see cref="Transition.TryFindIndoorWalkablePlane"/> MISS
/// event, dumping foot world/local position, the nearest
/// walkable polygon in the cell (with XY-containment flag and
/// vertical gap), and whether the LandCell terrain at the same
/// XY would have grounded the player.</description></item>
/// <item><description>One <c>[floor-polys]</c> line per indoor
/// cell cached, enumerating each walkable-eligible polygon's
/// id, normal Z, local-XY bounding box, and plane Z at the
/// bbox center.</description></item>
/// </list>
/// Together these answer H1 (multi-cell iteration missing) vs H2
/// (probe distance too short) vs H3 (poly absent /
/// <c>walkable_hits_sphere</c> rejection) for the ISSUES #83
/// stuck-falling bug. Spike-only — remove once the root cause is
/// identified and the fix lands.
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_WALK_MISS=1</c>.
/// No DebugPanel mirror — one-shot diagnostic.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeWalkMissEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_MISS") == "1";
/// <summary>
/// Phase A6.P1 cdb probe spike (2026-05-21). When true, every BSP
/// collision response site emits a structured <c>[push-back]</c> line:
/// input/output sphere center, plane geometry, push-back delta, walk
/// interp, and the dispatcher's selected path. Direct comparison to
/// retail's cdb breakpoint set documented at
/// <c>tools/cdb/a6-probe.cdb</c>.
///
/// <para>
/// Three emission sites: <c>BSPQuery.AdjustSphereToPlane</c>
/// (the suspected over-correction site), <see cref="BSPQuery.FindCollisions"/>
/// (the 6-path dispatcher), and <see cref="Transition.CheckOtherCells"/>
/// (multi-cell BSP iteration outcomes). All three are zero-cost when
/// off — checked via early-out at each site.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_PUSH_BACK=1</c>.
/// Runtime-toggleable via DebugVM mirror.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md</c>.
/// </para>
/// </summary>
public static bool ProbePushBackEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PUSH_BACK") == "1";
/// <summary>
/// A6.P3 slice 4 investigation (2026-05-22) — dumps the polygon's
/// vertices + plane + sidesType + cell id whenever a push-back fires.
/// Lets us compare our extracted polygon (from our cache) against
/// WorldBuilder's straight-from-dat read for the same poly index, to
/// falsify the "is our dat-read producing wrong polygon geometry?"
/// hypothesis for issue #98 (cellar-up stuck at top step).
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_POLY_DUMP=1</c>.
/// Heavy output (one dump per AdjustSphereToPlane call); use briefly
/// to capture a specific scenario, then turn off.
/// </para>
/// </summary>
public static bool ProbePolyDumpEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_POLY_DUMP") == "1";
/// <summary>
/// Emit one <c>[poly-dump]</c> line with the full polygon geometry:
/// cell id, polygon index, num vertices, sides flag, plane normal+D,
/// and all vertex coordinates. Used in conjunction with the push-back
/// probe to identify which dat polygon a push-back hit so we can
/// cross-reference against WorldBuilder's straight-from-dat read.
///
/// <para>Caller MUST guard with <c>if (!ProbePolyDumpEnabled) return;</c>.</para>
/// </summary>
public static void LogPolyDump(uint cellId, ResolvedPolygon poly)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var sb = new System.Text.StringBuilder(256);
sb.AppendFormat(ci,
"[poly-dump] cell=0x{0:X8} polyId=0x{1:X4} numPts={2} sides={3} " +
"n=({4:F6},{5:F6},{6:F6}) d={7:F6} verts=[",
cellId, poly.Id, poly.NumPoints, poly.SidesType,
poly.Plane.Normal.X, poly.Plane.Normal.Y, poly.Plane.Normal.Z, poly.Plane.D);
for (int i = 0; i < poly.Vertices.Length; i++)
{
if (i > 0) sb.Append(',');
sb.AppendFormat(ci, "({0:F6},{1:F6},{2:F6})",
poly.Vertices[i].X, poly.Vertices[i].Y, poly.Vertices[i].Z);
}
sb.Append(']');
Console.WriteLine(sb.ToString());
}
/// <summary>
/// A6.P3 slice 5 placement-insert investigation (2026-05-22). One
/// <c>[place-fail]</c> line per Path 1 (Placement/Ethereal) call in
/// <c>BSPQuery.FindCollisions</c> that returns Collided, plus one per
/// <c>Transition.DoStepDown</c> placement_insert that rejects.
///
/// <para>
/// Investigation target: issue #98 cellar-up stuck. The 2026-05-22
/// handoff diagnosed BSPQuery path-selection (Path 5 vs Path 6) as
/// the divergence, but cross-referencing the retail cdb capture
/// (every BP4 hit shows <c>collide=0</c>) showed retail enters the
/// same Contact branch we do. The actual divergence is downstream:
/// our DoStepUp's step-down probe lifts the sphere onto the cellar
/// ramp, then placement_insert rejects, step_up returns failure,
/// step_up_slide fires, contact-recovery loops forever. This probe
/// identifies which polygon (or solid leaf) causes the placement
/// reject so we know what geometry is blocking the lifted position.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_PLACEMENT_FAIL=1</c>.
/// Low volume — only fires on actual rejection (one line per
/// Collided return from Path 1, plus one per DoStepDown placement
/// failure). Safe to leave on during a full scen4 cellar-up capture.
/// </para>
/// </summary>
public static bool ProbePlacementFailEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLACEMENT_FAIL") == "1";
/// <summary>
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
/// emits one <c>[step-walk]</c> line at selected points in the transition
/// sub-step loop and step-down probe. The line records requested vs
/// adjusted offset, current/check sphere position, cell id, walk interp,
/// contact planes, and walkable flags so a cellar-ramp capture can answer
/// whether forward motion is being projected into rising Z or lost before
/// the placement check.
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_STEP_WALK=1</c>. One-shot
/// diagnostic; no DebugPanel mirror until the root cause is identified.
/// </para>
/// </summary>
public static bool ProbeStepWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1";
/// <summary>
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
/// at the leaf where it returns true. Either
/// <see cref="LastPlacementFailPolyId"/> identifies the polygon that
/// intersected the sphere, or <see cref="LastPlacementFailSolidLeaf"/>
/// is true to indicate the sphere center landed inside a BSP leaf
/// marked solid (no specific polygon). The caller (Path 1) reads
/// these immediately after the true return to emit the
/// <c>[place-fail]</c> line, then clears them before the next test.
///
/// <para>
/// Writes are gated on <see cref="ProbePlacementFailEnabled"/> so the
/// production path pays only one boolean check per leaf hit when the
/// probe is off.
/// </para>
/// </summary>
public static ushort LastPlacementFailPolyId { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static Vector3 LastPlacementFailPolyNormal { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static float LastPlacementFailPolyD { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static bool LastPlacementFailSolidLeaf { get; set; }
/// <summary>
/// Emit one <c>[place-fail]</c> line for a placement_insert rejection.
/// <paramref name="source"/> tags the call site (e.g.
/// <c>"Path1.sphere0"</c> for the foot sphere in Path 1,
/// <c>"Path1.sphere1"</c> for the head sphere,
/// <c>"DoStepDown"</c> for the wrapper). The polygon (or solid leaf)
/// fields come from the side-channel populated during the recursive
/// BSP descent.
///
/// <para>Caller MUST guard with <c>if (!ProbePlacementFailEnabled) return;</c>.</para>
/// </summary>
public static void LogPlacementFail(
string source,
Vector3 sphereCenter,
float radius,
int sphereIdx,
uint cellId,
Vector3 worldOrigin,
bool ethereal)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string polyDesc = LastPlacementFailSolidLeaf
? "solid_leaf=1"
: LastPlacementFailPolyId != 0
? string.Format(ci, "polyId=0x{0:X4} n=({1:F4},{2:F4},{3:F4}) d={4:F4}",
LastPlacementFailPolyId,
LastPlacementFailPolyNormal.X, LastPlacementFailPolyNormal.Y, LastPlacementFailPolyNormal.Z,
LastPlacementFailPolyD)
: "no_poly_info";
Console.WriteLine(string.Format(ci,
"[place-fail] source={0} cell=0x{1:X8} sphere=({2:F4},{3:F4},{4:F4}) r={5:F4} " +
"sphereIdx={6} worldOrigin=({7:F4},{8:F4},{9:F4}) ethereal={10} {11}",
source, cellId, sphereCenter.X, sphereCenter.Y, sphereCenter.Z, radius,
sphereIdx, worldOrigin.X, worldOrigin.Y, worldOrigin.Z, ethereal, polyDesc));
}
/// <summary>
/// A6.P1 emission helper for the <c>AdjustSphereToPlane</c> site.
/// One line per call: input sphere center, plane geometry, push-back
/// delta, walk-interp before/after, and whether the adjust applied.
/// Direct paired comparison to retail's cdb breakpoint on
/// <c>CPolygon::adjust_sphere_to_plane</c>.
///
/// <para>
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
/// before computing the delta arguments — this method assumes the
/// caller paid that price already.
/// </para>
/// </summary>
public static void LogPushBackAdjust(
Vector3 inputCenter,
Vector3 outputCenter,
Plane plane,
float radius,
float walkInterpBefore,
float walkInterpAfter,
float dpPos,
float dpMove,
float iDist,
bool applied)
{
var delta = outputCenter - inputCenter;
float deltaMag = delta.Length();
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[push-back] site=adjust_sphere " +
"in=({0:F4},{1:F4},{2:F4}) " +
"out=({3:F4},{4:F4},{5:F4}) " +
"delta=({6:F4},{7:F4},{8:F4}) deltaMag={9:F4} " +
"n=({10:F4},{11:F4},{12:F4}) d={13:F4} " +
"r={14:F4} winterp={15:F4}->{16:F4} " +
"dpPos={17:F4} dpMove={18:F4} iDist={19:F4} applied={20}",
inputCenter.X, inputCenter.Y, inputCenter.Z,
outputCenter.X, outputCenter.Y, outputCenter.Z,
delta.X, delta.Y, delta.Z, deltaMag,
plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D,
radius, walkInterpBefore, walkInterpAfter,
dpPos, dpMove, iDist, applied));
}
/// <summary>
/// A6.P1 emission helper for the <c>FindCollisions</c> dispatcher
/// site. One line per call: input sphere center, movement vector,
/// path-selection state flags (collide / insertType / objState),
/// walk-interp at entry, and the return state. Direct paired
/// comparison to retail's cdb breakpoint on
/// <c>BSPTREE::find_collisions</c>.
///
/// <para>
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
/// before calling.
/// </para>
/// </summary>
public static void LogPushBackDispatch(
Vector3 sphereCenter,
Vector3 movement,
bool collide,
int insertType,
int objState,
float walkInterpEntry,
int returnState)
{
// Output format mirrors LogPushBackAdjust: single string.Format
// with CultureInfo.InvariantCulture so the F4 / hex formatting is
// locale-independent and the output is greppable.
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[push-back-disp] site=dispatch " +
"center=({0:F4},{1:F4},{2:F4}) " +
"mvmt=({3:F4},{4:F4},{5:F4}) " +
"collide={6} insertType={7} objState=0x{8:X} " +
"winterp={9:F4} return={10}",
sphereCenter.X, sphereCenter.Y, sphereCenter.Z,
movement.X, movement.Y, movement.Z,
collide, insertType, objState,
walkInterpEntry, returnState));
}
/// <summary>
/// A6.P1 emission helper for the <c>CheckOtherCells</c> multi-cell
/// BSP iteration site. One line per off-cell hit: from-cell, to-cell,
/// BSP result (Ok / Adjusted / Slid / Collided), and the iteration
/// outcome. Direct paired comparison to retail's
/// <c>CTransition::check_other_cells</c> loop at decomp line
/// 272717. Augments the existing A4 multi-cell BSP instrumentation
/// with explicit per-iteration outcome telemetry.
///
/// <para>
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
/// before calling.
/// </para>
/// </summary>
public static void LogPushBackCellTransit(
uint primaryCellId,
uint otherCellId,
int bspResult,
bool halted)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[push-back-cell] site=other_cell " +
"primary=0x{0:X8} other=0x{1:X8} " +
"bspResult={2} halted={3}",
primaryCellId, otherCellId, bspResult, halted));
}
/// <summary>
/// Emit one <c>[step-walk]</c> line for issue #98's cellar-ramp
/// investigation. Caller MUST guard with
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
/// </summary>
public static void LogStepWalk(
string site,
int stepIndex,
int stepCount,
SpherePath sp,
CollisionInfo ci,
ObjectInfo oi,
Vector3 requestedOffset,
Vector3 adjustedOffset,
TransitionState? state = null,
string? detail = null)
{
var culture = System.Globalization.CultureInfo.InvariantCulture;
var checkDelta = sp.CheckPos - sp.CurPos;
string stateText = state.HasValue ? state.Value.ToString() : "n/a";
string stepText = stepIndex >= 0 && stepCount > 0
? string.Format(culture, "{0}/{1}", stepIndex + 1, stepCount)
: "-";
Console.WriteLine(string.Format(culture,
"[step-walk] site={0} step={1} state={2} " +
"cur=({3:F4},{4:F4},{5:F4}) check=({6:F4},{7:F4},{8:F4}) " +
"delta=({9:F4},{10:F4},{11:F4}) cell=0x{12:X8}->0x{13:X8} " +
"req=({14:F4},{15:F4},{16:F4}) adj=({17:F4},{18:F4},{19:F4}) " +
"winterp={20:F4} stepUp={21} stepDown={22} insert={23} " +
"oi=0x{24:X} contact={25} onWalkable={26} " +
"cp={27} lkcp={28} hit={29} slide={30} walkPoly={31} lastWalkPoly={32}{33}",
site, stepText, stateText,
sp.CurPos.X, sp.CurPos.Y, sp.CurPos.Z,
sp.CheckPos.X, sp.CheckPos.Y, sp.CheckPos.Z,
checkDelta.X, checkDelta.Y, checkDelta.Z,
sp.CurCellId, sp.CheckCellId,
requestedOffset.X, requestedOffset.Y, requestedOffset.Z,
adjustedOffset.X, adjustedOffset.Y, adjustedOffset.Z,
sp.WalkInterp,
sp.StepUp, sp.StepDown, sp.InsertType,
(uint)oi.State, oi.Contact, oi.OnWalkable,
FormatPlane(ci.ContactPlaneValid, ci.ContactPlane, ci.ContactPlaneCellId, ci.ContactPlaneIsWater),
FormatPlane(ci.LastKnownContactPlaneValid, ci.LastKnownContactPlane, ci.LastKnownContactPlaneCellId, ci.LastKnownContactPlaneIsWater),
FormatVector(ci.CollisionNormalValid, ci.CollisionNormal),
FormatVector(ci.SlidingNormalValid, ci.SlidingNormal),
sp.HasWalkablePolygon, sp.HasLastWalkablePolygon,
string.IsNullOrEmpty(detail) ? string.Empty : " " + detail));
}
private static string FormatVector(bool valid, Vector3 value)
{
if (!valid)
return "n/a";
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
"({0:F4},{1:F4},{2:F4})",
value.X, value.Y, value.Z);
}
private static string FormatPlane(bool valid, Plane plane, uint cellId, bool isWater)
{
if (!valid)
return "n/a";
float zAtOrigin = MathF.Abs(plane.Normal.Z) > PhysicsGlobals.EPSILON
? -plane.D / plane.Normal.Z
: float.NaN;
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
"cell=0x{0:X8},water={1},n=({2:F4},{3:F4},{4:F4}),d={5:F4},z0={6:F4}",
cellId, isWater,
plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D,
zAtOrigin);
}
public static void LogCpBoolWrite(string field, bool oldValue, bool newValue)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: {oldValue} -> {newValue} caller={caller}"));
}
public static void LogCpPlaneWrite(string field, Plane oldPlane, Plane newPlane)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: n=({oldPlane.Normal.X:F3},{oldPlane.Normal.Y:F3},{oldPlane.Normal.Z:F3}) D={oldPlane.D:F3} -> n=({newPlane.Normal.X:F3},{newPlane.Normal.Y:F3},{newPlane.Normal.Z:F3}) D={newPlane.D:F3} caller={caller}"));
}
public static void LogCpCellIdWrite(string field, uint oldValue, uint newValue)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: 0x{oldValue:X8} -> 0x{newValue:X8} caller={caller}"));
}
/// <summary>
/// Walks the stack to identify the first frame outside <c>CollisionInfo</c>
/// and <c>PhysicsDiagnostics</c> — that's the actual caller writing the
/// ContactPlane field. Format: <c>TypeName.MethodName:line</c> when file
/// info is available, else just <c>TypeName.MethodName</c>. Walked with
/// <c>fileNeeded=true</c> only when the probe flag is on, so zero cost
/// when off.
/// </summary>
private static string GetCpCallerName()
{
// Skip 2: this method + the LogCp*Write helper that called it.
var st = new System.Diagnostics.StackTrace(2, fNeedFileInfo: true);
for (int i = 0; i < st.FrameCount; i++)
{
var f = st.GetFrame(i);
var m = f?.GetMethod();
if (m is null) continue;
var typeName = m.DeclaringType?.Name ?? "?";
if (typeName == "CollisionInfo" || typeName == "PhysicsDiagnostics") continue;
int line = f?.GetFileLineNumber() ?? 0;
return line > 0 ? $"{typeName}.{m.Name}:{line}" : $"{typeName}.{m.Name}";
}
return "?";
}
}