From b61f5fd4fc2016a471050e8f5a46e1aea15cb9d6 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 17:29:44 +0200 Subject: [PATCH 1/9] =?UTF-8?q?probe(physics):=20ACDREAM=5FPROBE=5FREACH?= =?UTF-8?q?=20=E2=80=94=20discriminate=20#334's=20three=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #334 is "large static formations can be walked through on flat ground, and jumping over them drops you inside" (Neftet, user-reported, pre-existing — reproduced on 52175aa1, before AP-22/AP-152/AP-156/AD-10). The report as originally filed pointed at one mechanism. The user's clarification that the formations are permeable on the ground generally, not only at a boundary between two of them, widens it to three, and a DAT sweep can only say which objects COULD fail, never which one IS failing at the spot. So: measure in game, at the failing spot, and let the log choose. The three outcomes this probe must tell apart, at the player's own collision query: (a) the object IS a candidate in the cell and the broadphase reach filter rejects it before its BSP is consulted — AP-158 / #333. That filter measures |currPos - obj.Position|, the part ORIGIN, against the BSP root sphere's RADIUS plus an acdream-invented 2 m slack. The sphere's CENTRE is frequently not the part origin (376 of 973 installed physics-BSP parts sit further from it than half their own radius; worst 20.762 m), so geometry well inside the sphere can be rejected. (b) the object is not in the cell's candidate set at all — a membership or registration failure. AP-156's territory, which did not fix this. (c) the object is a candidate, is not rejected, and still contributes nothing because no usable physics BSP resolves for it. Two line types from Transition.FindObjCollisionsInCell: [reach-obj] one per candidate, carrying mover guid, target entity id, GfxObj id, cell, and its disposition — exempt-self, exempt-missile, rejected-reach, exempt-rule, exempt-ethereal-stepdown, no-shape, bsp-only-skip, tested-{ok,collided,adjusted,slid}. For BSP candidates it also carries the origin-measured distance the filter used, the centre-measured distance it should have used, the budget, the shortfall, and wouldAcceptAtCenter — the boolean that separates a false rejection from an honest one. Identity is on every line (feedback_probe_identity_attribution). [reach-q] one per cell query, with the per-disposition tallies AND the raw entry count, emitted EVEN WHEN THE CELL YIELDS ZERO. That last part is the point: without it, an absence of rejection lines could not distinguish "nothing was rejected" from "nothing was there", and a criterion that cannot fail in the presence of the bug it exists to catch is the trap this campaign has already been caught by once. `blocked` is the control — it proves the probe can see a working collision as well as a missing one. The BSP root sphere is resolved through the SAME production accessor registration uses (GetFlatGfxObj(id).PhysicsBsp root node, per LiveEntityCollisionBuilder and ShadowShapeBuilder), so the probe cannot report geometry differing from what the registry actually emitted — AP-156's lesson was exactly that: one resolver. Volume: [reach-obj] de-duplicates per (mover, target, cell) and re-emits at once whenever the disposition changes or the shortfall crosses a 0.5 m bucket, otherwise at most once a second; [reach-q] de-duplicates per (mover, cell) on the whole tally tuple, so any change in what the cell yielded emits immediately, otherwise at most twice a second. Both emit eagerly on change — which is exactly when the player walks into the formation — and go quiet when nothing is happening. Nothing is aggregated away. Filtered to the player mover, matching PhysicsResolveCapture, so NPC and remote dead-reckoning resolves do not pollute the capture. The helper is static and takes everything by parameter so no closure display class enters the resolve path: Slice I1's 0 B/resolve budget holds with the probe compiled in and switched off. TEMPORARY. Strip with the rest of the physics-probe family once #334 is scored; both the flag and the call site say so. Clean bin/obj, Release build, full suite 11,208 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 --- .../Physics/PhysicsDiagnostics.cs | 230 ++++++++++++++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 201 ++++++++++++++- 2 files changed, 429 insertions(+), 2 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index f8fcd904..e3afc37a 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1146,6 +1146,230 @@ public static class PhysicsDiagnostics public static bool ProbeSweptEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1"; + // ----------------------------------------------------------------------- + // #334 broadphase / candidate-disposition probe — TEMPORARY (2026-08-06) + // + // STRIP THIS WHOLE REGION together with the rest of the physics probe + // family once #334 is scored. + // + // #334 is "large static formations can be walked through on flat ground, + // and jumping over them drops you inside". Three candidate mechanisms + // survive the report, and this probe exists to DISCRIMINATE them, not to + // confirm any one of them: + // + // (a) the object IS a candidate in the cell but the broadphase reach + // filter rejects it before its BSP is consulted (AP-158 / #333); + // (b) the object is NOT in the cell's candidate set at all — a + // membership / registration failure (AP-156's territory, which did + // not fix this, or the static publication path never registered it); + // (c) the object IS a candidate and IS NOT rejected, but resolves to no + // usable shape — an empty shape list or an unresolved physics BSP. + // + // Context for (a): the filter measures |currPos - obj.Position| — the part + // ORIGIN — against obj.Radius (the physics-BSP ROOT sphere radius) plus a + // 2 m acdream-invented slack. The BSP root sphere's CENTRE is frequently + // NOT the part origin (376 of 973 installed physics-BSP parts sit further + // from it than half their own radius; worst 20.762 m). Where that offset + // exceeds the slack, geometry well inside the sphere is rejected. So every + // candidate line carries BOTH distances and the decisive + // wouldAcceptAtCenter boolean. + // + // Context for (c): AP-152 (4abd1b5e) made us emit BSP shapes exclusively + // where primitives were also emitted before. That did NOT cause #334 (the + // user reproduced on a pre-AP-152 build), but the same failure mode can + // exist independently, so no-shape is a first-class disposition here. + // ----------------------------------------------------------------------- + + /// + /// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with + /// the physics-probe family). Emits two line types from + /// Transition.FindObjCollisionsInCell: + /// + /// + /// [reach-q] — one per-cell query summary: the number of + /// shadow entries the cell yielded and the per-disposition tallies. + /// It is emitted even when the cell yields zero entries, which is + /// what makes outcome (b) visible: "cell yielded 0" at a spot where a + /// formation is plainly in front of the player is a registration gap, + /// and is recorded as data rather than as silence. Without this line an + /// absence of rejection lines would be ambiguous between "nothing was + /// rejected" and "nothing was there", which is precisely the + /// unfalsifiable-criterion trap this campaign has already been bitten + /// by. + /// [reach-obj] — one per candidate, carrying its identity + /// (mover guid, target entity id, GfxObj id, cell) and its + /// disposition: exempt-self, exempt-missile, + /// rejected-reach, exempt-rule, + /// exempt-ethereal-stepdown, no-shape, + /// bsp-only-skip, or tested:<result>. For BSP + /// candidates it also carries the origin-measured distance the filter + /// used, the centre-measured distance it should have used, the budget, + /// the shortfall, and wouldAcceptAtCenter. + /// + /// + /// + /// Volume control (this site is hot — it runs per cell per transitional + /// insert, and one resolve performs many inserts). [reach-obj] is + /// de-duplicated per (mover, target, cell) and re-emits immediately + /// whenever the disposition changes or the shortfall crosses a 0.5 m + /// bucket, and otherwise at most once per second. [reach-q] is + /// de-duplicated per (mover, cell) on the full tally tuple, so any change + /// in what the cell yielded emits at once, and otherwise at most twice a + /// second. Both therefore emit eagerly on change — which is exactly when + /// the player walks into the formation — and stay quiet when nothing is + /// happening. Nothing is aggregated away: every distinct state the query + /// passes through appears. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_REACH=1. Zero cost when off + /// (one static bool read per query). + /// + /// + public static bool ProbeReachEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REACH") == "1"; + + private static readonly object _reachGate = new(); + private static readonly Dictionary<(uint Mover, uint Entity, uint Cell), (long Ms, string Disp, int Bucket)> + _reachSeenObj = new(); + private static readonly Dictionary<(uint Mover, uint Cell), (long Ms, long Tally)> + _reachSeenQuery = new(); + + /// + /// One [reach-obj] line. Self-guards on + /// . + /// + /// The moving entity's guid — never omitted; a + /// per-entity probe without an identity produced a wrong root cause once + /// already (feedback_probe_identity_attribution). + /// What happened to this candidate. Use the + /// documented vocabulary on . + /// What the reach filter measured: the distance + /// from the swept sphere's current centre to the target's part ORIGIN. + /// Negative when not applicable. + /// What it should have measured: the distance to + /// the target's physics-BSP root sphere CENTRE. Negative when not + /// applicable. + /// The filter's admission threshold, 2 m slack + /// included. + /// The same threshold WITHOUT the slack — the + /// honest conservative bound once the real centre is used. + public static void LogReachCandidate( + uint moverId, + uint entityId, + uint gfxObjId, + uint cellId, + ShadowCollisionType shape, + string disposition, + bool stepDown, + float distOrigin, + float distCenter, + float objRadius, + float sphereRadius, + float movementLen, + float budget, + float centerBudget, + Vector3 objPos, + Vector3 bspCentreOffset, + Vector3 currPos) + { + if (!ProbeReachEnabled) return; + + float shortfall = distOrigin - budget; + bool wouldAcceptAtCenter = distCenter >= 0f && distCenter <= centerBudget; + int bucket = distOrigin < 0f ? 0 : (int)MathF.Floor(shortfall / 0.5f); + long now = Environment.TickCount64; + + lock (_reachGate) + { + var key = (moverId, entityId, cellId); + if (_reachSeenObj.TryGetValue(key, out var prev) + && string.Equals(prev.Disp, disposition, StringComparison.Ordinal) + && prev.Bucket == bucket + && now - prev.Ms < 1000) + { + return; + } + _reachSeenObj[key] = (now, disposition, bucket); + } + + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[reach-obj] mover=0x{0:X8} obj=0x{1:X8} gfx=0x{2:X8} cell=0x{3:X8} " + + "disp={4} shape={5} stepDown={6} distOrigin={7:F3} distCenter={8:F3} " + + "objR={9:F3} sphereR={10:F3} move={11:F3} budget={12:F3} " + + "centerBudget={13:F3} shortfall={14:F3} wouldAcceptAtCenter={15} " + + "objPos=({16:F2},{17:F2},{18:F2}) " + + "bspCentreOffset=({19:F2},{20:F2},{21:F2}) |bspCentreOffset|={22:F3} " + + "currPos=({23:F2},{24:F2},{25:F2}) t={26}", + moverId, entityId, gfxObjId, cellId, + disposition, shape, stepDown, distOrigin, distCenter, + objRadius, sphereRadius, movementLen, budget, + centerBudget, shortfall, wouldAcceptAtCenter, + objPos.X, objPos.Y, objPos.Z, + bspCentreOffset.X, bspCentreOffset.Y, bspCentreOffset.Z, bspCentreOffset.Length(), + currPos.X, currPos.Y, currPos.Z, now)); + } + + /// + /// One [reach-q] per-cell query summary. MUST be called even when + /// the cell yields zero entries — that is the whole point of the line. + /// Self-guards on . + /// + /// Shadow entries the cell yielded, before any + /// exemption. Zero here at a spot with visible geometry is outcome (b). + /// Candidates that survived the exemptions and were + /// measured by the reach filter. + /// Of those, how many the reach filter + /// rejected — outcome (a). + /// Candidates that passed the filter but resolved to + /// no usable shape — outcome (c). + /// Candidates that actually reached a shape test. + public static void LogReachQuery( + uint moverId, + uint cellId, + bool stepDown, + int inCell, + int exempt, + int reached, + int rejectedReach, + int noShape, + int tested, + int blocked, + Vector3 currPos) + { + if (!ProbeReachEnabled) return; + + // Tally fingerprint: any change in what this cell yielded re-emits at + // once. Deliberately includes every counter, so a state the query + // passes through cannot be swallowed by the throttle. + long tally = (((long)inCell * 31 + exempt) * 31 + reached) * 31; + tally = ((tally + rejectedReach) * 31 + noShape) * 31; + tally = ((tally + tested) * 31 + blocked) * 31 + (stepDown ? 1 : 0); + + long now = Environment.TickCount64; + lock (_reachGate) + { + var key = (moverId, cellId); + if (_reachSeenQuery.TryGetValue(key, out var prev) + && prev.Tally == tally + && now - prev.Ms < 500) + { + return; + } + _reachSeenQuery[key] = (now, tally); + } + + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[reach-q] mover=0x{0:X8} cell=0x{1:X8} stepDown={2} inCell={3} " + + "exempt={4} reached={5} rejectedReach={6} noShape={7} tested={8} " + + "blocked={9} pos=({10:F2},{11:F2},{12:F2}) t={13}", + moverId, cellId, stepDown, inCell, + exempt, reached, rejectedReach, noShape, tested, + blocked, currPos.X, currPos.Y, currPos.Z, now)); + } + /// /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). /// Emits one [tp-probe] line per teleport-pipeline event with a @@ -1356,6 +1580,12 @@ public static class PhysicsDiagnostics ProbePlacementFailEnabled = false; ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; + ProbeReachEnabled = false; + lock (_reachGate) + { + _reachSeenObj.Clear(); + _reachSeenQuery.Clear(); + } ProbeTeleportEnabled = false; ProbeRemoteTeleportEnabled = false; ProbeRemoteLandingEnabled = false; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index aace5aab..34b5da29 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3705,12 +3705,30 @@ public sealed class Transition if (engine.DataCache is null) return TransitionState.OK; var objsInCell = engine.ShadowObjects.GetObjectsInCell(cellId); - if (objsInCell.Count == 0) return TransitionState.OK; var sp = SpherePath; var oi = ObjectInfo; var ci = CollisionInfo; + // #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with + // the physics-probe family). Filtered to the player mover so NPC / + // remote dead-reckoning resolves do not pollute the capture, matching + // PhysicsResolveCapture's filter. The zero-entry query below is + // reported EXPLICITLY: "this cell yielded nothing" is outcome (b) — + // a registration gap — and must appear as data, never as silence. + bool reachProbe = PhysicsDiagnostics.ProbeReachEnabled && oi.IsPlayer; + + if (objsInCell.Count == 0) + { + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + inCell: 0, exempt: 0, reached: 0, rejectedReach: 0, + noShape: 0, tested: 0, blocked: 0, + currPos: sp.GlobalCurrCenter[0].Origin); + return TransitionState.OK; + } + // #42 diagnostic (2026-05-05): identify which static object causes // the airborne first-frame ~1m push. bool airborneDiag = !oi.Contact @@ -3730,6 +3748,11 @@ public sealed class Transition // (DoStepUp → TransitionalInsert → this) must not observe // registry mutations through the same reference mid-iteration. using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); + + // #334 probe tallies — see LogReachQuery for what each one decides. + int rExempt = 0, rReached = 0, rRejected = 0, rNoShape = 0, + rTested = 0, rBlocked = 0; + foreach (ShadowEntry obj in nearbyObjs.Entries) { // Self-skip — fix #42 (2026-05-05). Mirrors retail @@ -3745,13 +3768,29 @@ public sealed class Transition // horizontal push (validated by [SWEEP-OBJ] traces with // gfxObj=0x02000001 at exactly the entity's own position). if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId) + { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-self", sphereRadius, movement.Length(), currPos); + } continue; + } // OBJECTINFO::missile_ignore 0x0050CEB0 is an all-shapes // exemption. Retail computes it before the BSP-vs-cyl/sphere // dispatch; a true result reaches neither branch. if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags)) + { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-missile", sphereRadius, movement.Length(), currPos); + } continue; + } // Broad-phase: can the moving sphere reach this object? Vector3 deltaToCurr = currPos - obj.Position; @@ -3761,8 +3800,17 @@ public sealed class Transition else distToCurr = deltaToCurr.Length(); float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f; + if (reachProbe) rReached++; if (distToCurr > maxReach) + { + if (reachProbe) + { + rRejected++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "rejected-reach", sphereRadius, movement.Length(), currPos); + } continue; + } // Commit C 2026-04-29 — retail exemption block at the top of // CPhysicsObj::FindObjCollisions @@ -3773,7 +3821,15 @@ public sealed class Transition // landblock entries register with State=0 and Flags=None, // so this is a cheap fall-through for them. if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State)) + { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-rule", sphereRadius, movement.Length(), currPos); + } continue; + } // Retail CPhysicsObj::FindObjCollisions ethereal branch // (pc:276795-276806 / 0x0050f0a2-0x0050f0c9). A target counts as @@ -3799,7 +3855,16 @@ public sealed class Transition bool etherealForTest = (obj.State & 0x4u) != 0 || (ObjectInfo.Ethereal && (obj.State & 0x1u) == 0); if (etherealForTest && sp.StepDown) - continue; // retail pc:276799 — ethereal target not tested in step-down + { + // retail pc:276799 — ethereal target not tested in step-down + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-ethereal-stepdown", sphereRadius, movement.Length(), currPos); + } + continue; + } sp.ObstructionEthereal = etherealForTest; // L.2a slice 3 (2026-05-12): snapshot collision-normal state so @@ -3848,6 +3913,15 @@ public sealed class Transition // clear (pc:276989) fires after shape tests; we clear early here to // leave the flag clean for the next iteration. sp.ObstructionEthereal = false; + // #334 outcome (c): the entry IS a candidate, the reach + // filter DID admit it, and it still contributes nothing + // because no usable physics BSP resolved for its GfxObj. + if (reachProbe) + { + rNoShape++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "no-shape", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3915,6 +3989,12 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "bsp-only-skip", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3958,6 +4038,12 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "bsp-only-skip", sphereRadius, movement.Length(), currPos); + } continue; } @@ -4121,6 +4207,24 @@ public sealed class Transition // per-object test body before the outer loop continues. sp.ObstructionEthereal = false; + // #334: this candidate actually reached a shape test. `result` is + // post-Layer-2, i.e. what the query will really act on. + if (reachProbe) + { + rTested++; + if (result != TransitionState.OK) rBlocked++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + result switch + { + TransitionState.OK => "tested-ok", + TransitionState.Collided => "tested-collided", + TransitionState.Adjusted => "tested-adjusted", + TransitionState.Slid => "tested-slid", + _ => "tested-invalid", + }, + sphereRadius, movement.Length(), currPos); + } + if (result != TransitionState.OK) { if (airborneDiag) @@ -4133,13 +4237,106 @@ public sealed class Transition $"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " + $"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})"); } + // #334 (TEMPORARY): the early exit is a real query outcome, so + // it must be summarised too — otherwise the summary would + // under-report exactly the queries where something DID block, + // and "blocked" is the control that proves the probe can see a + // working collision as well as a missing one. + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + nearbyObjs.Entries.Length, rExempt, rReached, + rRejected, rNoShape, rTested, rBlocked, currPos); return result; } } + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + nearbyObjs.Entries.Length, rExempt, rReached, + rRejected, rNoShape, rTested, rBlocked, currPos); + return TransitionState.OK; } + /// + /// #334 candidate-disposition probe helper (2026-08-06 — TEMPORARY, strip + /// with the physics-probe family). Static, and takes everything by + /// parameter, so it introduces no closure display class into + /// — Slice I1's 0 B/resolve budget + /// must hold with the probe compiled in and switched off. + /// + /// + /// The target's physics-BSP ROOT sphere is resolved through the SAME + /// production accessor registration used + /// (GetFlatGfxObj(id).PhysicsBsp's root node, per + /// LiveEntityCollisionBuilder and ShadowShapeBuilder), so the + /// probe cannot report geometry that differs from what the registry + /// actually emitted. AP-156's lesson was exactly that: one resolver. + /// + /// + private static void ProbeReachCandidate( + PhysicsEngine engine, + ObjectInfo oi, + SpherePath sp, + uint cellId, + in ShadowEntry obj, + string disposition, + float sphereRadius, + float movementLen, + Vector3 currPos) + { + bool xyOnly = obj.CollisionType == ShadowCollisionType.Cylinder; + + Vector3 dOrigin = currPos - obj.Position; + float distOrigin = xyOnly + ? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y) + : dOrigin.Length(); + + // World-space offset from the part ORIGIN (what the filter measures + // against) to the BSP root sphere CENTRE (what it should measure + // against). Zero for non-BSP shapes, whose Position already IS their + // centre. + Vector3 bspCentreOffset = Vector3.Zero; + if (obj.CollisionType == ShadowCollisionType.BSP) + { + var flatBsp = engine.DataCache?.GetFlatGfxObj(obj.GfxObjId)?.PhysicsBsp; + if (flatBsp is { RootIndex: >= 0 }) + { + bspCentreOffset = Vector3.Transform( + flatBsp.Nodes[flatBsp.RootIndex].BoundingSphere.Origin * obj.Scale, + obj.Rotation); + } + } + + Vector3 dCentre = currPos - (obj.Position + bspCentreOffset); + float distCentre = xyOnly + ? MathF.Sqrt(dCentre.X * dCentre.X + dCentre.Y * dCentre.Y) + : dCentre.Length(); + + float budget = sphereRadius + obj.Radius + movementLen + 2f; + + PhysicsDiagnostics.LogReachCandidate( + moverId: oi.SelfEntityId, + entityId: obj.EntityId, + gfxObjId: obj.GfxObjId, + cellId: cellId, + shape: obj.CollisionType, + disposition: disposition, + stepDown: sp.StepDown, + distOrigin: distOrigin, + distCenter: distCentre, + objRadius: obj.Radius, + sphereRadius: sphereRadius, + movementLen: movementLen, + budget: budget, + centerBudget: budget - 2f, + objPos: obj.Position, + bspCentreOffset: bspCentreOffset, + currPos: currPos); + } + /// /// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel — /// CSortCell::find_collisions (Ghidra 0x005340a0): an outdoor From f0588725cf316062aa3124f5133d4d5567ecbebe Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 17:56:08 +0200 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20#334=20measured=20in=20game=20?= =?UTF-8?q?=E2=80=94=20a=20landblock-spanning=20object=20is=20registered?= =?UTF-8?q?=20by=20one=20sphere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reach-filter theory I proposed is REFUTED by measurement, and the real cause is found. Probe evidence committed as 334-neftet-probe.log (8,401 lines, ACDREAM_PROBE_REACH at b61f5fd4). Standing INSIDE the formation, the collision system reports inCell=2 exempt=2 reached=0 rejectedReach=0 — two candidates, both the player's own body spheres. Nothing was rejected because nothing was there. That kills AP-158 as the cause: the formation is not in the candidate set at all. The blocking part the user found is what makes it diagnostic. One object does collide — gfx=0x010046D8, a BSP with objR=69.471, the landblock's baked rock geometry — and it appears in cells 0x8764000A and 0x87640012 while being absent from 0x87640011, 0x87640019 and 0x87630018. Same object, same landblock, present in one cell and missing from the one directly beside it (grid (2,1) versus (2,0)). Cause: BuildFloodSpheres derives cell membership from a single bounding sphere per part. A 69.471 m radius cannot reach every cell of a 192 m landblock, so cells beyond it get no registration and the player walks through. Retail does not use a sphere here — calc_cross_cells 0x00515230 routes BSP objects to find_bbox_cell_list 0x00510fc0 -> calc_cross_cells_static 0x00518160, a walk over the object's extent. Recorded explicitly because the null result was misleadable: AP-156 (b52967de) did NOT fix this and was never going to. AP-156 corrected the sphere's POSITION; this is about its COVERAGE. Sequential halves of one weakness, not competing explanations — and without that note the next reader would reasonably conclude AP-156 had failed. Process note worth keeping: I proposed the reach filter, and a DAT sweep would have "confirmed" it by finding exactly the oversized objects I predicted. The user insisted on measuring in game instead, which produced the opposite answer in one run. Co-Authored-By: Claude Opus 4.8 --- docs/ISSUES.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4ed75097..519a5d1f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -10575,7 +10575,58 @@ did **not** resolve it either, which is itself a diagnostic clue (see below). formations, and no floor above it — jumping over lands you inside/through. "Solid near the middle, absent at the edges" is the shape to reason from. -### Leading candidate — AP-158 / #333, the broadphase reach filter +### MEASURED IN GAME 2026-08-06 — cause found, and it is NOT the reach filter + +**The reach-filter theory is REFUTED.** A live probe (`ACDREAM_PROBE_REACH`, +commit `b61f5fd4`) was run with the user standing in front of, and then inside, +a Neftet formation. Evidence: `334-neftet-probe.log`, 8,401 lines. + +Standing inside the formation the collision system reported: + +``` +[reach-q] cell=0x87640019 inCell=2 exempt=2 reached=0 rejectedReach=0 tested=0 blocked=0 +``` + +**Two candidates, both the player's own body spheres.** Nothing was rejected +because there was nothing to reject — the formation is not in the collision +candidate set at all. `rejectedReach=0` kills AP-158 as the cause here. + +**The blocking part the user found is the discriminator.** One object does +collide: `gfx=0x010046D8`, a BSP with `objR=69.471` — the landblock's baked +rock geometry, one object spanning a large area. Its coverage: + +| Cell | Rock object present? | +|---|---| +| `0x8764000A`, `0x87640012` | **YES** — `tested-collided` / `tested-slid`, player blocked | +| `0x87640011`, `0x87640019`, `0x87630018` | **NO** — `inCell=2`, both entries the player | + +**Same object, same landblock, present in some cells and absent from directly +adjacent ones.** `0x87640012` is cell index 17 → grid (2,1); `0x87640011` is +index 16 → grid (2,0), immediately beside it and empty. + +### Cause: a landblock-spanning object is registered by a single bounding sphere + +`ShadowObjectRegistry.BuildFloodSpheres` derives cell membership from one +sphere per part. This object's own radius is **69.471 m** while a landblock is +**192 m** across, so a single sphere cannot geometrically reach every cell the +mesh occupies. Cells beyond its reach receive no registration and the player +walks through. + +Retail does not use a sphere here. `CPhysicsObj::calc_cross_cells` @`0x00515230` +routes BSP-bearing objects to `find_bbox_cell_list` @`0x00510fc0` → +`calc_cross_cells_static` @`0x00518160`, i.e. a walk over the object's +**extent**, not a single enclosing sphere. + +**Relationship to AP-156 (`b52967de`), which did not fix this and was not +expected to:** AP-156 moved the flood sphere to the right *place* (it was +centred on the part origin while sized from the geometry). That was necessary +and is confirmed correct — but the sphere is also the wrong *shape* for objects +whose extent exceeds their own radius. AP-156 fixed position; this issue is +about coverage. Sequential, not alternative. + +### Superseded — the original leading candidate, retained for provenance + +### Superseded detail — AP-158 / #333, the broadphase reach filter `TransitionTypes.cs:3898`-ish measures `currPos - obj.Position` (the part **origin**) against `obj.Radius`, admitting a contact only when the geometry From 13fcf381386ab5f8ae367ad229fce4a55be8987f Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 19:07:06 +0200 Subject: [PATCH 3/9] fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334) acdream had never implemented retail's SECOND cell-membership algorithm. CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at 0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list @0x00510fc0 for a BSP-bearing object; everything below that jump is the OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every object, BSP-bearing or not, was routed through it. That path's outdoor expansion is a HARD CAP of one cell in each direction. CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius and adds at most the eight neighbours of the sphere's own cell, so for any radius >= 12 m both boundary tests are unconditionally true and the result is exactly 3x3. Widening the radius or adding a second sphere is mechanically incapable of adding a tenth cell. The user's live probe measured the consequence directly: standing inside a Neftet formation, inCell=2 exempt=2 reached=0 -- the geometry was not a candidate at all. The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells @0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST part's own adjust_to_outside, baseX/baseY within-block, each part's authored CGfxObj::gfx_bound_box re-fit through all eight corners (BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses landblocks freely, clamped only to [0, 0x7f8). BuildShadowCellSetFromParts is find_bbox_cell_list's worklist. RegisterMultiPart dispatches on the same flag retail does, and BuildFloodSpheres' BSP arm is deleted rather than left unreachable. Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary Ninja: BN mis-renders four separate constructs inside add_all_outside_cells alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as identically zero, a wrong get_landcell argument, and both x87 flag tests as `unimplemented {test ah}`. ShadowPartGeometry pairs the BSP root sphere with the authored box so no resolver can answer one and leave the other call site to synthesize a substitute -- the AP-156 invariant applied a second time, since that split is what produced AP-156 and then this. The box comes from FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's algorithm and already in the prepared package: no bake change, no DAT re-read. Cost, measured over the installed DATs before any code was written: 1,258 physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells) over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x); dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles. Precondition confirmed before pinning any expected cell set: 0x010046D8's box is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and 0x87640019 -- the two cells the probe measured empty. Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read "extra broadphase candidates, never a missed one", which generalised the indoor direction to the whole row and is why #334 sat inside it unnoticed). AP-159 + issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle. Issue #336 files a fourth load-sensitive test flake seen once during the gate. Ten tests, every one sabotage-verified in both directions across eight mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part, map clamp, adjust guard, landblock clamp, box-path-for-everything). The strongest is an installed-DAT replay of the user's own probe evidence. Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the new tests. Co-Authored-By: Claude Opus 4.8 --- docs/ISSUES.md | 61 +- .../retail-divergence-register.md | 8 +- docs/research/2026-08-06-334-contract.md | 774 ++++++++++++++++++ .../Physics/LiveEntityCollisionBuilder.cs | 27 +- src/AcDream.Core/Physics/CellTransit.cs | 327 +++++++- src/AcDream.Core/Physics/PhysicsDataCache.cs | 19 + .../Physics/ShadowObjectRegistry.cs | 220 +++-- src/AcDream.Core/Physics/ShadowPartBox.cs | 171 ++++ src/AcDream.Core/Physics/ShadowShape.cs | 67 +- .../Physics/ShadowShapeBuilder.cs | 51 +- .../LiveEntityCollisionBuilderTests.cs | 6 +- ...pBitfieldSurvivesAppearanceRebuildTests.cs | 6 +- ...InstalledSetupBspPrimitiveDispatchTests.cs | 4 +- ...ue334NeftetFormationCellMembershipTests.cs | 142 ++++ .../Physics/DoorBugTrajectoryReplayTests.cs | 6 +- .../Issue334BspBoxCellMembershipTests.cs | 412 ++++++++++ .../ShadowObjectRegistryMultiPartTests.cs | 18 +- .../Physics/ShadowObjectRegistryTests.cs | 2 +- .../Physics/ShadowSetPositionCommitTests.cs | 2 +- 19 files changed, 2186 insertions(+), 137 deletions(-) create mode 100644 docs/research/2026-08-06-334-contract.md create mode 100644 src/AcDream.Core/Physics/ShadowPartBox.cs create mode 100644 tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 519a5d1f..fa262dd4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,31 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX + +**Status:** OPEN +**Severity:** low. Over-inclusive only — extra broadphase candidates indoors, never a missed one. The opposite direction (the outdoor half) was #334 and is closed. +**Filed:** 2026-08-06, at the #334 fix. +**Component:** physics / cell membership +**Register row:** AP-159. + +#334 ported `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 and the OUTDOOR arm of the part-array `find_transit_cells` it dispatches to (`CLandCell::find_transit_cells` @0x00533840 → `add_all_outside_cells` @0x00533360 → `add_cell_block` @0x005331d0). The INDOOR arm of that same dispatch is still acdream's sphere traversal. + +**What retail does** (`CEnvCell::find_transit_cells` @0x0052cae0, disassembled from the PDB-paired 2013-09-06 binary), per portal × per part: + +1. cheap reject: the part's `CGfxObj::physics_sphere` centre through `Position::localtolocal` (`0x0052cb5a`), tested against the portal plane with `eps = F_EPSILON + radius` (`0x0052cb65`); +2. on pass, the ADMITTING test is box-vs-plane: `CPhysicsPart::GetBoundingBox` @0x0050d600 (`0x0052cbdd`) → `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) → `Plane::intersect_box` @0x005aa170 (`0x0052cc05`); +3. if the side differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` (`0x0052cc1e`) sets the “leads outside” flag; otherwise `CCellPortal::GetOtherCell` @0x0053ba30 (`0x0052cc2b`) — a THISCALL on the portal record (`ecx` set at `0x0052cc18`) taking ONE explicit argument, `cellarray->do_not_load_cells` (`0x0052cc27 mov eax,[edi+4]`; the CELLARRAY layout is +0 `added_outside`, +4 `do_not_load_cells`, +8 `num_cells`, +0xc `cells`, cross-checked against `find_bbox_cell_list`'s `0x00510fc8`/`0x00510fcf` zeroing and `add_all_outside_cells`' `0x0053336c` read of `[arg3]`). That RESOLVES the #334 contract's open question 11.4 in the AFFIRMATIVE — the flag IS threaded through, as the single explicit argument, not omitted. Then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 → `BSPTREE` @0x0053c880 gates the add (`0x0052cc5a`); +4. after all portals, the outside flag runs `add_all_outside_cells` (`0x0052ccea`). + +**What acdream does:** `CellTransit.BuildShadowCellSetFromParts`'s indoor arm calls `FindTransitCellsSphere` with the per-part BSP root spheres (`ShadowObjectRegistry.BuildBspPartSpheres`), i.e. step 1's cheap reject used as the admitting test. Same for the outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0). + +**Why it was deferred rather than folded into #334:** closing it needs a BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations, plus their exact referee — a separately gateable change with no bearing on #334's outdoor defect, and one that no #334 gate would exercise. Adding ~150 lines of unverified geometry under a green-but-uncovering test is the failure mode this campaign has now hit ten times. + +**Files:** `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm, `FindTransitCellsSphere`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`). + +--- + ## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested **Status:** OPEN @@ -1741,6 +1766,28 @@ it. Do #297 FIRST — #298 depends on it. the radar, same defect class, same fix shape (mirror the property into the bitfield/snapshot at its source). Filed from the #297 delta review. +- **#336 — OPEN — `RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` is a FOURTH, load-sensitive flake — distinct from #302, #308 and #321. LOW.** + `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2381` asserts + `GC.GetAllocatedBytesForCurrentThread()` is EXACTLY 0 across a warmed 10,000-iteration + steady-contact refresh loop. Observed failing once on 2026-08-06 inside a full-solution + `dotnet test AcDream.slnx -c Release -m:1` run (measured 2,944 bytes), then passing on the + immediate full-suite retry, on both of two isolated `AcDream.Runtime.Tests` project runs, + and on a filtered single-test run. + **Filed rather than absorbed, and deliberately NOT conflated with the other three.** + It shares #302's MECHANISM (an exact `GC.GetAllocatedBytesForCurrentThread()` assertion, + sensitive to JIT tiering and background GC on the measuring thread) but it is a different + test in a different assembly — #302 is `AcDream.App.Tests`, this is + `AcDream.Runtime.Tests` — so “the known allocation flake” would hide whichever of the two + is real on any given run. #308 is a wall-clock deadline in `AcDream.Core.Net.Tests`; + #321 is a concurrent-decode dedup in the sound cache. Four mechanisms, four rows. + **Not caused by #334's cell-membership port,** which is the change in flight when it was + seen: the measured loop calls only `RuntimeCollisionReportingState` handling, registers no + shadow inside the measurement, and touches none of `CellTransit` / `ShadowObjectRegistry` / + `ShadowShape`. Fix shape, same as #302: warm the path before measuring, or assert a bounded + range rather than an exact zero — matching how the other allocation gates in the repo are + written. Do not delete the assertion; the 0 B/resolve budget it guards is a real Slice I1 + invariant. + - **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray` is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of `AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on @@ -10556,7 +10603,19 @@ missing is the plugin-API surface. ## #334 — Large static formations lose collision at their boundaries (Neftet) -**Status:** OPEN +**Status:** DONE (2026-08-06) — retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path is ported. A physics-BSP object's outdoor cell membership is now the FILLED land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans, crossing landblock boundaries freely, instead of the fixed 3×3 sphere neighbourhood. Awaiting the user's live gate at the same Neftet formations. + +**Fix.** `CellTransit.BuildShadowCellSetFromParts` + `CellTransit.AddAllOutsideCellsFromParts` (`CLandCell::add_all_outside_cells` @0x00533360 + `add_cell_block` @0x005331d0, disassembled from the PDB-paired 2013-09-06 binary — Binary Ninja mis-renders four separate constructs inside that one function); `ShadowPartGeometry` / `ShadowPartBox` carry the BSP root sphere AND the authored box as one value; `ShadowObjectRegistry.RegisterMultiPart` dispatches on `HAS_PHYSICS_BSP_PS` exactly as retail does at `0x00515285`, and `BuildFloodSpheres`' BSP arm is deleted rather than left unreachable. Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read “extra broadphase candidates, never a missed one” — #334 is a missed one); AP-159 (indoor part-array overload, issue #335) and AD-49 (seed-time rectangle) filed. + +**Cost, measured over the installed DATs BEFORE any code was written** (1,258 physics-BSP GfxObjs with vertices): cells/object p50 = 4, p90 = 4, p99 = 12, max 49 (7×7). The port is CHEAPER than the old 3×3 = 9 for 98.97% of them — the crossover is exact, any object under 24 m of XY extent yields at most 2×2. Row totals (`shapes × cells`) over all 1,031 landblocks carrying BSP owners fall from 97,173 to 15,607 (0.161×); dense Arwic 0xC6A9 falls 342 → 43 (0.126×). Exactly ONE landblock more than doubles (0x8964, 45 → 112 rows, 2.489×). The worst single-owner rectangle in the whole world is 81 cells (9×9) in 0x8766 — above the 7×7 bound predicted from root-sphere statistics, because that bound assumed the BSP root sphere bounds the whole vertex array and it bounds only the physics polygons' subset. + +**Precondition confirmed before any expected cell set was pinned:** `0x010046D8`'s authored box is 96 m × 96 m about a part origin at block-local (63.78, 56.29) — cell (2,2) = `0x87640013`, which independently corroborates the 3×3-centred-at-`0x87640013` diagnosis derived from the live probe. Its rectangle spans cell columns 0..4 on both axes and DOES contain `0x87640011` and `0x87640019`, the two cells the probe measured empty. + +**Gate note (AP-158 / #333):** the fix is necessary and not sufficient in general. A player at the far corner of a large new rectangle can still be discarded by the broadphase reach filter, which measures from the part origin. The live gate FAILS if `inCell` rises while `rejectedReach` rises with it; the remedy for that is #333, not a wider budget here. + +Original finding below. + +**Status (at filing):** OPEN **Severity:** HIGH — walk-through and fall-through on world geometry. **Filed:** 2026-08-06, user-reported in live play. **Component:** physics / collision / broadphase diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0d49d97f..c1b2bc6c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 48 active rows (AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 49 active rows (AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -111,6 +111,7 @@ readiness/requeue adaptation. See | AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests | | AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is | | AD-48 | **Filed at Campaign V slice V11 (2026-07-29).** Presentation is paced by the Vulkan swapchain present mode (FIFO, i.e. VSync) or by a refresh-rate software pacer when uncapped, rather than by retail's D3D9 `Present` with its own frame-rate limiter. Frame delivery cadence, and therefore input-to-photon latency, is a property of our present path rather than a port of retail's. | `src/AcDream.App/RuntimeOptions.cs:98-100`; `src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs` | Retail's limiter and ours both bound the frame rate to the display; the simulation is fixed-step and clock-driven, so gameplay timing does not ride on presentation cadence. The uncapped path exists for measurement and is not the shipping default. | A pacing mismatch shows up as judder or input latency that differs from retail's feel without any visual difference in a captured frame — invisible to every pixel gate by construction. Issue **#235** (the capped/RDP jump-presentation cadence alias) is the known live instance of this class. | D3D9 `IDirect3DDevice9::Present`; retail's frame limiter in `RenderDeviceD3D` | +| AD-49 | **Filed 2026-08-06 at the #334 fix.** `CellTransit.BuildShadowCellSetFromParts` runs the outdoor cell rectangle AT SEED TIME for an outdoor seed, then gates only the growing-array WALK on cell residency. Retail's `find_bbox_cell_list` @0x00510fc0 gates everything on `obj->cell` (`0x00510fed test eax,eax` / `je 0x511020`), reaching `add_all_outside_cells` only from the walk. Retail can: a placed `CPhysicsObj` always holds a resident `CObjCell`. acdream's `CellGraph` residency is transiently false during landblock streaming (the #168 / #169 residence-race family), so deferring the rectangle to the walk would drop a landblock static or a live entity to a SINGLE cell for the window before its landblock publishes. This is the same residency policy `BuildShadowCellSet` already applies to its outdoor seed - retail's `CObjCell::find_cell_list` calls `add_all_outside_cells` at `0x0052b53f`, ahead of the `arg4` walk gate at `0x0052b576` - so the two registration floods differ only in sphere-vs-box, which is the whole of #334. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts` seed block) | Keeps the sphere and box floods on ONE residency rule, so a future streaming-race fix has one place to change rather than two that disagree. The alternative - retail's literal shape - would introduce a new transient under-inclusive window, which is the #98 / #168 direction. | Over-inclusive only: an object whose landblock is not yet resident registers its full rectangle immediately instead of after the reflood (`ShadowObjectRegistry.RefloodOwnerForLandblock`, driven by `LandblockPhysicsContentBuilder.PublishStaticCollision`'s tail). The rows are correct the moment the cells exist; nothing is registered that the box does not span. | `CPhysicsObj::find_bbox_cell_list` 0x00510fc0 (0x00510fe2 / 0x00510fed); `CObjCell::find_cell_list` 0x0052b4e0 (0x0052b53f / 0x0052b576) | | AD-50 | **Filed at Campaign N slice N2 (2026-07-29).** The inbound sequence tracker's watermark (`highestIDReceived_`) initializes to **1**, not retail's zero-init of `ReceiverData`. Watermark INIT only — every mechanism (sanity window, duplicate/parked-key path, gap walk, re-park, RejectRetransmit abandonment) is the verbatim retail port. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`AceInitialWatermark`) | ACE never emits S2C sequence 1: its `PacketSequence` starts unprimed at `uint.MaxValue`, the cleartext ConnectRequest takes NextValue 0, and the first ENCRYPTED flush re-primes CurrentValue to 1 so the first encrypted sequenced packet is 2 (ACE NetworkSession.cs:716-717 + Sequence/UIntSequence.cs:9-13,30-41; pinned by the N0 double and the N2 clean-lifecycle conformance test asserting min encrypted S2C sequence == 2 with zero NAKs). A zero-init watermark would gap-walk the permanent id-1 hole: one spurious NAK, the first pre-drawn word mis-assigned to id 1, and the keystream off by one from the very first encrypted packet. holtburger seeds the same value (crates/holtburger-session/src/session/api.rs:30, `last_server_seq: 1`), mirroring ACE's own C2S-side `lastReceivedPacketSequence = 1` (NetworkSession.cs:57). | Against a hypothetical server that DOES emit sequence 1 as its first encrypted packet (retail's own numbering), init-1 would classify it "not newer" and drop it as a duplicate — the mirror-image wedge. Only ACE-family servers exist for this client today. | `ReceiverData` zero-init (construction inside `SharedNet`; `highestIDReceived_` starts 0); `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the walk that would mis-NAK id 1) | | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | @@ -162,7 +163,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 110 active rows (AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 111 active rows (AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -183,9 +184,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-153 | **Filed 2026-08-06 at the AP-152 retirement — a modelling difference the fix itself introduces.** Retail's shape-dispatch flag is CACHED ONCE. `CPartArray::CacheHasPhysicsBSP` @0x00518110 walks the part array, ORs 0x10000 into `CPartArray::pa_state` on the first part whose `gfxobj->physics_bsp` is non-null, and `CPhysicsObj::CacheHasPhysicsBSP` @0x0050f570 mirrors it onto `CPhysicsObj::state+0xa8`. A full `.text` scan for direct call/jmp to 0x0050f570 finds EXACTLY ONE caller, `CPhysicsObj::InitPartArrayObject+0x7e` @0x0051272e — so after an `AnimPartChanged` part swap retail's DISPATCH flag is stale while its per-part test (`CPhysicsPart::find_obj_collisions` @0x0050d8d0) stays live. acdream's step-0 gate is LIVE in both: it re-derives from the effective part identities on every `FromSetup` call. | `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (`ReconcileAppearance`) | The two disagree only when a swap adds or removes the LAST physics-BSP part. Humanoid part swaps (clothing / armour) involve no physics-BSP GfxObj on either side, so this is unreachable against ACE today. Deliberately NOT modelled with cached state — that would be inventing staleness to reproduce a retail bug. | If a server ever swapped a prop's part array across the physics-BSP boundary, acdream would switch its collision geometry on the swap where retail would keep dispatching on the construction-time flag: a prop that gained a BSP part would lose its primitive immediately in acdream and only on re-init in retail. | `CPartArray::CacheHasPhysicsBSP` 0x00518110; `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570; sole caller `CPhysicsObj::InitPartArrayObject+0x7e` 0x0051272e | | AP-154 | **Filed 2026-08-06 at the AP-152 retirement (contract §11.6) — an undeclared dependency on a specific server implementation.** Retail COMPUTES `HAS_PHYSICS_BSP_PS` itself from its own part array (AP-153's anchors). acdream's query-time guard `Transition.BspOnlyDispatch` reads it out of the SERVER's wire `PhysicsState`: `LiveEntityCollisionBuilder.cs:161` copies `exactRecord.FinalPhysicsState` into `ShadowEntry.State`, and a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` returns only that predicate and one unrelated mover-state read. acdream never ORs the bit in client-side. It happens to be correct because ACE derives the same DAT bit (`WorldObject_Networking.cs:665-668` from `SetupFlags.HasPhysicsBSP`), overriding the weenie's authored value — which is why a 2018 weenie dump showing `PhysicsState = 0x8` for the cottage door does not contradict our own live capture of `0x10008`. | `src/AcDream.Core/Physics/TransitionTypes.cs:1348` (`BspOnlyDispatch`), call sites `:3911` / `:3954`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:161` | Narrowed, not closed, by the AP-152 fix: the shape list no longer contains a primitive for a BSP-bearing object, so the guard has nothing left to skip and the OUTCOME is now independent of the wire. The guard itself still keys on the wire. Not bundled — changing `registration.State` touches every consumer of `FinalPhysicsState` (Hidden, Missile, ethereal layer 2, the `[setstate]` log) and needs its own gate. | Against a server that does not derive the bit from the DAT, a BSP-bearing object built by a producer other than `FromSetup` would have its primitive tested where retail tests only the BSP. | `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives) vs `LiveEntityCollisionBuilder.cs:161` (copies); `HAS_PHYSICS_BSP_PS` acclient.h:2833 | | AP-155 | **Filed 2026-08-06 at the AP-152 retirement; NARROWED 2026-08-06 to its static-publication half alone.** Its flood half was bundled here with a different code path, a different population and a different gate — the exact fault the C4 handoff warns about — and its direction was recorded BACKWARDS; both are now split out as AP-156. **Static paths emit a Setup Sphere as a height-capped CYLINDER.** `LandblockPhysicsPublisher.cs:1030-1037` and `LandblockPhysicsContentBuilder.cs:683-690` both convert a Setup Sphere to `ShadowCollisionType.Cylinder` with `CylHeight = radius * 2f` and the origin shifted down by one radius; the live path emits a true `ShadowCollisionType.Sphere`, produced at exactly ONE site in `src/` (`ShadowShapeBuilder.cs`). Retail tests a Setup Sphere with `CSphere::intersects_sphere` @0x00537a80 / @0x00537fd0 (two overloads) in both cases — 3-D distance, no height clamp. The static paths also derive "has BSP" from `entity.MeshRefs` (the render mesh list) where the live path derives it from `setup.Parts` plus the effective post-`AnimPartChanged` identities; the two sources can disagree. | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1030-1037`; `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:683-690` | Affects static props only and changes their collision geometry over a much larger population than AP-152's 172, so it needs its own count and its own gate. Deliberately not folded into the AP-152 or AP-156 commits. | A static prop whose Setup carries a Sphere blocks over a height-clamped cylinder instead of a true sphere, and rests one radius lower than the authored origin. | `CSphere::intersects_sphere` 0x00537a80 / 0x00537fd0 | -| AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. | A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | +| AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. **OUTDOOR HALF CLOSED 2026-08-06 by #334 (see AP-159 for what remains).** | **RISK COLUMN CORRECTED 2026-08-06 at the #334 fix — as written below it was FALSE, and its falsity is what let #334 sit unnoticed inside this row.** It generalised the INDOOR direction (sphere-vs-portal-plane, over-inclusive) to the whole residual. The OUTDOOR direction was the opposite and strictly worse: acdream routed BSP-bearing objects through `CObjCell::find_cell_list`, whose outdoor expansion is a hard-capped ±1-cell 3×3 for ANY radius, so every formation wider than one 24 m land cell was MISSED in its outer cells — a user-observed loss of collision, not extra candidates. Original text, retained for the record: *"A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed."* That statement now holds only for the indoor half, which is AP-159. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | | AP-157 | **Filed 2026-08-06 at the AP-152 retail-conformance review (finding F4) — an unregistered substitution that predates AP-152 and was stepped over when its neighbours were filed.** `CPhysicsObj::calc_cross_cells`' THIRD branch (`0x005152dc` -> `CPartArray::GetSortingSphere` @0x00518b00 -> `CObjCell::find_cell_list` @0x0052b990) floods from ONE authored whole-object sphere: `GetSortingSphere` returns `[partArray+0x54] + 0x70`, i.e. `CSetup::sorting_sphere` (acclient.h: `CSetup` carries `CSphere sorting_sphere` immediately after `step_up_height`), and that overload takes a single sphere with no cap. acdream's `only == null` branch floods from EVERY non-BSP, non-Cylinder shape instead — the Setup's per-part `Spheres` array. Different DAT field, different cardinality, different extent. 4,154 of 5,935 installed Setups carry a non-zero `SortingSphere` and `DatReaderWriter.Setup` already exposes it, so this is available rather than blocked. Same site, second item: `BuildFloodSpheres` collapses a Cylinder to one sphere at its BASE point with the cylinder radius and IGNORES `CylHeight` entirely, where retail's `CObjCell::find_cell_list` @0x0052b9f0 is handed the `CCylSphere` array as `(low_pt, radius, height)`. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`, the `anyCyl` and `only == null` branches) | Deliberately NOT folded into the AP-156 fix. It is a different branch of `calc_cross_cells`, reached only by objects with neither a physics BSP nor a CylSphere, so its population is disjoint from the 172 AP-152 Setups and its live gate is a different set of objects. Bundling it would make the AP-156 connected gate un-attributable — which is exactly how AP-155 came to carry two lifecycles under one id. | Sorting-sphere half: an object with several authored Spheres floods from all of them rather than from the one authored whole-object sphere — usually wider (max 5 Spheres on any installed Setup, so retail's 10-cap is never the difference), but a `sorting_sphere` LARGER than every per-part Sphere would make acdream under-inclusive, the #98 / #168 direction. CylHeight half: a tall thin cylinder floods a sphere of its radius at its base and can miss the cells its upper half occupies. | `CPhysicsObj::calc_cross_cells` 0x00515230 (0x005152dc / 0x005152e3 / 0x005152fb); `CPartArray::GetSortingSphere` 0x00518b00 (`[+0x54]+0x70`); `CObjCell::find_cell_list` 0x0052b990 (sorting sphere) / 0x0052b9f0 (cylsphere, `(low_pt, radius, height)`) | | AP-158 | **Filed 2026-08-06 at the AP-156 fix review (finding A2) — an UNREGISTERED INVENTION, not a port, that predates AP-156 and is issue #333.** The shadow broadphase discards a candidate outright when `distToCurr > sphereRadius + obj.Radius + movement.Length() + 2f`. **Retail has no distance pre-filter at all.** `CObjCell::find_obj_collisions` @0x0052b750, disassembled from the PDB-paired binary for this row rather than inherited: it early-returns `OK_TS` only when `sphere_path.insert_type == INITIAL_PLACEMENT_INSERT` (`0x0052b759 cmp dword [ebx+0x174],2` / `0x0052b765 je 0x52b7a0`), then walks `shadow_object_list` (`[cell+0xc8]`, count `[cell+0xc4]`) and calls `CPhysicsObj::FindObjCollisions` (`0x0052b78b call 0x50f050`) on every entry whose `physobj` is unparented (`[physobj+0x40] == 0`) and is not the mover itself — UNCONDITIONALLY. There is no distance test in the function. Neither the `+ 2f` slack nor the `movement.Length()` term has a retail counterpart; retail's own cross-cell slack constant is `F_EPSILON` = 1.9999999e-4 m (`0x0052cb5f fld dword [0x7c8c70]`), 0.0002 m and not 2 m. **Second half of the defect:** the filter measures `currPos - obj.Position`, i.e. from the PART ORIGIN, while `obj.Radius` is the BSP root bounding-sphere radius measured about a centre that AP-156 established is frequently metres away — `ShadowEntry` does not carry the `BoundsCenter` that `ShadowShape` now does. A mover touching the geometry is up to `d + R + r` from the part origin and is admitted only when `d <= movement + 2`, roughly 2.5 m for a walking player. | `src/AcDream.Core/Physics/TransitionTypes.cs:3757-3765`; `ShadowEntry` (`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:2735`) carries no `BoundsCenter` | Deliberately NOT folded into the AP-156 commit: different code path (collision query, not cell membership) and it needed its own retail question answered, which this row answers. The minimal fix is mechanical — carry `BoundsCenter` on `ShadowEntry` and measure from `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`; only whether to keep the `+ 2f` slack at all is genuinely open. | **This is the gate immediately downstream of AP-156, and it can mask AP-156's entire visible benefit.** 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above the ~2.5 m budget and 46 above 5 m; at a test scale of 1.75 those become 4.4 m and 8.75 m against an unchanged budget. Worked case: Setup 0x02000255, one part, root sphere origin (0.000, -0.007, 9.911), radius 10.522 — a player against its upper half is ~20.4 m from the part origin while `maxReach` is ~13.5 m. Discarded before `BSPQuery` ever runs. A tall prop that still does not block after AP-156 is THIS row, not a failure of AP-156. | `CObjCell::find_obj_collisions` 0x0052b750 (0x0052b759 / 0x0052b765 / 0x0052b788 / 0x0052b78b), pseudo-C 308916-308940; `CEnvCell::find_transit_cells` 0x0052cae0 (`F_EPSILON` at 0x0052cb5f -> 0x7c8c70); issue #333 | +| AP-159 | **Filed 2026-08-06 at the #334 fix - the INDOOR half of AP-156's traversal residual, now the whole of it.** #334 ported retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path so a physics-BSP object's OUTDOOR membership is the filled land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans (`CLandCell::add_all_outside_cells` @0x00533360 -> `add_cell_block` @0x005331d0). The INDOOR arm of that same walk is NOT ported: retail's part-array `CEnvCell::find_transit_cells` @0x0052cae0 admits a neighbour cell on a BOX test - `CPhysicsPart::GetBoundingBox` @0x0050d600 -> `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) -> `Plane::intersect_box` @0x005aa170 (`0x0052cc05`), then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 -> `BSPTREE` @0x0053c880 - where acdream keeps `CellTransit.FindTransitCellsSphere`'s sphere-vs-portal-plane test, fed from the SAME per-part `CGfxObj::physics_sphere` values retail uses for its cheap `eps = F_EPSILON + radius` pre-reject at `0x0052cb65`. The outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0) is on the same sphere input for the same reason. Deferred deliberately: closing it needs a new BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations plus their exact referee, which is a separately gateable change with no bearing on #334's outdoor defect. Filed as issue #335. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts`, indoor arm); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`) | The sphere set is exact (AP-156) and the sphere is a strictly LOOSER admitter than the box for a convex part, so the indoor set is a superset of retail's. Retail is itself conservative here in four compounding ways (render-mesh AABB over physics hull, axis-aligned re-fit after rotation, filled rectangle over per-cell test, one rectangle unioned across parts), so an over-inclusive indoor set is the same direction retail errs in. | A cell whose portal plane a part's sphere straddles but whose box does not joins the object's shadow set: extra broadphase candidates, never a missed one. This is AP-156's original risk statement, which is true of the indoor half and was false of the outdoor half. | `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cbdd / 0x0052cbf9 / 0x0052cc05 / 0x0052cc5a); `Plane::intersect_box` 0x005aa170; `CCellStruct::box_intersects_cell` 0x00533910; `CEnvCell::check_building_transit` 0x0052c5d0 | | ~~AP-152~~ | **RETIRED 2026-08-06 (the commit that filed it is one day old; this retirement corrects four statements in it).** `ShadowShapeBuilder.FromSetup` now DISPATCHES instead of unioning: a step-0 gate derived from the parts suppresses steps 1 and 2 whenever any part's EFFECTIVE GfxObj carries a physics BSP. Retail's priority, re-disassembled from the PDB-paired binary for this commit rather than inherited: `CPhysicsObj::FindObjCollisions` @0x0050f050 tests `HAS_PHYSICS_BSP_PS` FIRST (`0x0050f165 test dword [esi+0xa8],0x10000` / `0x0050f16f je 0x50f1a2`) and leaves the BSP branch through the UNCONDITIONAL `0x0050f19d jmp 0x50f2b0`, which is past the CylSphere loop at 0x50f1a2 AND the Sphere loop at 0x50f21d; a CylSphere-bearing object that survives its loop RETURNS (`0x0050f1d6 jae 0x50f317`); a Setup with zero spheres returns the seeded OK_TS (`0x0050f22f je 0x50f31b`). **BSP wins.** **CORRECTION 1 — the row's risk statement was FALSE as written.** It predicted "catching or stopping on a doorway sill". acdream did not test the extra primitive either: `Transition.BspOnlyDispatch` (`TransitionTypes.cs:1348`, landed 2026-05-25 as A6.P7) already skipped BOTH primitive branches (`:3911`, `:3954`) whenever the target's wire `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` (`WorldObject_Networking.cs:665-668`). The row's own anchor column cites the flag it failed to notice acdream was already keying on. So this retirement is NOT a collision-response change; the live half was CELL MEMBERSHIP, which had no such guard (see AP-155). **CORRECTION 2 — "the affected primitives are small and centred at the part origin" was FALSE in both halves.** The largest is `0x02001741`'s CylSphere at **r = 6.714 m**; `0x0200086E`'s Sphere is r = 5.842 m with origin (0.759, 0.165, 5.842), nowhere near the part origin. **CORRECTION 3 — the cottage door's "~14 cm base Sphere" was the wrong field.** `0x020019FF`'s Sphere radius is **0.100 m** at origin (0, 0, 0.018); `0.141` is `Setup.Radius`, which AP-22 had just finished proving is never collision geometry. **CORRECTION 4 — the row named ONE pinning test where TWO existed.** `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` also failed under the exclusive rule; both are corrected, neither deleted. Population re-measured independently at 172 of 5,935 (73 CylSphere+BSP, 99 Sphere+BSP; 530 carry a physics-BSP part), agreeing exactly with the filing commit's separate sweep, and now pinned by an installed-DAT test with external bucket controls. | RETIRED — `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0 gate + `EffectivePartGfxObjId`, shared with step 3 so the two can never read different identities); `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_EmitsBspPartsOnly`, `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, `FromSetup_DispatchGateReadsTheEffectivePartIdentities`); `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` (`CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape` — no App fixture combined a primitive with a BSP part before); `tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs` (population). `Transition.BspOnlyDispatch` is deliberately KEPT: retail genuinely dispatches at the query site too, and it guards against a future additive producer. | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f); `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 / 0x0051528f) -> `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPhysicsPart::find_obj_collisions` 0x0050d8d0; `CPartArray::CacheHasPhysicsBSP` 0x00518110; evidence `docs/research/2026-08-06-ap152-contract.md` | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | diff --git a/docs/research/2026-08-06-334-contract.md b/docs/research/2026-08-06-334-contract.md new file mode 100644 index 00000000..9b668b16 --- /dev/null +++ b/docs/research/2026-08-06-334-contract.md @@ -0,0 +1,774 @@ +# #334 — implementation contract: port retail's BSP cell-membership path + +**Filed** 2026-08-06. **Base** `f0588725`, branch +`claude/resume-session-e0bd03e1-d5bf45`. +**Status** IMPLEMENTED 2026-08-06. S0 measured (see the ISSUES #334 entry for the +figures); the S1/S2 split was collapsed into one commit because S1 alone changes no +behaviour and its P2 golden is asserted by S2 test T6. Deviations from this contract, +all reported at the fix: (a) §2.7 / T7 — the INDOOR part-array overload is NOT ported +and is now AP-159 / issue #335, so the fix covers the outdoor half only, which is the +whole of the measured defect; (b) the outdoor rectangle runs at seed time rather than +only from find_bbox_cell_list’s residency-gated walk (AD-49); (c) §11.4 is RESOLVED — +CCellPortal::GetOtherCell @0x0053ba30 IS handed cellarray->do_not_load_cells as its +single explicit thiscall argument (0x0052cc27/0x0052cc2b); (d) §4.2’s 7×7 upper bound +is EXCEEDED in the field — the worst single-owner rectangle over all installed +landblocks is 9×9, for the reason the contract itself caveated. Everything else in +§1 was independently byte-verified by disassembly and held. + +**Original status** CONTRACT ONLY — no production code, no tests, no commit in the +session that produced this file. + +**One line.** acdream has never implemented retail's +`CPhysicsObj::find_bbox_cell_list` path at all. Every object — including +BSP-bearing ones — is routed through a port of the *other* branch, +`CObjCell::find_cell_list`, whose outdoor expansion is a fixed 3×3 land-cell +neighbourhood. The fix is to implement the missing path, not to enlarge +anything. + +--- + +## 0. Executive summary + +Retail dispatches cell membership on `HAS_PHYSICS_BSP_PS` (0x10000) into two +structurally different algorithms. acdream implements one of them and uses it +for both. + +| | retail | acdream at `f0588725` | +|---|---|---| +| BSP-bearing object | `find_bbox_cell_list` → per-part **bounding box** → filled land-cell **rectangle** | `BuildShadowCellSet` → per-part **sphere** → **3×3** neighbourhood | +| CylSphere object | `find_cell_list(cylspheres)` → 3×3 per sphere | same | +| Sorting-sphere object | `find_cell_list(sortingSphere)` → 3×3 | same shape, different source field (AP-157) | + +The outdoor 3×3 is a **hard cap of ±1 cell (±24 m)** and is independent of the +sphere's radius — see §9.3 for the proof. This is why AP-156 (which fixed the +sphere's *position*) could not fix #334, and why widening the radius, adding a +second sphere, or tuning any constant cannot fix it either. Those are not +merely disallowed by policy; they are mechanically incapable of adding a tenth +cell. + +--- + +## 1. Stage 1 — what retail actually does + +All addresses below were **disassembled from the PDB-paired binary** +`C:\Users\erikn\Downloads\acclient.exe` +(`py tools/pdb-extract/check_exe_pdb.py` → `=== MATCH ===`, linker +2013-09-06T00:17:56Z, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), +not taken from Binary Ninja. Every address in §1 was resolved to the construct +claimed for it. Four BN artifacts found in the process are listed in §9. + +### 1.1 The dispatch (`CPhysicsObj::calc_cross_cells` @`0x00515230`, pc:283332) + +``` +00515285 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000 +0051528f 7574 jne 0x515305 ; -> find_bbox_cell_list +00515291 8b4e10 mov ecx, [esi + 0x10] ; part_array +00515298 e8e32d0000 call 0x518080 ; GetNumCylsphere +0051529f 743b je 0x5152dc ; 0 -> sorting sphere +``` + +`0xa8` is `CPhysicsObj::state`; `0x10000` is `HAS_PHYSICS_BSP_PS` +(`acclient.h:2833`). The static twin +`CPhysicsObj::calc_cross_cells_static` @`0x00515160` (pc:283280) carries the +**identical** gate at `0x005151b0` and differs only in setting +`CELLARRAY::do_not_load_cells = 1`. Both tails are +`remove_shadows_from_cells` → `add_shadows_to_cells`. + +### 1.2 The flood driver (`CPhysicsObj::find_bbox_cell_list` @`0x00510fc0`, pc:279006) + +This function **forms no bounding box itself** — it is a worklist. That is the +grain of truth in the "no bounding box at all" claim, and it is why that claim +is misleading: the boxes are formed one and two levels down (§1.4, §1.6). + +``` +00510fd5 mov eax,[ebx+0x90] ; obj->cell +00510fe2 call 0x6b4ff0 ; CELLARRAY::add_cell(ca, cell->m_DID.id, cell) <- seed +00510ff8 mov eax,[esi+8] ; num_cells +00511012 call 0x518160 ; CPartArray::calc_cross_cells_static(pa, cell_i, ca) +00511017 mov eax,[esi+8] ; num_cells RE-READ each iteration <- the array GROWS +0051101d jb 0x511002 +``` + +Seed with the object's own cell, then walk the array while it grows. Transitive +closure over the cell graph, terminated by `CELLARRAY::add_cell`'s dedup. + +### 1.3 The per-cell dispatch (`CPartArray::calc_cross_cells_static` @`0x00518160`, pc:286228) + +Three-instruction thunk. **Not** the extent walk, despite the name: + +``` +00518176 ff527c call dword ptr [edx + 0x7c] ; cell->vtable[0x7c] +``` + +`CObjCell`'s vftable base is `0x007c8b20`; `+0x7c` = `0x007c8b9c`, which holds +`0x0052b080` — the 4-argument +`find_transit_cells(uint numParts, CPhysicsPart** parts, CELLARRAY*)` +overload, distinct from the 6-argument `(Position, uint, CSphere*, CELLARRAY*, +SPHEREPATH*)` sphere/transition overload at `0x0052b070`. + +Overrides: `CEnvCell` @`0x0052cae0` (pc:310127), `CLandCell` @`0x00533840` +(pc:317612), `CSortCell` @`0x00534080` (pc:318323), base `CObjCell` +@`0x0052b080` = `Turbine::Debug::Abort()`. + +### 1.4 Outdoors — the extent walk (`CLandCell::add_all_outside_cells` @`0x00533360`, pc:317289) + +`CLandCell::find_transit_cells` = `add_all_outside_cells` + `CSortCell`'s +building bridge. The extent walk is here. + +``` +if (cellarray.added_outside) return; # 0053336c, runs ONCE per flood +cellarray.added_outside = 1; +p0 = first non-null part; # 005333a2..005333ad +gid = adjust_to_outside(&p0->pos) ? outCellId : 0; + # 005333dd call 0x5a9bc0 + # 005333eb neg esi / sbb esi,esi / and esi,eax +cell0 = LScape::get_landcell(landscape, gid); # 0053340c +if (!cell0) return; # 00533417 je 0x53361c +if (!gid_to_lcoord(gid, &gx, &gy)) return; # 00533428, GLOBAL land-cell coords +baseX = ((gid & 0xFFFF) - 1) >> 3; # 0053343a and eax,0xffff / dec / shr 3 +baseY = (gid - 1) & 7; # 00533443 dec esi / and esi,7 +minDX = minDY = maxDX = maxDY = 0; # 00533390..0053339c +for each non-null part p: + if (!p->Always2D()): + b = BBox::LocalToGlobal(p->gfxobj->gfx_bound_box, p->pos, cell0->pos); + # 0053350e GetBoundingBox, 00533527 LocalToGlobal + a = floor(b.min.x / 24); bb = floor(b.min.y / 24); + c = floor(b.max.x / 24); d = floor(b.max.y / 24); + else: + (sphere centre -/+ radius) / 24, floored + minDX = min(minDX, a - baseX); # 005335a2 sub esi,edx / jge + minDY = min(minDY, bb - baseY); # 005335b4 + maxDX = max(maxDX, c - baseX); # 005335c2 / jle + maxDY = max(maxDY, d - baseY); # 005335d5 +add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY, cellarray); # 00533614 +``` + +**The four stack reads are byte-verified as `min.x, min.y, max.x, max.y`.** The +`fld` displacements (`[esp+0x48]`, `[esp+0x54]`, `[esp+0x5c]`, `[esp+0x60]`) +look inconsistent because `sub esp,8` at `0x533536` and `add esp,8` at +`0x533592` bracket the middle three; normalised to the entry frame they are +`+0x48, +0x4c, +0x54, +0x58`, and the out-`BBox` written by `LocalToGlobal` +(`lea ecx,[esp+0x54]` at three-pushes depth) is based at `+0x48`. A `BBox` is +`m_vMin`(0,4,8) `m_vMax`(0xc,0x10,0x14), so those four are exactly +min.x / min.y / max.x / max.y. Z is never read — land cells are a 2-D grid. + +The four accumulators are initialised to **0**, so the rectangle always +contains the base cell even when the box math contributes nothing. + +`square_length` = `0x7c920c` = `24.0f`, read from the binary +(`00 00 c0 41`). + +### 1.5 Filling the rectangle (`CLandCell::add_cell_block` @`0x005331d0`, pc:317202) + +``` +for x = x0 .. x1 inclusive: # 005331e4 / 0053324d jle + for y = y0 .. y1 inclusive: # 005331f0 / 00533246 jle + if (x >= 0 && y >= 0 && x < 0x7f8 && y < 0x7f8): # 2040 = 255*8 + id = (((x >> 3) << 8) | (y >> 3)) << 16 | ((x & 7) * 8 + (y & 7) + 1) + # 0053320a..0053322e + add_cell(ca, id, LScape::get_landcell(landscape, id)) +``` + +Three properties that matter: + +1. **The rectangle is filled, not outlined.** An L-shaped or diagonal object + claims cells its geometry never enters. Retail's coverage is deliberately + conservative. +2. **`x`/`y` are GLOBAL land-cell coordinates** over the 2040×2040 world grid + and the landblock prefix is re-derived per cell, so the rectangle **crosses + landblock boundaries freely**. +3. `add_cell` (`0x006b4ff0`) dedups by **id** via a linear scan and stores the + `LScape` pointer beside it — including `null` for a non-resident cell. + +`gid_to_lcoord` @`0x00497a90` (pc:163500) byte-verified to return global +coords: `*x = ((gid>>21) & 0x7f8) + ((cellIdx-1)>>3)`, +`*y = (lby << 3) + ((cellIdx-1) & 7)`. + +### 1.6 The box itself (`CPhysicsPart::GetBoundingBox` @`0x0050d600`, pc:274837) + +``` +0050d60a return &this->gfxobj->gfx_bound_box; +``` + +`gfx_bound_box` is filled by `CGfxObj::init_end` @`0x00534200` (pc:318480): +seed `min = max = vertices[0]`, then `BBox::AdjustBBox` over every vertex of +`vertex_array`. **It is the AABB of the GfxObj's vertex array in the GfxObj's +own frame** — the render vertex array, which is also the array the physics +polygons index into. + +`BBox::LocalToGlobal` @`0x005b2120` (pc:448440) is a proper **8-corner +re-fit**: transform `min`, seed both corners from it, transform the other +seven and `AdjustBBox` each. A rotated box therefore grows, conservatively. +The output frame is `cell0->pos`'s — i.e. landblock-local metres, which is +what makes `floor(v / 24)` comparable to `baseX`/`baseY` in 0..7. + +### 1.7 Indoors (`CEnvCell::find_transit_cells` @`0x0052cae0`, pc:310127) + +Per portal × per part: + +- centre of the part's physics sphere in cell-local space + (`Position::localtolocal`), tested against the portal plane with + `eps = 0.0002 + radius` (`0x0052cb65`) — a cheap reject; +- on pass, `BBox::LocalToLocal(partBBox, part->pos, cell->pos)` then + `Plane::intersect_box(portalPlane, box)` (`0x0052cc05`). **The admitting + test is box-vs-plane, not sphere-vs-plane.** +- if the result differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` sets + a flag meaning *this portal leads outside*; otherwise + `BBox::LocalToLocal` into the destination cell and + `CCellStruct::box_intersects_cell` gates the add. +- after all portals, the outside flag runs + `CLandCell::add_all_outside_cells` (`0x0052ccea`). + +### 1.8 Answer to "is retail exact or conservative?" + +**Conservative, in four compounding ways**, all in the over-inclusive +direction: the render-mesh AABB rather than the physics hull; axis-aligned +re-fit after rotation; a *filled* rectangle rather than a per-cell test; one +rectangle unioned across all parts rather than per-part rectangles. Retail +registers the object in cells its geometry does not touch and lets the narrow +phase reject. That is the safe direction (#98 / #168 are the other one), and it +means a faithful port does not need to be clever. + +--- + +## 2. The exact change, by symbol + +### 2.1 New: `CellTransit.BuildShadowCellSetFromParts` (`src/AcDream.Core/Physics/CellTransit.cs`) + +Port of `find_bbox_cell_list` (§1.2). Signature mirrors +`BuildShadowCellSet`, taking part boxes instead of spheres: + +``` +public static IReadOnlyList BuildShadowCellSetFromParts( + PhysicsDataCache cache, + uint seedCellId, + IReadOnlyList worldParts, // new value type, §2.3 + bool isStatic) +``` + +Body: seed with `seedCellId`; walk `candidates` by index while it grows +(re-reading `Count`, §1.2); per candidate dispatch outdoor → +`AddAllOutsideCellsFromParts` + the existing building bridge, indoor → +`FindTransitCellsParts`. + +### 2.2 New: `CellTransit.AddAllOutsideCellsFromParts` + +Port of §1.4 + §1.5. Reuses the existing `AddOutsideCell` helper (already +global-lcoord and already landblock-crossing — do not touch it) inside a +double loop, with the `0 <= v < 0x7f8` clamp from §1.5. Guarded by the same +once-per-flood `added_outside` latch `BuildShadowCellSet` already models, but +note the **cardinality change**: the sphere overload runs the whole body per +sphere; the parts overload computes **one** rectangle over all parts and runs +once. + +### 2.3 New: `ShadowPartBox` (`src/AcDream.Core/Physics/`) + +`(Vector3 LocalMin, Vector3 LocalMax, Vector3 LocalPosition, Quaternion +LocalRotation, float Scale)` — the per-part input to the 8-corner re-fit. +Follow `ShadowShape`'s AP-156 precedent: **factory-only construction, with min +and max arriving as one value**, so no future call site can take one and drop +the other. + +### 2.4 Changed: `ShadowShape` — carry the box + +Add `LocalBoundsMin` / `LocalBoundsMax`, filled by the **same resolver that +already supplies `Radius` and `BoundsCenter`**. This is the AP-156 invariant +re-applied: one resolver, one value, scaled together. + +Source: `FlatGfxObjVisualBounds.Min` / `.Max`, which +`FlatCollisionAssetBuilder.FlattenGfxObj` already computes from +`PhysicsDataCache.ComputeVisualBounds(source.VertexArray)` — **the exact +`CGfxObj::init_end` computation** — and which +`FlatCollisionAssetSerializer` already writes into the prepared package. **No +bake-format change, no DAT re-read, no new parsing.** This is the single +largest de-risking fact in this contract. + +Resolvers to widen: `ShadowShapeBuilder.FromSetup`'s +`physicsBspBounds: Func` and +`FromLandblockBspParts`'s `Func getGfxObj`; +`LiveEntityCollisionBuilder._physicsBspBounds` is the single live supplier. + +### 2.5 Changed: `ShadowObjectRegistry.RegisterMultiPart` + +The dispatch, mirroring §1.1 — this is the whole fix in one place: + +``` +bool hasBsp = shapes.Any(s => s.CollisionType == ShadowCollisionType.BSP); +var cellSet = hasBsp + ? CellTransit.BuildShadowCellSetFromParts(FloodCache, seed, boxes, isStatic) + : CellTransit.BuildShadowCellSet (FloodCache, seed, spheres, spheres.Count, isStatic); +``` + +### 2.6 What happens to `BuildFloodSpheres` + +**It stays, unchanged, and keeps its cap logic** — it is a correct port of the +`!HAS_PHYSICS_BSP` branch's two arms, which retail still uses for CylSphere and +sorting-sphere objects. What changes is that its **BSP arm becomes dead**: with +the §2.5 dispatch, a shape list containing a BSP shape never reaches it. + +Delete the BSP arm rather than leaving it unreachable. That arm's XML doc +(`ShadowObjectRegistry.cs:436-442`, "A BSP part contributes its ROOT BOUNDING +SPHERE placed at its real center") becomes false the moment §2.5 lands and must +go with it. Leaving a dead-but-plausible BSP arm behind is exactly how a future +producer silently re-acquires the bug. + +Objects that legitimately are spherical are **untouched**: same function, same +cap, same 3×3, byte-identical cell sets. That is proof obligation P2. + +### 2.7 Indoor half: `CellTransit.FindTransitCellsParts` + +Port of §1.7 alongside `FindTransitCellsSphere` (which stays for the sphere +route). This is the half AP-156's row already names as its open residual. + +--- + +## 3. Interaction with what landed tonight + +### 3.1 AP-156 (`b52967de`) — **this port RETIRES its open residual** + +AP-156's row states its remainder explicitly: *"Closing it means porting the +per-cell `find_transit_cells` part-array overload, which is different work from +getting the sphere set right."* That is precisely §2.1/§2.2/§2.7. **Sequential, +not competing; AP-156 is a prerequisite and stands.** + +Two register consequences, both in the same commit as the fix: + +- **AP-156's Risk column is FALSE as written and must be corrected before it is + retired.** It records the traversal residual as *"extra broadphase + candidates, never a missed one."* #334 is a missed one. The row generalised + the **indoor** direction (sphere-vs-portal-plane, over-inclusive) to the + whole residual, and the **outdoor** direction is the opposite: a fixed 3×3 + that is under-inclusive for every object wider than one cell. Correct the row + first, then retire it — a row deleted while still carrying a false risk + statement takes the finding with it. +- `BoundsCenter` stays. It still positions the sphere for the non-BSP routes + and for the `eps = 0.0002 + radius` portal pre-reject in §1.7. + +### 3.2 AP-152 (`4abd1b5e`) — **preserved and depended on, not conflicting** + +AP-152 made shape emission BSP-exclusive: a BSP-bearing Setup emits BSP shapes +and no primitive. §2.5's `hasBsp` predicate is therefore *unambiguous* — post +AP-152 a shape list is homogeneous in practice, so "has a BSP shape" and +"is a BSP object" coincide, exactly as retail's cached `HAS_PHYSICS_BSP_PS` +does. **Without AP-152 this dispatch would be ill-defined.** Nothing to narrow +or retire; add a cross-reference from AP-152's row. + +### 3.3 AP-158 / #333 — **a blocking interaction, and the one thing that can make this fix look like it did nothing** + +The broadphase reach filter discards a candidate when +`distToCurr > sphereRadius + obj.Radius + movement + 2f`, measuring from the +**part origin**. This port's whole purpose is to register objects in cells +*further from the part origin than the sphere reaches* — which is the precise +input that makes AP-158 fire. + +Bound: a player standing at the far corner of the new rectangle is up to +`~1.73·R + |BoundsCenter|` from the part origin, against a budget of +`R + r + move + 2`. For `R = 69.471` and the measured +`|BoundsCenter| = 34.977` that is ~155 m tested against ~72 m — **rejected**. + +For the specific Neftet object the fix does still work: the player positions in +the two adjacent cells sit ~50 m from the part origin against a ~72 m budget, +so those cells pass. **But the general statement is that #334's fix is +necessary and not sufficient**, and the AP-156 fix review already recorded this +exact failure mode one layer up ("the fix may produce no visible change at all, +because the geometry now lands in the right cell and is then discarded by the +filter"). Do not let it happen twice. + +**Directive:** the §7 gate must report `rejectedReach` per scenario. A gate that +shows `inCell` rise while `rejectedReach` rises with it is a **fail**, and the +remedy is #333, not a wider budget here. + +--- + +## 4. Cost — measured where possible, and honestly bounded where not + +### 4.1 What the cost is, structurally + +Cells per object changes from **≤ 9, position-dependent** to +**(⌈Xextent/24⌉+1) × (⌈Yextent/24⌉+1)**. + +The crossover is exact and favourable: **any object whose XY extent is ≤ 24 m +yields at most 2×2 = 4 cells — fewer than today's 9.** The port is *cheaper* +for every creature, prop, door and item, and more expensive only for objects +wider than one land cell. Those are landblock-baked terrain formations and +building shells. + +### 4.2 Measured worst live case + +From the committed probe log (`334-neftet-probe.log`), the only object in the +sample above 1.4 m: `gfx=0x010046D8`, `objR = 69.471`, +`|bspCentreOffset| = 34.977`. The three other BSP objects observed are +1.075 / 1.271 / 1.370 m — i.e. 1×1 rectangles, strictly cheaper than today. + +Upper bound for the outlier: the box is contained in the mesh's extent, so +extent ≤ 2R = 138.9 m → at most `floor(138.9/24)+1 = 6` cells per axis, +1 for +straddle = **7×7 = 49 cells**, versus 9 today. Caveat stated plainly: this +bound assumes the BSP root sphere bounds the whole vertex array; it bounds the +*physics polygons'* vertices, which are a subset, so a render-only vertex +outside it would exceed the bound. + +### 4.3 Where the cost lands relative to existing budgets + +- **Not on the per-frame resolve path.** Slice I1 measured 0 B/resolve for + player, remote, projectile, camera and grounded walkable-publication + profiles; the flood is registration-time, not resolve-time. The ordinary + production profile's CPU/GPU p50 of 1.869 / 1.096 ms is not exposed to it. +- **Landblock statics** (`isStatic: true`, both hosts): once per landblock + publication, already metered by the Slice E retirement/publication budgets. +- **Live remotes:** `RuntimeRemotePhysicsUpdater` re-floods per tick, gated on + >1 cm movement / rotation / cell change. Creature extents are ≪ 24 m → ≤ 4 + cells → **strictly cheaper than the current 3×3 on the hottest path in the + system.** + +### 4.4 The real cost is memory, not CPU + +`_cells` is `Dictionary>` and +`RegisterMultiPart` writes **every shape row into every flooded cell**. Rows +per object = `shapes × cells`. For a many-part baked formation at 49 cells this +is a 5.4× row multiplication over today's 9. Landblock-baked part arrays are +the population with both the largest part counts and the largest extents, so +the two multiply. + +### 4.5 What I could NOT measure, and the measurement to run first + +**I did not enumerate the installed distribution of physics-BSP GfxObj bounding +boxes.** It is not derivable from anything in the repo: the register's existing +figures (973 physics-BSP parts, 530 BSP-bearing Setups, 477 unique physics-BSP +GfxObjs, 118 above 2.5 m offset, 46 above 5 m) are all **sphere** statistics. + +**Required before any code is written** — same route the AP-156 and #333 +figures used (an out-of-repo scratch program over the installed +`client_portal.dat`), reporting over all 477 unique physics-BSP GfxObjs: + +1. histogram of `ceil(Xextent/24)+1` × `ceil(Yextent/24)+1`; +2. the count exceeding 1×1, 2×2 and 4×4; +3. the worst case, with its gfx id; +4. total `Σ shapes × cells` over one dense landblock (Arwic) before and after. + +**Gate:** if the p99 rectangle exceeds 7×7 or the dense-Arwic row total more +than doubles, stop and report rather than proceeding. That is the point at +which "the faithful port is too expensive" becomes a real finding and the +honest alternative — retail's own `CELLARRAY` growth policy, or a shared row +rather than a per-cell copy — gets designed deliberately instead of discovered +in a profile. + +--- + +## 5. Blast radius — BOTH hosts, checked not inferred + +`ShadowObjectRegistry` and `CellTransit` are in **`AcDream.Core`**, which both +hosts reference. Project graph read from the `.csproj` files: + +``` +AcDream.Headless -> AcDream.Runtime -> {Core, Core.Net, Content, Plugin.Abstractions} +AcDream.App -> {Runtime, Core, Core.Net, Content, UI.Abstractions, Plugins.Smoke} +``` + +### 5.1 Production call sites of `RegisterMultiPart` (complete) + +| site | host reach | +|---|---| +| `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:178` | App only | +| `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:959, 1044` | App only | +| `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:619, 700` | **App AND Headless** | + +### 5.2 Headless is reached — verified by call site, not by dependency inference + +`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` calls +`LandblockPhysicsContentBuilder.HydrateStaticEntities` (:386), +`HydrateProceduralScenery` (:392), `BuildDatBundle` (:403), +`PublishPreparedCells` (:435), `CacheBuildings` (:442), +`CachePreparedObjects` (:447). Lines 619 and 700 of that builder — the +`FromLandblockBspParts` BSP path and the `FromSetup` path — are exactly the +sites that register the landblock-baked formations #334 is about. + +**Headless registers the same objects through the same Core code and is +affected identically.** This is the survey C5b missed and the direction AP-152's +contract got wrong; it is settled here by reading the call sites. + +### 5.3 Consumers that must NOT change + +`_cells` shape is unchanged (same key, same row type), so every reader — +`TransitionTypes`, `PhysicsEngine`, `CollisionWorldState`, +`RuntimePhysicsState`, `ShadowPositionSynchronizer`, +`RuntimeCollisionReportingState`, `ProjectileController`, camera collision — +sees only a different *membership*, never a different *shape*. No consumer +signature changes. + +--- + +## 6. Proof obligations and test plan + +### 6.1 Proof obligations + +- **P1 — rectangle equality.** For a BSP object the registered outdoor set + equals `add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY)` exactly: + not a superset, not a subset, and *filled*. +- **P2 — non-BSP invariance.** Cylinder-only and Sphere-only owners register + byte-identical cell sets to `f0588725`. +- **P3 — map bounds.** No registered cell has a global lcoord outside + `[0, 0x7f8)`. +- **P4 — host agreement.** App and Headless produce the same cell set for the + same landblock and seed. +- **P5 — negative-safe floor.** `floor`, not truncation (see trap §8.5). + +### 6.2 Tests — each with the sabotage that must redden it + +Every fixture below is **non-degenerate on the axis under test**: in particular +**every BSP fixture has an extent that exceeds its own radius**, which is the +whole property at issue. A fixture whose box fits inside its sphere makes the +new path and the old path agree and proves nothing — that is the failure mode +this campaign has now hit ten times. + +| # | test | sabotage that MUST redden it | +|---|---|---| +| T1 | Part with 100 m × 100 m box and 1 m sphere → rectangle spans ≥ 5 cells per axis | swap the box for the sphere → collapses to 3×3 | +| T2 | Two parts forming an L → the notch cell **is** present (rectangle is filled) | compute per-part rectangles and union them → notch disappears | +| T3 | Box reaching past cellX 7 → cells carry the **neighbour landblock's** prefix | clamp the rectangle to the seed landblock | +| T4 | Rectangle at the map corner → no cell outside `[0, 0x7f8)` | drop the `0x7f8` clamp | +| T5 | Non-cubic box rotated 37° → rectangle grows vs unrotated | transform only min/max instead of all 8 corners | +| T6 | Cylinder-only owner → cell set identical to a `f0588725` golden | route every owner through the box path | +| T7 | Owner straddling an EnvCell portal whose **box** crosses the plane but whose **sphere** does not → destination cell present | keep `FindTransitCellsSphere` on the BSP route | +| T8 | Seed cell not resident → outdoor registration skipped, no throw (§1.4 `if (!cell0) return`) | drop the null check | +| T9 | Negative delta (box extends below the base cell) → cells with lower lcoord present | use `(int)(v/24f)` truncation instead of `MathF.Floor` | + +### 6.3 T10 — the installed-DAT replay of the measured evidence (strongest test) + +Assert that `gfx=0x010046D8` (entity `0xC8764000`, position +`(63.78, 248.29, 0.08)`, from the committed probe log) registers into +**`0x87640011` and `0x87640019`** — the two cells the probe measured EMPTY — +as well as `0x8764000A` and `0x87640012`, which it measured populated. + +Sabotage: revert `RegisterMultiPart` to `BuildFloodSpheres` → the two new cells +vanish. + +This asserts observed reality and re-encodes no constant under test. + +**Precondition that must be honoured, not assumed.** Whether the box actually +reaches those two cells is a **prediction, not a measurement**. §9.3 establishes +that the current 3×3 is centred on cell `0x87640013` (x=2, y=2) and therefore +structurally cannot reach cellY 0 — that half is proven. Whether +`0x010046D8`'s box extends ≥ 48 m in −Y is not. + +**Directive:** measure `0x010046D8`'s `FlatGfxObjVisualBounds` first and derive +the expected rectangle from it. If the measured box does **not** reach +`0x87640011` / `0x87640019`, **stop and report** — that would mean the +diagnosis is incomplete and a second mechanism is present. Do not weaken the +test to match; do not pin the cell list before the box is read. + +### 6.4 Not permitted + +No source-text pins. No test asserting `24f` or `0x7f8` by reading the +constant it is testing. No test whose expected cell set was produced by running +the new code. + +--- + +## 7. Gate design — positive evidence, named observables + +`ACDREAM_PROBE_REACH` (`b61f5fd4`, +`PhysicsDiagnostics.ProbeReachEnabled`) produces the before/after comparison +directly. Capture one log at `f0588725` and one at the fix commit, same route. + +**Per-scenario pass condition** — all three must hold: + +1. a `[reach-obj]` row with `gfx=0x010046D8` appears in cells where it did not + before; +2. that row's `disp` is `tested-*`, **not** `rejected-reach` (§3.3); +3. `[reach-q]`'s `inCell` rises **and** `rejectedReach` does **not** rise with + it. + +**Scenarios, named by the user's own report:** + +- **G1 — walk through on flat ground.** Approach a formation on level ground + and walk into its face. Observable: blocked, `blocked ≥ 1`. +- **G2 — the boundary between two formations.** Walk along the seam where two + formations meet — the exact geometry the report calls out. Observable: + continuously blocked across the seam; before the fix `inCell=2 exempt=2` with + no rock row, after it a rock row in every cell along the seam. +- **G3 — jump over and land inside.** Jump onto/over a formation. Observable: + lands **on** the geometry, does not fall through; a walkable contact plane is + reported at the landing tick. +- **G4 — a formation the fix should not change.** Any 1×1-extent prop nearby: + its cell set must be unchanged (visual P2 corroboration). + +**Regression gates:** Release build (after deleting all `bin`/`obj` — four +stale-DLL incidents this session, one under `-t:Rebuild`); complete solution +suite; the exact-binary lifecycle/reconnect route; the canonical nine-stop +route; the native-Linux Headless multi-session run, since §5.2 puts Headless in +scope. + +--- + +## 8. Traps + +1. **AP-158 masks the fix in far cells.** §3.3. The most likely way this lands + green and changes nothing the user can see. +2. **Cardinality change.** The sphere overload runs its whole body *per + sphere*; the parts overload computes **one** rectangle over all parts and + runs **once**. Reusing the sphere loop's per-item structure produces N + rectangles and silently breaks T2. +3. **`0x00518160` is not the extent walk.** It is a 3-instruction vtable thunk. + The extent walk is `0x00533360`. #334's issue body cites the former as "a + walk over the object's extent" (§9.2). +4. **Two functions named `calc_cross_cells_static`.** + `CPhysicsObj::` @`0x00515160` is a *caller* of `find_bbox_cell_list`; + `CPartArray::` @`0x00518160` is the thunk *below* it. They are not on the + same level and confusing them inverts the call graph. +5. **`floor`, not truncation.** Retail calls `floor` then `_ftol2`. C# + `(int)(v / 24f)` truncates toward zero and is wrong for every negative + block-local coordinate. T9. +6. **The base gid comes from the FIRST NON-NULL PART's + `adjust_to_outside`,** not from the object's position (`0x005333a2`). + `DeriveOutdoorSeed` clamps to the seed block; the rectangle must not + inherit that clamp. +7. **Global vs within-landblock indices in the same expression.** + `gid_to_lcoord` returns global coords; `baseX`/`baseY` are within-block + 0..7. The deltas bridge them. Mixing the two frames is the single most + likely arithmetic error, and BN's own output already drops the + `and eax,0xffff` that makes `baseX` within-block (§9.4). +8. **Do not touch `AddOutsideCell`.** It is already correct and already + landblock-crossing; the new path composes it. +9. **The `isStatic` prune is indoor-seeded only.** The outdoor rectangle is + deliberately unpruned. Extending the prune to it would re-create #334 in a + new form. +10. **Don't leave `BuildFloodSpheres`' BSP arm unreachable-but-plausible.** + §2.6. + +--- + +## 9. Claims found FALSE or STALE at `f0588725` + +### 9.1 "`find_bbox_cell_list` forms no bounding box at all" — MISLEADING; refuted as a characterisation + +Literally true of that one function (§1.2 — it is a worklist driver). False as a +description of the mechanism: the boxes are formed in +`CLandCell::add_all_outside_cells` (§1.4, `GetBoundingBox` + +`BBox::LocalToGlobal`) and `CEnvCell::find_transit_cells` (§1.7, +`BBox::LocalToLocal` + `Plane::intersect_box`), one and two levels below. The +name is accurate. Reading only the top frame and stopping is what produced the +claim. + +### 9.2 `docs/ISSUES.md` #334 — address chain imprecise + +The issue reads *"`find_bbox_cell_list` @0x00510fc0 → `calc_cross_cells_static` +@0x00518160, i.e. a walk over the object's extent."* The **routing is correct** +and the **conclusion is correct**, but `0x00518160` is `CPartArray::`'s vtable +thunk (§1.3), not an extent walk, and the similarly named +`CPhysicsObj::calc_cross_cells_static` @`0x00515160` is a *caller* of +`find_bbox_cell_list`, not a callee. Correct chain: +`0x00515230` → `0x00510fc0` → `0x00518160` → `[vtbl+0x7c]` → +`0x00533840` → `0x00533360` → `0x005331d0`. + +### 9.3 #334's stated cause is right but understates the mechanism — and this is what kills "widen the sphere" + +The issue attributes the loss to the sphere's radius (69.471 m) being smaller +than a landblock (192 m). The operative cap is not the radius at all. +`CellTransit.AddAllOutsideCells` computes `minRad = radius`, +`maxRad = 24 − radius`, and adds at most the **eight** neighbours of the +sphere's own cell. **For any radius ≥ 12 m both boundary tests are +unconditionally true and the result is exactly the 3×3 — a larger radius cannot +add a tenth cell.** Outdoor reach is hard-capped at ±24 m for every object in +the game. + +Consequence: widening the radius, adding a supplementary sphere at another +point (it would produce its own 3×3, not a joined region), or tuning any +constant is *mechanically* incapable of fixing this, not merely disallowed. + +**Independent confirmation against the measured data.** Global lcoords +(lbx=0x87, lby=0x64): present `(1081,801)`, `(1082,801)`; absent +`(1082,800)`, `(1083,800)`, `(1082,799)`. A 3×3 centred at `(1082,802)` — cell +`0x87640013`, x=2, y=2 — contains both present cells and excludes all three +absent ones. Every other candidate centre contradicts at least one observation. +The player's own logged positions (`currPos ≈ (52.4, 216.0)` while in +`0x87640012`) independently constrain the landblock origin to the same +solution. **The 3×3 hypothesis explains the measured evidence with zero +contradictions.** + +### 9.4 Binary Ninja artifacts in `acclient_2013_pseudo_c.txt` — four, all in the load-bearing function + +Anyone porting §1.4 from the pseudo-C alone gets these wrong: + +1. `add_all_outside_cells` pc:317343 renders `baseX` as + `((uint32_t)esi_4 - 1) >> 3`. **BN dropped the `and eax, 0xffff`** + (`0x0053343a`). Without it `baseX` includes the landblock bits and every + delta is garbage. +2. pc:317330 renders the base-gid select as `((esi_2 - esi_2) & var_58)`, + which is identically **zero**. The real code is the standard + `neg esi / sbb esi,esi / and esi,eax` conditional select + (`0x005333eb`) = `retval ? outsideCellId : 0`. +3. `add_cell_block` pc:317219 renders + `LScape::get_landcell(landscape, edx_2)` with `edx_2 = i & 7`. The real + argument is `esi`, the full computed cell id (`0x00533230 push esi`). + Same artifact in `add_all_outside_cells` (`..., added_outside)` where + `added_outside == 0`). +4. Both `x87` flag tests in the min/max accumulation appear as + `unimplemented {test ah, ...}` / bit-shuffled `FCMP_UO` expressions. The + real comparisons are plain integer `jge`/`jle` on `_ftol2` results + (`0x005335a6`, `0x005335b8`, `0x005335c8`, `0x005335d9`) — the fifth + confirmed instance this campaign of BN dropping flag semantics. + +### 9.5 AP-156's Risk column is FALSE for the outdoor half + +Recorded as *"extra broadphase candidates, never a missed one."* #334 is a +missed one. §3.1. + +### 9.6 `ShadowObjectRegistry.cs:436-442` XML doc becomes false on landing + +*"A BSP part contributes its ROOT BOUNDING SPHERE placed at its real center."* +True at `f0588725`; false the moment §2.5 lands. Delete with the BSP arm +(§2.6). + +### 9.7 Stale, not false + +`CellTransit.BuildShadowCellSet`'s XML calls itself *"the sphere-overlap portal +flood retail runs at SHADOW REGISTRATION time"* — accurate for the branch it +ports, but it is presented as **the** registration flood when it is one of two. +Narrow the wording when §2.1 lands. + +--- + +## 10. Size estimate and split call + +~600–800 production lines (two `CellTransit` ports, one value type, the +`ShadowShape` field and its resolvers, the `RegisterMultiPart` dispatch, the +`BuildFloodSpheres` BSP-arm deletion) plus ~400 test lines. + +**Split: THREE commits, sequential, one agent, no parallelism** (shared files — +`CellTransit.cs`, `ShadowShape.cs`, `ShadowObjectRegistry.cs` — are touched by +every slice). + +| slice | content | gate | +|---|---|---| +| **S0** | §4.5 measurement only. No repo change. | numbers reported; the §4.5 stop-gate evaluated | +| **S1** | `ShadowPartBox`, `ShadowShape` bounds + resolvers, package/serializer read-through. No behaviour change. | full suite green; P2 golden cell sets bit-identical | +| **S2** | `AddAllOutsideCellsFromParts` + `BuildShadowCellSetFromParts` + `FindTransitCellsParts` + the §2.5 dispatch + BSP-arm deletion | T1–T10; P1–P5; Release; both hosts; §7 connected gate | + +S0 is not optional. It is the slice that can still say "this is too expensive" +before anything is written, which is the only honest way to make that call. + +**Rollback:** each slice reverts independently; S2 alone restores `f0588725` +behaviour. + +--- + +## 11. What I could not establish + +1. **The installed distribution of physics-BSP GfxObj bounding boxes** — §4.5. + Not derivable from the repo; every existing figure is a sphere statistic. + S0 exists to close this. +2. **Whether `0x010046D8`'s box actually reaches `0x87640011` / `0x87640019`** + — §6.3. The *absence* is proven and its cause is proven; the *presence + after the fix* is a prediction until the box is read. The contract makes + reading it a precondition rather than an assumption, because a contract + asserting a mechanism that does not exist is how this campaign produced + three defects. +3. **Whether `0x010046D8` is one object or several instances sharing a gfx + id.** The log shows one entity id (`0xC8764000`) across all 1,356 rows, so + one instance is the working assumption; a second instance elsewhere in the + landblock would not change the diagnosis but would change T10's expected + set. +4. **Whether `CCellPortal::GetOtherCell` takes `do_not_load_cells` as a third + argument.** BN reads the field at `0x0052cc2a` but shows a two-argument + call. Only matters for indoor static registration (§2.7); resolve by + disassembly during S2 rather than porting BN's shape. diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs index bb7907e5..59929e9c 100644 --- a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs +++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs @@ -47,7 +47,7 @@ internal sealed record LiveEntityCollisionRegistration( /// internal sealed class LiveEntityCollisionBuilder { - private readonly Func _physicsBspBounds; + private readonly Func _physicsBspBounds; /// /// The dispatch gate, derived from so the /// two can never disagree (AP-156), and cached once so @@ -62,11 +62,13 @@ internal sealed class LiveEntityCollisionBuilder : this( id => { - FlatPhysicsBsp? flat = - physicsData.GetFlatGfxObj(id)?.PhysicsBsp; + FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id); + FlatPhysicsBsp? flat = asset?.PhysicsBsp; return flat is { RootIndex: >= 0 } - ? flat.Nodes[flat.RootIndex].BoundingSphere - : (FlatCollisionSphere?)null; + ? ShadowPartGeometry.Create( + flat.Nodes[flat.RootIndex].BoundingSphere, + asset!.VisualBounds) + : (ShadowPartGeometry?)null; }, defaultPose) { @@ -74,14 +76,15 @@ internal sealed class LiveEntityCollisionBuilder } /// The part GfxObj's physics-BSP root - /// bounding sphere, or null when it has none. ONE resolver answers both - /// questions the builder asks — "does this part dispatch as BSP?" and - /// "where and how big is its flood sphere?" — so the dispatch gate and - /// the emitted geometry cannot disagree, and the sphere's radius cannot - /// be carried while its origin is dropped. That split is what produced - /// the AP-156 mis-placed flood. + /// bounding sphere AND its authored vertex-array box, or null when it has + /// no physics BSP. ONE resolver answers every question the builder asks — + /// "does this part dispatch as BSP?", "where and how big is its flood + /// sphere?", and "what is its outdoor extent?" — so the dispatch gate and + /// the emitted geometry cannot disagree, the sphere's radius cannot be + /// carried while its origin is dropped (AP-156), and the outdoor extent + /// walk cannot be left without a box (#334). internal LiveEntityCollisionBuilder( - Func physicsBspBounds, + Func physicsBspBounds, LiveEntityDefaultPoseResolver defaultPose) { _physicsBspBounds = physicsBspBounds diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 783eaf35..09d6f4d0 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -372,6 +372,146 @@ public static class CellTransit } } + /// + /// Outdoor extent walk for a physics-BSP part array — the OTHER outdoor + /// expansion retail has, and the one #334 was missing entirely. Verbatim + /// port of CLandCell::add_all_outside_cells @0x00533360 (pc:317289) + /// plus CLandCell::add_cell_block @0x005331d0 (pc:317202), + /// disassembled from the PDB-paired 2013-09-06 binary rather than read + /// from Binary Ninja's pseudo-C, which mis-renders four separate + /// constructs inside this one function + /// (docs/research/2026-08-06-334-contract.md §9.4). + /// + /// + /// Shape, byte-verified: + /// + /// + /// The base landcell comes from the FIRST part's own + /// adjust_to_outside (0x005333a2-0x005333dd), + /// NOT from the object's position; a failed adjust selects gid 0 + /// through the neg/sbb/and conditional select at + /// 0x005333eb, whose get_landcell then returns null + /// and the walk returns (0x00533417). + /// baseX = ((gid & 0xFFFF) - 1) >> 3 + /// (0x0053343a and eax,0xffff — the mask BN drops) and + /// baseY = (gid - 1) & 7 (0x00533443): WITHIN-BLOCK + /// 0..7, bridged to the GLOBAL lcoords from + /// gid_to_lcoord (0x00533428) by the four deltas. + /// Per part: GetBoundingBox @0x0050d600 → + /// BBox::LocalToGlobal @0x005b2120 (0x00533527), then + /// floor(v / square_length) on min.x, min.y, max.x, max.y + /// (stack slots +0x48/+0x4c/+0x54/+0x58 in the entry frame — + /// the raw displacements differ only because sub esp,8 at + /// 0x00533536 brackets the middle three). Z is never read: + /// land cells are a 2-D grid. square_length is + /// 0x7c920c = 00 00 c0 41 = 24.0f, read from the + /// binary. + /// The four accumulators are seeded to ZERO + /// (0x00533390-0x0053339c), so the rectangle always + /// contains the base cell, and are combined with plain integer + /// jge/jle (0x005335a6, 0x005335b8, + /// 0x005335c8, 0x005335d9) — BN renders these as + /// unimplemented {test ah} / FCMP_UO. + /// ONE rectangle over ALL parts, filled — not outlined, not a + /// per-part union (0x00533614 + /// add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY, + /// cellarray), argument order recovered from the five pushes + /// at 0x005335f2-0x00533613). An L-shaped object + /// claims the notch; retail's coverage is deliberately + /// conservative and lets the narrow phase reject. + /// + /// + /// + /// Retail also has an Always2D() arm (0x0053346b) that falls + /// back to the part's sphere. It is unreachable here: this overload is + /// only ever handed physics-BSP parts, and a 2-D sprite part carries no + /// physics BSP. + /// + /// + /// The object's physics-BSP parts, world-placed. + /// The flood seed cell — supplies the landblock + /// base adjust_to_outside measures part 0's position against. + /// World origin of the seed cell's + /// landblock (#106 frame convention); when the + /// seed block IS the anchor. + /// False when adjust_to_outside or gid_to_lcoord + /// rejects the base position (map edge / invalid id) — retail returns + /// without adding anything. + public static bool AddAllOutsideCellsFromParts( + IReadOnlyList worldParts, + uint currentCellId, + Vector3 currentBlockOrigin, + ICollection candidates) + { + if (worldParts is null || worldParts.Count == 0) + return false; + + // 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell. + // DeriveOutdoorSeed clamps its own result to the seed block; this + // deliberately does not inherit that clamp — a part array whose first + // part sits over the neighbour block anchors there, as retail does. + Vector3 seedFramePos = worldParts[0].WorldPosition - currentBlockOrigin; + Vector3 baseFramePos = seedFramePos; + uint baseCellId = currentCellId; + if (!LandDefs.AdjustToOutside(ref baseCellId, ref baseFramePos)) + return false; // gid 0 → get_landcell null → return + if (!LandDefs.GidToLcoord(baseCellId, out int gx, out int gy)) + return false; // 0x00533432 je 0x53361c + + int baseX = (int)(((baseCellId & 0xFFFFu) - 1u) >> 3); // 0x0053343a + int baseY = (int)((baseCellId - 1u) & 7u); // 0x00533443 + + // adjust_to_outside re-based part 0's position into the ADJUSTED + // block's local frame; retail's BBox::LocalToGlobal writes every part + // box into that same frame (cell0->pos). The re-basing is a pure + // translation, so applying it to the world origin is exact. + Vector3 frameOrigin = + currentBlockOrigin - (baseFramePos - seedFramePos); + + int minDX = 0, minDY = 0, maxDX = 0, maxDY = 0; // 0x00533390 + + for (int i = 0; i < worldParts.Count; i++) + { + worldParts[i].RefitTo(frameOrigin, out Vector3 boxMin, out Vector3 boxMax); + + // floor, then _ftol2 — NOT truncation. C#'s (int)(v / 24f) + // truncates toward zero and is wrong for every negative + // block-local coordinate, which is precisely the case a part + // hanging off the block's SW corner produces. + int a = (int)MathF.Floor(boxMin.X / LandDefs.CellLength); + int b = (int)MathF.Floor(boxMin.Y / LandDefs.CellLength); + int c = (int)MathF.Floor(boxMax.X / LandDefs.CellLength); + int d = (int)MathF.Floor(boxMax.Y / LandDefs.CellLength); + + if (a - baseX < minDX) minDX = a - baseX; // 0x005335a2 + if (b - baseY < minDY) minDY = b - baseY; // 0x005335b4 + if (c - baseX > maxDX) maxDX = c - baseX; // 0x005335c2 + if (d - baseY > maxDY) maxDY = d - baseY; // 0x005335d5 + } + + AddCellBlock(gx + minDX, gy + minDY, gx + maxDX, gy + maxDY, candidates); + return true; + } + + /// + /// CLandCell::add_cell_block @0x005331d0 (pc:317202): both loops are + /// INCLUSIVE (0x0053324d / 0x00533246 jle) and the rectangle + /// is FILLED. Coordinates are GLOBAL lcoords, so the landblock prefix is + /// re-derived per cell and the rectangle crosses landblock boundaries + /// freely — that re-derivation is 's + /// , whose + /// rejection IS retail's + /// 0 <= v < 0x7f8 clamp at 0x005331f0-0x00533206. + /// + private static void AddCellBlock( + int x0, int y0, int x1, int y1, + ICollection candidates) + { + for (int x = x0; x <= x1; x++) + for (int y = y0; y <= y1; y++) + AddOutsideCell(candidates, x, y); + } + private static void AddOutsideCell(ICollection candidates, int lx, int ly) { // CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check, @@ -500,8 +640,13 @@ public static class CellTransit } /// - /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the - /// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time. + /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an + /// object with NO physics BSP — the sphere-overlap portal flood retail + /// runs at SHADOW REGISTRATION time for the cylsphere and sorting-sphere + /// branches. It is ONE of TWO registration floods: a BSP-bearing object + /// takes instead + /// (CPhysicsObj::calc_cross_cells @0x00515230 dispatches on + /// HAS_PHYSICS_BSP_PS at 0x00515285). /// Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, /// pc:308742) as invoked by CPhysicsObj::calc_cross_cells / /// calc_cross_cells_static (Ghidra 0x00515230 / 0x00515160): @@ -657,6 +802,184 @@ public static class CellTransit return candidates.OrderedIds; } + /// + /// #334 (2026-08-06). Registration-side cell-set builder for a + /// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which + /// acdream had never implemented. Port of + /// CPhysicsObj::find_bbox_cell_list @0x00510fc0 (pc:279006), the + /// branch CPhysicsObj::calc_cross_cells @0x00515230 takes at + /// 0x00515285 test dword [esi+0xa8],0x10000 / + /// 0x0051528f jne 0x515305HAS_PHYSICS_BSP_PS + /// (acclient.h:2833). ports the + /// branches BELOW that jump (cylspheres, then the sorting sphere) and + /// remains correct for them. + /// + /// + /// find_bbox_cell_list forms no bounding box itself — it is a + /// worklist. It seeds the array with the object's OWN cell + /// (0x00510fe2 CELLARRAY::add_cell) and walks it while it grows, + /// re-reading num_cells each iteration + /// (0x00510ff8 / 0x00511017 / 0x0051101d jb), + /// dispatching each array cell through + /// CPartArray::calc_cross_cells_static @0x00518160 — a forwarding + /// thunk to cell->vtable[0x7c] (0x00518176; + /// CObjCell's vftable base 0x007c8b20 + 0x7c = + /// 0x007c8b9c, holding 0x0052b080, the four-argument + /// part-array find_transit_cells). The boxes are formed one and two + /// levels down: outdoors in + /// + /// (CLandCell::find_transit_cells @0x00533840 = + /// add_all_outside_cells @0x00533360 + the CSortCell + /// @0x00534080 building bridge), indoors in + /// CEnvCell::find_transit_cells @0x0052cae0. + /// + /// + /// + /// Note the difference from 's seed: the + /// sphere overload calls add_all_outside_cells AT SEED TIME for an + /// outdoor id (CObjCell::find_cell_list 0x0052b53f); + /// find_bbox_cell_list does not — the outdoor expansion happens + /// only when the walk reaches a landcell, under the same once-per-flood + /// CELLARRAY::added_outside latch (0x0053336c). And it is + /// ONE rectangle over all parts, run ONCE, not a per-part loop. + /// + /// + /// + /// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array + /// overload — box-vs-portal-plane + /// (BBox::LocalToLocal @0x005b1e60 + Plane::intersect_box + /// @0x005aa170 at 0x0052cbf9/0x0052cc05) and + /// CCellStruct::box_intersects_cell @0x00533910 — is NOT ported + /// here. Indoor candidates keep the sphere-vs-portal traversal + /// already runs, from the same + /// per-part BSP root spheres, which is byte-for-byte the behaviour every + /// BSP object had before #334. AP-156's row already names that port as its + /// open residual; #334 is the OUTDOOR half of it. + /// + /// + /// Per-part world-placed authored boxes — the + /// outdoor extent walk's input. + /// Per-part world-placed BSP root spheres — + /// the indoor residual's and the building bridge's input. Same parts, same + /// order, from the same values. + public static IReadOnlyList BuildShadowCellSetFromParts( + PhysicsDataCache cache, + uint seedCellId, + IReadOnlyList worldParts, + IReadOnlyList worldPartSpheres, + bool isStatic) + { + var candidates = new CellArray(); + if (seedCellId == 0u || worldParts is null || worldParts.Count == 0) + return candidates.OrderedIds; + + int sphereCount = + EffectiveSphereCount(worldPartSpheres, worldPartSpheres?.Count ?? 0); + + uint seedLow = seedCellId & 0xFFFFu; + cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin); + + // SEED. 0x00510fd5-0x00510fe2 adds the object's own cell by id; + // 0x00510fed / 0x00510ff6 then skip the walk when the cell or the part + // array is null. + // + // DEVIATION (registered, AD-40): the outdoor rectangle runs at seed + // time here, not only from the walk. Retail can gate everything on + // obj->cell because a placed CPhysicsObj always has a resident + // CObjCell; acdream's CellGraph residency is transiently false during + // streaming (#168 / #169), and deferring the rectangle to the walk + // would drop a static or a live entity to a single cell for the window + // before its landblock publishes. This is the SAME residency policy + // BuildShadowCellSet already applies to its outdoor seed + // (CObjCell::find_cell_list 0x0052b53f, ahead of the arg4 walk gate), + // so the two registration floods differ only in sphere-vs-box — which + // is the whole of #334 — and the direction is over-inclusive. + bool outdoorAdded = false; // CELLARRAY::added_outside + bool seedLoaded; + if (seedLow >= 0x0100u) + { + candidates.Add(seedCellId); + seedLoaded = cache.GetCellStruct(seedCellId) is not null; + } + else + { + candidates.Add(seedCellId); + outdoorAdded = AddAllOutsideCellsFromParts( + worldParts, seedCellId, blockOrigin, candidates); + seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null; + } + + if (!seedLoaded) + return candidates.OrderedIds; + + for (int i = 0; i < candidates.Count; i++) + { + uint cellId = candidates.OrderedIds[i]; + if ((cellId & 0xFFFFu) >= 0x0100u) + { + var cell = cache.GetCellStruct(cellId); + if (cell is null) continue; // 0x00511009 null cell pointer + + if (sphereCount == 0) continue; + FindTransitCellsSphere( + cache, cell, cellId, worldPartSpheres!, sphereCount, + candidates, out bool exitStraddle); + + if (exitStraddle && !outdoorAdded) + { + outdoorAdded = AddAllOutsideCellsFromParts( + worldParts, seedCellId, blockOrigin, candidates); + } + } + else + { + if (cache.CellGraph.GetVisible(cellId) is null) + continue; + + // CLandCell::find_transit_cells @0x00533840: + // add_all_outside_cells (added_outside-guarded) then the + // CSortCell building bridge for this landcell's building. + if (!outdoorAdded) + { + outdoorAdded = AddAllOutsideCellsFromParts( + worldParts, seedCellId, blockOrigin, candidates); + } + + var building = cache.GetBuilding(cellId); + if (building is not null && sphereCount > 0) + { + CheckBuildingTransit( + cache, building, worldPartSpheres!, sphereCount, + candidates, out _); + } + } + } + + // Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY. + // The outdoor rectangle is deliberately unpruned: pruning it would + // re-create #334 in a new form. + if (isStatic && seedLow >= 0x0100u) + { + var seedCell = cache.GetCellStruct(seedCellId); + if (seedCell is not null) + { + var keep = new List(candidates.Count); + foreach (uint id in candidates.OrderedIds) + { + if (id == seedCellId || seedCell.VisibleCellIds.Contains(id)) + keep.Add(id); + } + if (keep.Count != candidates.Count) + { + candidates.Clear(); + foreach (uint id in keep) candidates.Add(id); + } + } + } + + return candidates.OrderedIds; + } + /// /// Verbatim port of CEnvCell::find_visible_child_cell /// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 9e58e7e1..af4f3e87 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -197,6 +197,10 @@ public sealed class PhysicsDataCache { _visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray); } + GfxObjVisualBounds? parsedBounds = + _visualBounds.TryGetValue(gfxObjId, out var cachedBounds) + ? cachedBounds + : null; if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing)) { @@ -217,6 +221,10 @@ public sealed class PhysicsDataCache Vertices = gfxObj.VertexArray, Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), FlatPhysicsBsp = prepared?.PhysicsBsp, + VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb + ? new FlatGfxObjVisualBounds( + pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents) + : null), }; _gfxObj[gfxObjId] = physics; @@ -285,6 +293,7 @@ public sealed class PhysicsDataCache Radius = root.Radius, }, FlatPhysicsBsp = prepared.PhysicsBsp, + VisualBounds = prepared.VisualBounds, }); } @@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics /// omit it. /// public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; } + + /// + /// Retail CGfxObj::gfx_bound_box — the AABB of this GfxObj's vertex + /// array, filled by CGfxObj::init_end @0x00534200 and returned by + /// CPhysicsPart::GetBoundingBox @0x0050d600. Cached beside + /// so the single + /// ShadowShapeBuilder.FromLandblockBspParts resolver answers both of + /// retail's cell-membership questions from one lookup (#334). + /// + public FlatGfxObjVisualBounds? VisualBounds { get; init; } } /// Cached collision shape data for a Setup (character/creature capsule). diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index d4f0bb2a..701704ea 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -430,12 +430,29 @@ public sealed class ShadowObjectRegistry /// /// /// BR-7: the cell set is ONE flood for the whole entity (retail floods - /// per OBJECT with its full sphere set, not per part). The flood spheres - /// follow CPhysicsObj::calc_cross_cells' own EXCLUSIVE priority — - /// physics-BSP parts, else CylSpheres, else the remaining shapes — see - /// for the disassembly. A BSP part - /// contributes its ROOT BOUNDING SPHERE placed at its real center - /// (), not at the part origin. + /// per OBJECT, not per part). WHICH flood is retail's own exclusive + /// dispatch on HAS_PHYSICS_BSP_PS + /// (CPhysicsObj::calc_cross_cells @0x00515230, + /// 0x00515285 test dword [esi+0xa8],0x10000 / + /// 0x0051528f jne 0x515305): + /// + /// + /// BSP-bearing → find_bbox_cell_list @0x00510fc0, ported as + /// . Each part + /// contributes its authored BOUNDING BOX + /// (/Max), and the + /// outdoor expansion is the FILLED CELL RECTANGLE that box spans — + /// crossing landblock boundaries freely. Before #334 these objects + /// were routed through the sphere flood below, whose outdoor reach + /// is a fixed 3×3 (±24 m) regardless of radius, so any formation + /// wider than one land cell simply was not registered in its outer + /// cells. + /// otherwise → + + /// , retail's + /// cylsphere and sorting-sphere branches, byte-identical to before + /// #334 for every object that legitimately is spherical. + /// + /// /// Every shape row is then written into every flooded cell, mirroring /// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow. /// @@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry : DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId); if (seed == 0u) return; - var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); - var cellSet = CellTransit.BuildShadowCellSet( - FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); + // Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP + // (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a + // physics BSP, and calc_cross_cells (0x00515285) branches on that bit. + // AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and + // "is a BSP object" coincide exactly as the cached retail flag does. + bool hasBsp = false; + for (int i = 0; i < shapes.Count; i++) + { + if (shapes[i].CollisionType == ShadowCollisionType.BSP) + { + hasBsp = true; + break; + } + } + + IReadOnlyList cellSet; + if (hasBsp) + { + var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes); + var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes); + cellSet = CellTransit.BuildShadowCellSetFromParts( + FloodCache, seed, partBoxes, partSpheres, isStatic); + } + else + { + var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); + cellSet = CellTransit.BuildShadowCellSet( + FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); + } if (cellSet.Count == 0) return; DeregisterCore(entityId, publishMutation: false); @@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry } /// - /// Retail cross-cell dispatch, CPhysicsObj::calc_cross_cells - /// @0x00515230, in retail's own priority order: + /// Flood spheres for an object with NO physics BSP — retail's cylsphere + /// and sorting-sphere branches of CPhysicsObj::calc_cross_cells + /// @0x00515230, both of which sit BELOW the HAS_PHYSICS_BSP_PS jump + /// at 0x0051528f jne 0x515305 and are unreachable from it: /// /// - /// BSP-bearing (0x00515285 test dword [esi+0xa8],0x10000 / - /// 0x0051528f jne 0x515305) → CPhysicsObj::find_bbox_cell_list - /// @0x00510fc0. The cylsphere and sorting-sphere branches are BOTH below - /// that jump and unreachable from it. find_bbox_cell_list adds the - /// object's own cell and then walks the PART ARRAY through - /// CPartArray::calc_cross_cells_static @0x00518160's - /// [vtbl+0x7c] dispatch, whose EnvCell body - /// (CEnvCell::find_transit_cells @0x0052cae0) tests each part's - /// CGfxObj::physics_sphere — the BSP root bounding sphere, center - /// transformed through the part's own Position — against the cell's - /// portal planes. acdream floods from those same per-part spheres - /// ( + ) - /// rather than walking portal planes per part; the sphere set is exact, - /// the traversal is the sphere-vs-portal one (AP-156). - /// else cylspheres (0x00515298 GetNumCylsphere non-zero) → + /// cylspheres (0x00515298 GetNumCylsphere non-zero) → /// CObjCell::find_cell_list @0x0052b9f0 over the cylsphere array; /// each contributes one sphere at its world BASE point with the cylinder /// radius, capped at 10. @@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry /// /// /// - /// The BSP-first rule is redundant for every shape list acdream produces - /// today — dispatches at - /// emission (AP-152) and both landblock-static publishers emit - /// homogeneous lists — exactly as - /// Transition.BspOnlyDispatch is redundant at the query site. It is - /// kept because retail genuinely dispatches here, and because a producer - /// that handed this method a mixed list would otherwise flood a - /// BSP-bearing object from its primitive and silently place it in the - /// wrong shadow cells (the #98 / #168 symptom class). + /// #334: there is no BSP arm here any more, and there must not be one. + /// The BSP branch is a structurally different algorithm over BOXES + /// (), and + /// routes to it before this method is + /// reached. The arm this method used to carry — "a BSP part contributes + /// its ROOT BOUNDING SPHERE placed at its real center" — described + /// retail's INDOOR portal reject, not its outdoor expansion, and using it + /// for both is what capped every BSP object's outdoor reach at a 3×3 + /// neighbourhood. A BSP shape reaching this method would be a dispatch + /// bug; it is skipped rather than flooded from, so it cannot silently + /// produce the wrong cells (the #98 / #168 symptom class). /// /// private static List BuildFloodSpheres( @@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry const int RetailSphereCap = 10; var spheres = new List(); - bool anyBsp = false; bool anyCyl = false; foreach (var s in shapes) { - if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true; - else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true; + if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true; } - // Retail's branch, chosen once: BSP-bbox, else cylspheres, else the - // sorting sphere (which acdream approximates with the remaining - // shapes' bounding spheres — AP-157). - ShadowCollisionType? only = - anyBsp ? ShadowCollisionType.BSP - : anyCyl ? ShadowCollisionType.Cylinder - : null; + // Retail's branch, chosen once: cylspheres, else the sorting sphere + // (which acdream approximates with the Sphere shapes — AP-157). + ShadowCollisionType only = + anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere; // The 10-sphere clamp belongs to the CYLSPHERE branch alone. // CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at @@ -667,41 +695,29 @@ public sealed class ShadowObjectRegistry // fixed static-buffer capacity (the destination array at // 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy. // - // BSP branch: NO CAP, and this is a retail port. find_bbox_cell_list - // @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 -> - // CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded - // only by num_parts. Clamping it dropped parts 11..N out of the flood - // entirely: 7 installed Setups carry more than 10 physics-BSP parts - // (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair - // runs, fences, rock clusters — routinely do. - // - // only == null (the sorting-sphere branch): int.MaxValue is NOT a - // retail port and the addresses above do not justify it. Retail's - // overload @0x0052b990 pushes a literal 1 (0x0052b9d6 push 1) and - // floods from ONE authored CSetup::sorting_sphere. acdream floods from - // every Sphere shape instead — a different DAT field with a different - // cardinality, which is AP-157, filed and open. Capping at 1 HERE would - // not move toward retail: it would take Spheres[0], which is not the - // sorting sphere. int.MaxValue keeps the substitution in its safe - // (over-inclusive) direction until AP-157 ports the real field. Inert - // over installed data — max 5 Spheres on any Setup (0x020016F7). + // Sorting-sphere branch: int.MaxValue is NOT a retail port and the + // addresses above do not justify it. Retail's overload @0x0052b990 + // pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored + // CSetup::sorting_sphere. acdream floods from every Sphere shape + // instead — a different DAT field with a different cardinality, which + // is AP-157, filed and open. Capping at 1 HERE would not move toward + // retail: it would take Spheres[0], which is not the sorting sphere. + // int.MaxValue keeps the substitution in its safe (over-inclusive) + // direction until AP-157 ports the real field. Inert over installed + // data — max 5 Spheres on any Setup (0x020016F7). int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue; foreach (var s in shapes) { - if (only is { } required && s.CollisionType != required) + if (s.CollisionType != only) continue; if (spheres.Count >= cap) break; - // Place the sphere where the GEOMETRY is, not where the part - // origin is. Composed exactly as the ShadowEntry rows below are - // (partWorldPos / partWorldRot), then offset by the shape's own - // BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0 - // transforms CGfxObj::physics_sphere's center through the part's - // Position at [part+0x30] before reading its radius at - // 0x0052cb65. Primitives carry BoundsCenter == Zero because their - // LocalPosition already is their center. + // A primitive's LocalPosition already IS its centre, so + // BoundsCenter is Zero; the composition is kept identical to the + // emitted ShadowEntry rows so the flood and the geometry can never + // disagree about where the shape is. var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot); var partWorldRot = entityWorldRot * s.LocalRotation; var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot); @@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry return spheres; } + /// + /// #334: the per-part world-placed authored boxes retail's + /// CLandCell::add_all_outside_cells @0x00533360 divides by + /// square_length. Composed exactly as the emitted + /// rows are, so the flood rectangle and the + /// collision geometry describe the same placement. + /// + private static List BuildFloodPartBoxes( + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList shapes) + { + var boxes = new List(shapes.Count); + foreach (var s in shapes) + { + if (s.CollisionType != ShadowCollisionType.BSP) + continue; + boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot)); + } + return boxes; + } + + /// + /// The per-part BSP ROOT bounding spheres retail's part-array + /// CEnvCell::find_transit_cells @0x0052cae0 loads at + /// 0x0052cb36 mov esi,[ecx+0x74], transforms through the part's own + /// Position (0x0052cb4c / Position::localtolocal) and reads + /// the radius from at 0x0052cb65 fadd [esi+0xc]. + /// + /// + /// These drive ONLY the indoor half of the BSP flood and the outdoor + /// building bridge (CEnvCell::check_building_transit @0x0052c5d0), + /// which still use the sphere traversal — the AP-159 residual. The + /// outdoor expansion uses and never + /// these. No cap: find_bbox_cell_list walks every part, bounded + /// only by num_parts (7 installed Setups carry more than 10 + /// physics-BSP parts, max 49 on Setup 0x02001A91). + /// + /// + private static List BuildBspPartSpheres( + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList shapes) + { + var spheres = new List(shapes.Count); + foreach (var s in shapes) + { + if (s.CollisionType != ShadowCollisionType.BSP) + continue; + var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot); + var partWorldRot = entityWorldRot * s.LocalRotation; + spheres.Add(new DatReaderWriter.Types.Sphere + { + Origin = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot), + Radius = s.Radius, + }); + } + return spheres; + } + /// /// Derive the outdoor landcell id under a world position — the implicit /// seed for landblock-baked statics registered without a cell id diff --git a/src/AcDream.Core/Physics/ShadowPartBox.cs b/src/AcDream.Core/Physics/ShadowPartBox.cs new file mode 100644 index 00000000..d3de6972 --- /dev/null +++ b/src/AcDream.Core/Physics/ShadowPartBox.cs @@ -0,0 +1,171 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// One physics-BSP part's flood geometry, resolved as ONE value: the part +/// GfxObj's physics-BSP root bounding sphere AND the axis-aligned box of its +/// vertex array, both in the GfxObj's own unscaled frame. +/// +/// +/// The pairing is the AP-156 invariant applied a second time. Retail's cell +/// membership reads BOTH — CEnvCell::find_transit_cells @0x0052cae0 +/// takes CGfxObj::physics_sphere ([gfxobj+0x74]) for its cheap +/// portal-plane reject, and CLandCell::add_all_outside_cells @0x00533360 +/// takes CPhysicsPart::GetBoundingBox @0x0050d600 +/// (&gfxobj->gfx_bound_box) for the outdoor extent walk. A resolver +/// that answered only one of the two would leave the other call site to +/// synthesize a substitute, which is exactly how the sphere came to be placed +/// at the part origin (AP-156) and how #334's outdoor rectangle came to be a +/// fixed 3×3. +/// +/// +/// +/// / come from +/// FlatGfxObjVisualBounds, which +/// FlatCollisionAssetBuilder.FlattenGfxObj computes with +/// PhysicsDataCache.ComputeVisualBounds(source.VertexArray) — the exact +/// CGfxObj::init_end @0x00534200 computation (seed min=max=vertices[0], +/// then BBox::AdjustBBox over every vertex of the render vertex array, +/// which is also the array the physics polygons index into). +/// +/// +public readonly record struct ShadowPartGeometry +{ + private ShadowPartGeometry( + FlatCollisionSphere sphere, + Vector3 boxMin, + Vector3 boxMax) + { + Sphere = sphere; + BoxMin = boxMin; + BoxMax = boxMax; + } + + /// Retail CGfxObj::physics_sphere — the physics BSP root + /// bounding sphere, origin included, unscaled. + public FlatCollisionSphere Sphere { get; } + + /// Retail CGfxObj::gfx_bound_box.m_vMin, unscaled. + public Vector3 BoxMin { get; } + + /// Retail CGfxObj::gfx_bound_box.m_vMax, unscaled. + public Vector3 BoxMax { get; } + + /// + /// Pairs the two. is the prepared + /// package's FlatGfxObjVisualBounds; when it is absent (graph-only + /// fixtures, and prepared assets baked without the field) the box falls + /// back to the sphere's own axis-aligned bound, which contains every + /// physics polygon vertex the sphere contains and keeps the substitution + /// in the over-inclusive direction retail itself uses (§1.8 of + /// docs/research/2026-08-06-334-contract.md). The fallback lives + /// HERE so no call site can observe a half-populated value. + /// + public static ShadowPartGeometry Create( + FlatCollisionSphere sphere, + FlatGfxObjVisualBounds? visualBounds) + { + if (visualBounds is { } bounds) + return new ShadowPartGeometry(sphere, bounds.Min, bounds.Max); + + var extent = new Vector3(sphere.Radius); + return new ShadowPartGeometry( + sphere, + sphere.Origin - extent, + sphere.Origin + extent); + } +} + +/// +/// One physics-BSP part's world-placed bounding box — the per-part input to +/// retail's outdoor extent walk. +/// +/// +/// Retail's CLandCell::add_all_outside_cells @0x00533360 calls +/// BBox::LocalToGlobal(part->gfxobj->gfx_bound_box, part->pos, +/// cell0->pos) (@0x00533527) per part, so the box it divides by +/// square_length is the part's authored box re-fit through the part's +/// own placement. / are that +/// authored box (entity-scaled); / +/// are the part placement +/// composes for the +/// ShadowEntry rows, so the flood and the geometry can never disagree +/// about where the part is. +/// +/// +/// +/// CONSTRUCTION IS BY FACTORY ONLY: min and max arrive together, from one +/// , so no future producer can carry one and drop the +/// other. +/// +/// +public readonly record struct ShadowPartBox +{ + private ShadowPartBox( + Vector3 localMin, + Vector3 localMax, + Vector3 worldPosition, + Quaternion worldRotation) + { + LocalMin = localMin; + LocalMax = localMax; + WorldPosition = worldPosition; + WorldRotation = worldRotation; + } + + /// Authored box minimum in the part's own frame, entity-scaled. + public Vector3 LocalMin { get; } + + /// Authored box maximum in the part's own frame, entity-scaled. + public Vector3 LocalMax { get; } + + /// The part's world placement — retail CPhysicsPart::pos. + public Vector3 WorldPosition { get; } + + /// The part's world orientation — retail CPhysicsPart::pos. + public Quaternion WorldRotation { get; } + + /// + /// Composes the part's world placement exactly as + /// composes the + /// emitted ShadowEntry's. + /// + public static ShadowPartBox FromShape( + in ShadowShape shape, + Vector3 entityWorldPosition, + Quaternion entityWorldRotation) + => new( + shape.LocalBoundsMin, + shape.LocalBoundsMax, + entityWorldPosition + + Vector3.Transform(shape.LocalPosition, entityWorldRotation), + entityWorldRotation * shape.LocalRotation); + + /// + /// Retail BBox::LocalToGlobal @0x005b2120 — a proper EIGHT-CORNER + /// re-fit, not a min/max transform: transform min, seed both + /// corners from it, transform the other seven and AdjustBBox each. + /// A rotated box therefore GROWS, conservatively, which is the direction + /// retail deliberately errs in. + /// + /// World origin of the destination frame — + /// retail's cell0->pos, i.e. the landblock the extent walk + /// anchors on. + public void RefitTo(Vector3 frameOrigin, out Vector3 min, out Vector3 max) + { + Vector3 offset = WorldPosition - frameOrigin; + min = new Vector3(float.MaxValue); + max = new Vector3(float.MinValue); + for (int corner = 0; corner < 8; corner++) + { + var local = new Vector3( + (corner & 1) == 0 ? LocalMin.X : LocalMax.X, + (corner & 2) == 0 ? LocalMin.Y : LocalMax.Y, + (corner & 4) == 0 ? LocalMin.Z : LocalMax.Z); + Vector3 world = Vector3.Transform(local, WorldRotation) + offset; + min = Vector3.Min(min, world); + max = Vector3.Max(max, world); + } + } +} diff --git a/src/AcDream.Core/Physics/ShadowShape.cs b/src/AcDream.Core/Physics/ShadowShape.cs index b559d88c..77685ca1 100644 --- a/src/AcDream.Core/Physics/ShadowShape.cs +++ b/src/AcDream.Core/Physics/ShadowShape.cs @@ -20,12 +20,13 @@ namespace AcDream.Core.Physics; /// CONSTRUCTION IS BY FACTORY ONLY (, , /// ) and the constructor is private. That is the AP-156 /// invariant expressed at the type rather than only at the producer: a BSP -/// shape's radius and its bounding-sphere CENTRE arrive as one -/// value and are scaled together inside -/// , so no call site — present or future — can take the -/// radius while dropping the origin. That split is exactly what produced -/// AP-156, and with the old public 7-argument constructor a new BSP producer -/// could have reintroduced it silently and green. +/// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX +/// arrive as one value and are scaled together +/// inside , so no call site — present or future — can take one +/// while dropping another. Those splits are exactly what produced AP-156 (the +/// centre dropped) and #334 (the box never resolved at all); with a public +/// positional constructor a new BSP producer could reintroduce either silently +/// and green. /// /// public readonly record struct ShadowShape @@ -38,7 +39,9 @@ public readonly record struct ShadowShape ShadowCollisionType collisionType, float radius, float cylHeight, - Vector3 boundsCenter) + Vector3 boundsCenter, + Vector3 localBoundsMin, + Vector3 localBoundsMax) { GfxObjId = gfxObjId; LocalPosition = localPosition; @@ -48,6 +51,8 @@ public readonly record struct ShadowShape Radius = radius; CylHeight = cylHeight; BoundsCenter = boundsCenter; + LocalBoundsMin = localBoundsMin; + LocalBoundsMax = localBoundsMax; } /// Source GfxObj id, for the BSP walk and for diagnostics. @@ -107,27 +112,51 @@ public readonly record struct ShadowShape public Vector3 BoundsCenter { get; } /// - /// One physics-BSP part. is the part - /// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame, - /// unscaled — retail's CGfxObj::physics_sphere. Radius and centre - /// are scaled together here, which is the whole point of taking them as - /// one value. + /// The shape's AXIS-ALIGNED BOX in the same local frame as + /// , already entity-scaled. For a BSP shape this + /// is retail's CGfxObj::gfx_bound_box — the AABB of the GfxObj's + /// vertex array, which CPhysicsPart::GetBoundingBox @0x0050d600 + /// returns and which CLandCell::add_all_outside_cells @0x00533360 + /// divides by square_length to build the outdoor cell rectangle + /// (#334). Primitive shapes carry their own radius/height box; they never + /// reach that path, because CPhysicsObj::calc_cross_cells + /// @0x00515230 routes only HAS_PHYSICS_BSP_PS objects to + /// find_bbox_cell_list. + /// + public Vector3 LocalBoundsMin { get; } + + /// + public Vector3 LocalBoundsMax { get; } + + /// + /// One physics-BSP part. carries the part + /// GfxObj's physics-BSP ROOT bounding sphere AND its vertex-array box in + /// the GfxObj's OWN frame, unscaled — retail's + /// CGfxObj::physics_sphere and CGfxObj::gfx_bound_box. + /// Sphere radius, sphere centre, and both box corners are scaled together + /// here, which is the whole point of taking them as one value: retail + /// reads the sphere for the indoor portal reject and the box for the + /// outdoor extent walk, and a producer that supplied one without the + /// other would silently force a substitute at the other call site + /// (AP-156, then #334). /// public static ShadowShape Bsp( uint gfxObjId, Vector3 localPosition, Quaternion localRotation, float scale, - FlatCollisionSphere localBounds) + ShadowPartGeometry localGeometry) => new( gfxObjId, localPosition, localRotation, scale, ShadowCollisionType.BSP, - localBounds.Radius * scale, + localGeometry.Sphere.Radius * scale, 0f, - localBounds.Origin * scale); + localGeometry.Sphere.Origin * scale, + localGeometry.BoxMin * scale, + localGeometry.BoxMax * scale); /// /// One Setup CylSphere. already IS the @@ -148,7 +177,9 @@ public readonly record struct ShadowShape ShadowCollisionType.Cylinder, radius, cylHeight, - Vector3.Zero); + Vector3.Zero, + new Vector3(-radius, -radius, 0f), + new Vector3(radius, radius, cylHeight)); /// /// One Setup Sphere. already IS the @@ -168,5 +199,7 @@ public readonly record struct ShadowShape ShadowCollisionType.Sphere, radius, 0f, - Vector3.Zero); + Vector3.Zero, + new Vector3(-radius), + new Vector3(radius)); } diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index f121e481..35b532fd 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -94,21 +94,25 @@ public static class ShadowShapeBuilder /// index and pose, but reads PhysicsBSP from the installed replacement. /// Null or short lists fall back to the Setup identity. /// The part GfxObj's physics-BSP ROOT - /// bounding sphere — retail's CGfxObj::physics_sphere, which is - /// literally BSPTREE::GetSphere(physics_bsp) @0x005397e0. Supplies - /// BOTH the emitted and its - /// , from one call, so the sphere's - /// size can never be carried while its position is dropped. Null (or a - /// null result) falls back to the loose-but-safe 2 m placeholder at the - /// part origin — a fixture-only configuration; production always supplies - /// it (LiveEntityCollisionBuilder). + /// bounding sphere AND its authored vertex-array box — retail's + /// CGfxObj::physics_sphere (BSPTREE::GetSphere(physics_bsp) + /// @0x005397e0) and CGfxObj::gfx_bound_box + /// (CPhysicsPart::GetBoundingBox @0x0050d600), as ONE + /// . Supplies the emitted + /// , + /// and /Max from one call, + /// so no part of the flood geometry can be carried while another is + /// dropped (AP-156, then #334). Null (or a null result) falls back to the + /// loose-but-safe 2 m placeholder at the part origin — a fixture-only + /// configuration; production always supplies it + /// (LiveEntityCollisionBuilder). public static IReadOnlyList FromSetup( Setup setup, float entScale, Func hasPhysicsBsp, IReadOnlyList? partPoseOverride = null, IReadOnlyList? effectivePartGfxObjIds = null, - Func? physicsBspBounds = null) + Func? physicsBspBounds = null) { if (setup is null) throw new ArgumentNullException(nameof(setup)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); @@ -210,17 +214,27 @@ public static class ShadowShapeBuilder // supplies both so one cannot be taken without the other. // Absent bounds keep the loose-but-safe 2 m placeholder, centred // on the part origin because nothing better is known. - // ShadowShape.Bsp scales radius and centre together. - FlatCollisionSphere bounds = + // ShadowShape.Bsp scales radius, centre and box together. + // + // #334: the SAME resolver also supplies the authored vertex-array + // box. Retail's outdoor cell membership + // (CLandCell::add_all_outside_cells @0x00533360, reached from + // find_bbox_cell_list @0x00510fc0) divides that box — never the + // sphere — by square_length to build its cell rectangle, so a + // resolver that answered only the sphere would leave that walk + // with nothing to walk. + ShadowPartGeometry geometry = physicsBspBounds?.Invoke(gfxId) - ?? new FlatCollisionSphere(Vector3.Zero, 2f); + ?? ShadowPartGeometry.Create( + new FlatCollisionSphere(Vector3.Zero, 2f), + null); result.Add(ShadowShape.Bsp( gfxObjId: gfxId, localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, localRotation: partFrame.Orientation, scale: entScale, - localBounds: bounds)); + localGeometry: geometry)); } return result; @@ -308,12 +322,21 @@ public static class ShadowShapeBuilder phys.BoundingSphere?.Origin ?? Vector3.Zero, phys.BoundingSphere?.Radius ?? 1f); + // #334: the same cached record carries the authored vertex-array + // box (CGfxObj::gfx_bound_box), which retail's outdoor extent walk + // — CLandCell::add_all_outside_cells @0x00533360 — divides by + // square_length. Landblock-baked part arrays are exactly the + // population whose extent exceeds one 24 m land cell, so the + // sphere alone cannot describe their membership. + ShadowPartGeometry geometry = + ShadowPartGeometry.Create(localBounds, phys.VisualBounds); + shapes.Add(ShadowShape.Bsp( gfxObjId: meshRef.GfxObjId, localPosition: pPos, localRotation: pRot, scale: partScale, - localBounds: localBounds)); + localGeometry: geometry)); } return shapes; diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs index 3f9ddd80..320034d5 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -412,8 +412,10 @@ public sealed class LiveEntityCollisionBuilderTests /// radius). A fixture pinned at Vector3.Zero cannot observe the /// centre at all — which is how AP-156's discarded origin stayed green. /// - private static FlatCollisionSphere? Bsp(float radius, float centerZ = 1.25f) - => new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius); + private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f) + => ShadowPartGeometry.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius), + null); private static LiveEntityDefaultPoseResolver PoseResolver() => new( _ => null, diff --git a/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs b/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs index b2f23b66..40136a30 100644 --- a/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs +++ b/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs @@ -143,8 +143,10 @@ public sealed class PvpBitfieldSurvivesAppearanceRebuildTests setup.Parts.Add(0x0100AB01u); var builder = new LiveEntityCollisionBuilder( id => id == 0x0100AB01u - ? new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f) - : null, + ? ShadowPartGeometry.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f), + null) + : (ShadowPartGeometry?)null, new LiveEntityDefaultPoseResolver( _ => null, new NullAnimationLoader(), diff --git a/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs b/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs index 460c8ae7..7343f4d7 100644 --- a/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs +++ b/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs @@ -339,7 +339,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests setup, EntScale, id => Bounds(id) is not null, - physicsBspBounds: Bounds); + physicsBspBounds: id => Bounds(id) is { } sphere + ? ShadowPartGeometry.Create(sphere, null) + : (ShadowPartGeometry?)null); // ShadowObjectRegistry.BuildFloodSpheres' composition, at an // entity placed at the world origin with identity rotation. diff --git a/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs b/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs new file mode 100644 index 00000000..a430c835 --- /dev/null +++ b/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Content.Tests; + +/// +/// #334, replayed against the installed DATs from the user's own live +/// evidence (334-neftet-probe.log, 8,401 lines, 2026-08-06). +/// +/// +/// Standing inside the Neftet rock formation the broadphase reported +/// inCell=2 exempt=2 reached=0 — the formation was not in the player's +/// cell at all. The one object that DID block, GfxObj 0x010046D8 +/// (root bounding sphere radius 69.471 m, bounds centre 34.977 m off the part +/// origin), was measured PRESENT in cells 0x8764000A and +/// 0x87640012 and ABSENT from 0x87640011, 0x87640019 and +/// 0x87630018. Those five observations have exactly one explanation: +/// a 3×3 land-cell neighbourhood centred on 0x87640013 — the cell +/// under the object's own position — which is what +/// CellTransit.AddAllOutsideCells produces for ANY sphere, because its +/// minRad = radius / maxRad = 24 - radius boundary tests are +/// unconditionally true above 12 m and it only ever adds the eight +/// neighbours. +/// +/// +/// +/// This test asserts the observed reality rather than a constant: the two +/// cells the probe measured EMPTY must be occupied, and the two it measured +/// POPULATED must stay occupied. It fails at f0588725 (the sphere route +/// cannot reach cellY 0 from a centre at cellY 2) and passes with the box +/// route. The expected rectangle was derived from the object's own +/// CGfxObj::gfx_bound_box read out of client_portal.dat, not +/// from running the code under test: the box is 96 m × 96 m about a part +/// origin at block-local (63.78, 56.29), i.e. cell (2,2), so it spans cell +/// columns 0..4 on both axes. +/// +/// +public sealed class Issue334NeftetFormationCellMembershipTests +{ + private const uint NeftetLandblock = 0x87640000u; + private const uint NeftetLandblockInfo = 0x8764FFFEu; + private const uint FormationGfxObj = 0x010046D8u; + + // The four cells named in the probe log, by their measured disposition. + private const uint MeasuredPresentA = 0x8764000Au; // lcoord (1081, 801) + private const uint MeasuredPresentB = 0x87640012u; // lcoord (1082, 801) + private const uint MeasuredAbsentA = 0x87640011u; // lcoord (1082, 800) + private const uint MeasuredAbsentB = 0x87640019u; // lcoord (1083, 800) + + [Fact] + public void NeftetFormation_RegistersInTheCellsTheProbeMeasuredEmpty() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + Assert.True( + dats.Cell.TryGet(NeftetLandblockInfo, out LandBlockInfo? info) + && info is not null, + "Neftet landblock info 0x8764FFFE is absent from the installed cell dat."); + + // The probe's entity 0xC8764000 is stab index 0 of this landblock + // (LandblockStaticEntityIdAllocator's 0xCXXYYIII packing). + Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj); + + Assert.True( + dats.Portal.TryGet(FormationGfxObj, out GfxObj? gfx) && gfx is not null, + "GfxObj 0x010046D8 is absent from the installed portal dat."); + + // Control: the fixture must be non-degenerate on the axis under test. + // A box that fits inside its own bounding sphere makes the box route + // and the sphere route agree, and proves nothing. + var cache = new PhysicsDataCache(); + cache.CacheGfxObj(FormationGfxObj, gfx!); + GfxObjPhysics? phys = cache.GetGfxObj(FormationGfxObj); + Assert.NotNull(phys); + Assert.NotNull(phys!.VisualBounds); + FlatGfxObjVisualBounds box = phys.VisualBounds!.Value; + float extentX = box.Max.X - box.Min.X; + float extentY = box.Max.Y - box.Min.Y; + float rootRadius = phys.BoundingSphere!.Radius; + Assert.True( + extentX > rootRadius && extentY > rootRadius, + $"Fixture is degenerate: extent ({extentX:F2}, {extentY:F2}) does not " + + $"exceed the root sphere radius {rootRadius:F3}."); + Assert.True( + extentX > 48f && extentY > 48f, + $"Fixture cannot reach two cells away: extent ({extentX:F2}, {extentY:F2})."); + + IReadOnlyList shapes = + ShadowShapeBuilder.FromLandblockBspParts( + new[] { new MeshRef(FormationGfxObj, Matrix4x4.Identity) }, + isBuildingShell: false, + cache.GetGfxObj); + ShadowShape only = Assert.Single(shapes); + Assert.Equal(ShadowCollisionType.BSP, only.CollisionType); + + var registry = new ShadowObjectRegistry { DataCache = cache }; + const uint ownerId = 0xC8764000u; + registry.RegisterMultiPart( + ownerId, + formation.Frame.Origin, + formation.Frame.Orientation, + shapes, + 0u, + EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: NeftetLandblock, + seedCellId: 0u, + isStatic: true); + + var held = new List(); + for (uint index = 1u; index <= 64u; index++) + { + uint cellId = NeftetLandblock | index; + if (registry.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId)) + held.Add(cellId); + } + + // The measured-populated pair must stay populated. + Assert.Contains(MeasuredPresentA, held); + Assert.Contains(MeasuredPresentB, held); + + // The measured-EMPTY pair is the #334 fact. + Assert.Contains(MeasuredAbsentA, held); + Assert.Contains(MeasuredAbsentB, held); + + // And the rectangle is the 5×5 the 96 m box spans about cell (2,2), + // clipped to this landblock's own 8×8 grid (the two columns below 0 + // land in the neighbour blocks 0x8763 / 0x8664 and are counted there). + Assert.Equal(25, held.Count); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs index f9d6ea7f..875afd23 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -926,7 +926,9 @@ public class DoorBugTrajectoryReplayTests s.LocalPosition, s.LocalRotation, s.Scale, - new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale))); + ShadowPartGeometry.Create( + new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale), + null))); } else { @@ -1113,7 +1115,7 @@ public class DoorBugTrajectoryReplayTests localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1f, - localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius)); + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, BspRadius), null)); var cylShape = ShadowShape.Cylinder( gfxObjId: 0u, diff --git a/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs b/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs new file mode 100644 index 00000000..5b28d686 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #334: a physics-BSP object's outdoor cell membership is the FILLED +/// RECTANGLE of land cells its authored bounding box spans, not a fixed 3×3 +/// neighbourhood. +/// +/// +/// Retail chain, disassembled from the PDB-paired 2013-09-06 binary: +/// CPhysicsObj::calc_cross_cells @0x00515230 +/// (0x00515285 test dword [esi+0xa8],0x10000) → +/// find_bbox_cell_list @0x00510fc0 → +/// CPartArray::calc_cross_cells_static @0x00518160 → +/// [vtbl+0x7c]CLandCell::find_transit_cells @0x00533840 → +/// add_all_outside_cells @0x00533360 → add_cell_block +/// @0x005331d0. +/// +/// +/// +/// EVERY fixture here has an XY extent that EXCEEDS its own bounding-sphere +/// radius. That is the axis under test: a box that fits inside its sphere +/// makes the new path and the old 3×3 agree, and proves nothing. The sphere +/// radius is deliberately kept at 1 m so no assertion below can be satisfied +/// by the sphere route — retail's outdoor sphere reach is hard-capped at ±1 +/// cell for ANY radius (check_add_cell_boundary compares against +/// radius and 24 - radius, both unconditionally true above +/// 12 m, and only ever adds the eight neighbours). +/// +/// +public sealed class Issue334BspBoxCellMembershipTests +{ + // Landblock (0xA9, 0xB4). Global lcoord origin = (0xA9*8, 0xB4*8). + private const uint LbId = 0xA9B40000u; + private const int GxBase = 0xA9 * 8; // 1352 + private const int GyBase = 0xB4 * 8; // 1440 + + /// Full outdoor cell id from a GLOBAL lcoord, hand-derived from + /// retail's add_cell_block packing at 0x0053320a-0x0053322e: + /// (((x>>3)<<8) | (y>>3)) << 16 | ((x&7)*8 + (y&7) + 1). + /// Written out here rather than calling LandDefs so the expectation does + /// not re-encode the code under test. + private static uint Cell(int gx, int gy) + => (uint)(((((gx >> 3) << 8) | (gy >> 3)) << 16) | ((gx & 7) * 8 + (gy & 7) + 1)); + + private static ShadowShape BspPart( + Vector3 boxMin, + Vector3 boxMax, + float sphereRadius = 1f, + Vector3 sphereCentre = default, + Vector3 localPosition = default, + Quaternion localRotation = default) + => ShadowShape.Bsp( + gfxObjId: 0x010046D8u, + localPosition: localPosition, + localRotation: localRotation == default ? Quaternion.Identity : localRotation, + scale: 1f, + localGeometry: ShadowPartGeometry.Create( + new FlatCollisionSphere(sphereCentre, sphereRadius), + new FlatGfxObjVisualBounds( + boxMin, + boxMax, + (boxMin + boxMax) * 0.5f, + ((boxMax - boxMin) * 0.5f).Length(), + (boxMax - boxMin) * 0.5f))); + + /// The sphere-only configuration the port replaced: box collapses + /// to the sphere's own AABB. Used as the in-test control that the fixture + /// is non-degenerate. + private static ShadowShape SphereOnlyPart( + float sphereRadius, + Vector3 sphereCentre = default, + Vector3 localPosition = default) + => ShadowShape.Bsp( + gfxObjId: 0x010046D8u, + localPosition: localPosition, + localRotation: Quaternion.Identity, + scale: 1f, + localGeometry: ShadowPartGeometry.Create( + new FlatCollisionSphere(sphereCentre, sphereRadius), + null)); + + private static List Rectangle( + Vector3 entityWorldPos, + uint seedCellId, + params ShadowShape[] shapes) + { + var boxes = shapes + .Select(s => ShadowPartBox.FromShape(s, entityWorldPos, Quaternion.Identity)) + .ToList(); + var candidates = new CellArray(); + CellTransit.AddAllOutsideCellsFromParts( + boxes, seedCellId, Vector3.Zero, candidates); + return candidates.OrderedIds.ToList(); + } + + // ── T1 ──────────────────────────────────────────────────────────────── + /// + /// A 100 m × 100 m box on a 1 m sphere spans five land cells per axis. + /// Sabotage: drop the box and flood from the sphere + /// () → one cell. The 5-per-axis span is + /// unreachable from ANY sphere, of any radius, through the 3×3 path. + /// + [Fact] + public void T1_HundredMetreBox_SpansFiveCellsPerAxis() + { + // Entity centred on cell (1,1): world (36, 36). Box ±50 m → world + // -14..86 per axis → floor(-14/24) = -1 .. floor(86/24) = 3, i.e. + // block-local cell columns -1..3, five per axis. + var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f)); + List cells = Rectangle(new Vector3(36f, 36f, 0f), LbId | 10u, shape); + + var expected = new List(); + for (int x = GxBase - 1; x <= GxBase + 3; x++) + for (int y = GyBase - 1; y <= GyBase + 3; y++) + expected.Add(Cell(x, y)); + + Assert.Equal(25, cells.Count); + Assert.Equal(expected.OrderBy(v => v), cells.OrderBy(v => v)); + + // Control: the same part described only by its 1 m sphere collapses. + List sphereOnly = Rectangle( + new Vector3(36f, 36f, 0f), LbId | 10u, SphereOnlyPart(1f)); + Assert.Single(sphereOnly); + Assert.Equal(Cell(GxBase + 1, GyBase + 1), sphereOnly[0]); + } + + // ── T2 ──────────────────────────────────────────────────────────────── + /// + /// The rectangle is FILLED and unioned ACROSS PARTS, not per part. Retail + /// combines the four DELTA accumulators over every part and calls + /// add_cell_block ONCE (0x00533614), so an L-shaped object + /// registers in the cells that close its L — cells its geometry never + /// enters. + /// + /// + /// The fixture is an L on purpose: one arm along +X, one along +Y. A + /// DIAGONAL fixture cannot detect the per-part sabotage, because retail + /// seeds the accumulators to ZERO (0x00533390), so each part's own + /// rectangle already spans from the base cell to that part — and for a + /// diagonal pair the two per-part rectangles union back to the same square. + /// Sabotage: emit one rectangle per part → the corner (3,3) disappears. + /// + /// + [Fact] + public void T2_LShapedPartArray_ClaimsTheCornerThatClosesTheL() + { + var box = (Min: new Vector3(-5f, -5f, -2f), Max: new Vector3(5f, 5f, 2f)); + var anchor = BspPart(box.Min, box.Max); + var eastArm = BspPart(box.Min, box.Max, localPosition: new Vector3(48f, 0f, 0f)); + var northArm = BspPart(box.Min, box.Max, localPosition: new Vector3(0f, 48f, 0f)); + + var entity = new Vector3(36f, 36f, 0f); + List cells = Rectangle(entity, LbId | 10u, anchor, eastArm, northArm); + + uint corner = Cell(GxBase + 3, GyBase + 3); + Assert.Contains(corner, cells); + Assert.Equal(9, cells.Count); + + // Control: the corner is not reachable from any part's own rectangle, + // so the containment above cannot be satisfied by a per-part union. + Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor)); + Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, eastArm)); + Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, northArm)); + } + + // ── T3 ──────────────────────────────────────────────────────────────── + /// + /// The rectangle crosses landblock boundaries freely: add_cell_block + /// works in GLOBAL lcoords and re-derives the block prefix per cell + /// (0x0053320a), so cells beyond column 7 carry the NEIGHBOUR + /// landblock's id. Sabotage: clamp the rectangle to the seed landblock → + /// the 0xAAB4 / 0xA9B5 rows vanish. + /// + [Fact] + public void T3_BoxPastTheBlockEdge_ProducesNeighbourLandblockCellIds() + { + // Entity on cell (7,7): world (180, 180). Box ±30 m → world 150..210 + // → cell columns 6..8; column 8 is the neighbour block's column 0. + var shape = BspPart(new Vector3(-30f, -30f, -2f), new Vector3(30f, 30f, 2f)); + List cells = Rectangle(new Vector3(180f, 180f, 0f), LbId | 64u, shape); + + Assert.Equal(9, cells.Count); + Assert.Contains(Cell(GxBase + 7, GyBase + 7), cells); // 0xA9B40040 + Assert.Contains(Cell(GxBase + 8, GyBase + 7), cells); // 0xAAB4xxxx + Assert.Contains(Cell(GxBase + 7, GyBase + 8), cells); // 0xA9B5xxxx + Assert.Contains(Cell(GxBase + 8, GyBase + 8), cells); // 0xAAB5xxxx + + Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB40000u); + Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xA9B50000u); + Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB50000u); + } + + // ── T4 ──────────────────────────────────────────────────────────────── + /// + /// Map bounds. add_cell_block rejects any coordinate outside + /// [0, 0x7f8) (0x005331f0-0x00533206). Sabotage: drop + /// the clamp → cells wrap into the far corner of the map or produce id 0. + /// + [Fact] + public void T4_RectangleAtTheMapCorners_EmitsNothingOutsideTheMap() + { + // SW corner: landblock (0,0), entity on cell (0,0), box ±50 m reaches + // three cells into negative lcoords on both axes. + var box = BspPart(new Vector3(-50f, -50f, -2f), new Vector3(50f, 50f, 2f)); + List sw = Rectangle(new Vector3(12f, 12f, 0f), 0x00000001u, box); + Assert.All(sw, id => Assert.NotEqual(0u, id)); + Assert.Equal(9, sw.Count); // x 0..2 × y 0..2 survive + Assert.Contains(0x00000001u, sw); + + // NE corner: landblock (254,254) — lcoords 2032..2039, the last legal + // row before 0x7f8 = 2040. + const uint neLb = 0xFEFE0000u; + int neGx = 254 * 8, neGy = 254 * 8; + List ne = Rectangle(new Vector3(180f, 180f, 0f), neLb | 64u, box); + Assert.All(ne, id => Assert.NotEqual(0u, id)); + Assert.Equal(9, ne.Count); // x 2035..2037+ y likewise + Assert.Contains(Cell(neGx + 7, neGy + 7), ne); + Assert.DoesNotContain(Cell(2040 & 0x7FF, 2040 & 0x7FF), ne); + } + + // ── T5 ──────────────────────────────────────────────────────────────── + /// + /// BBox::LocalToGlobal @0x005b2120 re-fits through ALL EIGHT + /// corners, so a rotated box grows. Sabotage: transform only + /// min and max → the −X overhang of a yawed asymmetric box + /// is lost and its westernmost cell disappears. + /// + [Fact] + public void T5_RotatedAsymmetricBox_KeepsTheCornerOverhangMinMaxWouldLose() + { + // Asymmetric box: X 0..60, Y 0..4. Yawed 37 degrees about Z the four + // XY corners land at (0,0), (47.92,36.11), (-2.41,3.19), (45.52,39.30); + // the true AABB therefore starts at x = -2.41, which is the corner a + // min/max-only transform (which sees only (0,0) and (45.52,39.30)) + // cannot produce. + Quaternion yaw37 = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, 37f * MathF.PI / 180f); + var shape = BspPart( + new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f), + localRotation: yaw37); + + // Entity at world x = 48 → the true box spans 45.59..95.92, crossing + // into cell column 1; the min/max-only box starts at exactly 48.0, + // which is column 2. + List cells = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, shape); + + Assert.Contains(Cell(GxBase + 1, GyBase + 0), cells); + Assert.Contains(Cell(GxBase + 3, GyBase + 2), cells); + + // Control: unrotated, the same box starts at exactly x = 48 and never + // reaches column 1 — so the containment above is the rotation's doing. + var unrotated = BspPart(new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f)); + List flat = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, unrotated); + Assert.DoesNotContain(Cell(GxBase + 1, GyBase + 0), flat); + } + + // ── T9 ──────────────────────────────────────────────────────────────── + /// + /// floor, not truncation: retail calls floor then + /// _ftol2 (0x0053353c / 0x00533542). Sabotage: + /// (int)(v / 24f) → for a box overhanging the block's SW corner, + /// -8/24 truncates to 0 and the previous landblock's column 7 is + /// silently dropped. + /// + [Fact] + public void T9_BoxOverhangingTheBlockOrigin_ReachesTheNegativeColumn() + { + var shape = BspPart(new Vector3(-20f, -20f, -2f), new Vector3(20f, 20f, 2f)); + // Entity at world (12, 12): the box spans -8..32, whose floor is -1. + List cells = Rectangle(new Vector3(12f, 12f, 0f), LbId | 1u, shape); + + Assert.Contains(Cell(GxBase - 1, GyBase - 1), cells); // 0xA8B3, cell 64 + Assert.Contains(Cell(GxBase - 1, GyBase + 0), cells); + Assert.Contains(Cell(GxBase + 0, GyBase - 1), cells); + // -8..32 → floor gives columns -1..1, three per axis. + Assert.Equal(9, cells.Count); + Assert.Contains(Cell(GxBase + 1, GyBase + 1), cells); + } + + // ── T8 ──────────────────────────────────────────────────────────────── + /// + /// adjust_to_outside failing (map edge / invalid id) makes retail + /// return before get_landcell and add nothing + /// (0x005333eb select → gid 0 → 0x00533417 je). Sabotage: + /// drop the null check → an exception or a bogus rectangle at lcoord 0. + /// + [Fact] + public void T8_BasePositionOffTheMap_AddsNothingAndDoesNotThrow() + { + var shape = BspPart(new Vector3(-5f, -5f, -2f), new Vector3(5f, 5f, 2f)); + var boxes = new List + { + ShadowPartBox.FromShape( + shape, new Vector3(-100000f, -100000f, 0f), Quaternion.Identity), + }; + var candidates = new CellArray(); + + bool added = CellTransit.AddAllOutsideCellsFromParts( + boxes, 0x00000001u, Vector3.Zero, candidates); + + Assert.False(added); + Assert.Empty(candidates.OrderedIds); + } + + // ── P2 / T6 ─────────────────────────────────────────────────────────── + /// + /// Non-BSP invariance. A cylinder-only owner must still take retail's + /// cylsphere branch — CObjCell::find_cell_list @0x0052b9f0 — and + /// produce exactly the sphere flood's cell set. Sabotage: route every + /// owner through the box path → the sets diverge (the cylinder's box is + /// its own ±radius extent, which spans a different rectangle). + /// + [Fact] + public void T6_CylinderOnlyOwner_MatchesTheUntouchedSphereFlood() + { + var cylinder = ShadowShape.Cylinder( + gfxObjId: 0u, + localPosition: Vector3.Zero, + localRotation: Quaternion.Identity, + scale: 1f, + // r = 12 at the exact centre of cell (1,1) is the configuration in + // which the two routes DISAGREE: check_add_cell_boundary's tests + // are STRICT (pointX > 24-r, pointX < r), so 12 > 12 and 12 < 12 + // both fail and the sphere claims exactly one cell — while the + // same extent as a BOX spans 24..48, whose floor is columns 1 AND + // 2. A fixture at any other radius makes the routes agree and + // proves nothing. + radius: 12f, + cylHeight: 24f); + + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0x334001u; + var worldPos = new Vector3(36f, 36f, 50f); + reg.RegisterMultiPart( + ownerId, worldPos, Quaternion.Identity, + new[] { cylinder }, 0u, EntityCollisionFlags.None, + 0f, 0f, LbId); + + IReadOnlyList expected = CellTransit.BuildShadowCellSet( + new PhysicsDataCache(), + LbId | 10u, + new[] + { + new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = 12f }, + }, + 1, + isStatic: false); + + // Control: the golden must be the SINGLE cell only the sphere route + // produces, so the equality below cannot be satisfied by the box route. + Assert.Equal(new[] { LbId | 10u }, expected); + + var actual = new List(); + foreach (uint id in expected) + { + if (reg.GetObjectsInCell(id).Any(e => e.EntityId == ownerId)) + actual.Add(id); + } + + Assert.NotEmpty(expected); + Assert.Equal(expected.OrderBy(v => v), actual.OrderBy(v => v)); + + // And nothing outside it: the cylinder claims no cell the sphere + // flood did not. + for (uint index = 1u; index <= 64u; index++) + { + uint cellId = LbId | index; + bool held = reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId); + Assert.Equal(expected.Contains(cellId), held); + } + } + + // ── P1 / dispatch ───────────────────────────────────────────────────── + /// + /// The dispatch itself: a BSP-bearing owner registered through + /// lands in EVERY cell + /// of its box rectangle — not the nine of the sphere neighbourhood. This + /// is the end-to-end #334 fact at the production entry point. + /// + [Fact] + public void RegisterMultiPart_BspBearingOwner_OccupiesTheFullBoxRectangle() + { + var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f)); + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0x334002u; + + reg.RegisterMultiPart( + ownerId, new Vector3(36f, 36f, 0f), Quaternion.Identity, + new[] { shape }, 0u, EntityCollisionFlags.None, + 0f, 0f, LbId, seedCellId: LbId | 10u); + + int held = 0; + for (int x = GxBase - 1; x <= GxBase + 3; x++) + for (int y = GyBase - 1; y <= GyBase + 3; y++) + { + uint cellId = Cell(x, y); + Assert.Contains( + reg.GetObjectsInCell(cellId), + e => e.EntityId == ownerId); + held++; + } + + Assert.Equal(25, held); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs index ea9864a5..b70a4f94 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs @@ -26,19 +26,19 @@ public class ShadowObjectRegistryMultiPartTests localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)), + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)), ShadowShape.Bsp( gfxObjId: 0x010044B6u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)), + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)), ShadowShape.Bsp( gfxObjId: 0x010044B6u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)) + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)) }; [Fact] @@ -260,9 +260,11 @@ public class ShadowObjectRegistryMultiPartTests localPosition: localPosition, localRotation: localRotation == default ? Quaternion.Identity : localRotation, scale: 1f, - localBounds: new FlatCollisionSphere( - boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter, - radius)); + localGeometry: ShadowPartGeometry.Create( + new FlatCollisionSphere( + boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter, + radius), + null)); private static List FloodCellsFor(params ShadowShape[] shapes) { @@ -444,7 +446,9 @@ public class ShadowObjectRegistryMultiPartTests setup, entScale: 1f, hasPhysicsBsp: id => id == part, - physicsBspBounds: id => id == part ? bounds : null); + physicsBspBounds: id => id == part + ? ShadowPartGeometry.Create(bounds, null) + : (ShadowPartGeometry?)null); ShadowShape only = Assert.Single(shapes); Assert.Equal(ShadowCollisionType.BSP, only.CollisionType); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 8542e130..d26b0735 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -973,7 +973,7 @@ public class ShadowObjectRegistryTests Vector3.Zero, Quaternion.Identity, scale: 1f, - localBounds: new FlatCollisionSphere(Vector3.Zero, radius)); + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, radius), null)); private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform) { diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs index 52dd5292..04fc10ef 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs @@ -467,5 +467,5 @@ public sealed class ShadowSetPositionCommitTests local, Quaternion.Identity, scale: 1f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 0.25f)); + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 0.25f), null)); } From 49a7e906524065abecc8a2c5bd31b74659215acd Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 19:49:59 +0200 Subject: [PATCH 4/9] =?UTF-8?q?probe(physics):=20ACDREAM=5FPROBE=5FSUPPORT?= =?UTF-8?q?=20+=20ACDREAM=5FWIRE=5FMESH=20=E2=80=94=20separate=20#337's=20?= =?UTF-8?q?three=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user is wedged at the top of Neftet rock plateaus, jumps sink into the mesh, and a corpse falls straight through. ACDREAM_PROBE_REACH already ruled out its own domain: blocked=0, every candidate tested-ok. Three candidates remain — terrain support, a collision mesh not where its visual is, or the transition wedging on an unobstructed path. ACDREAM_PROBE_RESOLVE alone cannot separate them. It prints a three-value contact-plane token, no plane normal, no plane height, no terrain sample and no plane provenance, so all three produce the same line. Two additions: [support] — one line per resolve for EVERY body, not just the player. A corpse is a plain physics body with no player-specific logic, so its fall-through is the cheapest available control on "movement code vs geometry data", and it is invisible to any player-filtered probe. The line samples the outdoor terrain INDEPENDENTLY at the body's own out-XY and prints the contact plane's own height at that same XY. Two heights at one point make support=terrain / object / none a measurement rather than an inference, and cpSrc= names the site that asserted the plane so provenance and classification cross-check. [geom] — once per GfxObj that comes near a mover: the object's physics-BSP vertex cloud against its visual mesh AABB in the same local frame, through the same prepared accessors the resolver queries. verdict=coincident REFUTES the working hypothesis for that object outright; no-physics-bsp / empty-physics-bsp / displaced / extent-mismatch each name a specific data defect. Built to refute, not to confirm — two diagnoses on this defect's lineage have already been refuted by measurement. ACDREAM_WIRE_MESH upgrades the existing F2 overlay, which drew a broadphase proxy cylinder for BSP objects and so could not answer the question at all, to the real physics-BSP polygon edges (cyan) beside the visual mesh box (magenta) and the terrain surface (yellow). Own class per code-structure rule 1. The provenance latch lives on PhysicsDiagnostics, not on CollisionInfo. Two fields there first — the obvious home — broke the flat/graph differential referee and the scratch-reset poison test, both of which compare CollisionInfo member-for-member. Teaching either to skip a member is a one-line green fix that puts a permanent hole in a referee whose whole job is comparing everything. Captured as feedback_probe_state_off_compared_types. Seven tests cover the support classifier's boundaries: a wrong classifier does not fail to answer, it answers confidently wrong. Gates: Release build 0 errors; complete suite 11,225 passed / 4 skipped / 0 failed from a cleaned tree — baseline 11,218/4/0 plus exactly the seven new tests, skips unchanged. Issue #337 filed with the symptom set, what is ruled out, and a table of what each possible output means. All of this is TEMPORARY and recorded for stripping with the physics-probe family. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 23 + docs/ISSUES.md | 85 +++ .../Rendering/CollisionMeshWireframe.cs | 372 ++++++++++++ .../WorldSceneDiagnosticsController.cs | 60 ++ .../Physics/PhysicsDiagnostics.cs | 553 ++++++++++++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 70 +++ src/AcDream.Core/Physics/TransitionTypes.cs | 46 +- .../Rendering/RenderingDiagnostics.cs | 48 ++ .../Physics/SupportProbeClassifierTests.cs | 120 ++++ 9 files changed, 1376 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/Rendering/CollisionMeshWireframe.cs create mode 100644 tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 28efd094..4c6efe7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1475,6 +1475,29 @@ via `PlayerMovementController.ApplyServerRunRate`) or from delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site. Heavy while a pack is stuck (~60 Hz × stuck count). Converged the #171 residuals (the deep-overlap sign pin AP-82). +- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the + collision geometry where the visual geometry is?** (#337, TEMPORARY). + `[support]`: one line per resolve **for every body, not just the player** + (a corpse falling through geometry is the cheapest control there is on + "movement code vs geometry data"). It samples the outdoor terrain + independently at the body's own out-XY and prints the contact plane's own + height at that same XY, so `support=terrain` / `object` / `none` is a + measurement rather than an inference; `cpSrc=` names the site that wrote + the plane so provenance cross-checks the classification. Edge-eager, + throttled to 4 Hz per body, and emits every 10 cm of vertical movement. + `[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex + cloud against its visual mesh AABB in the same frame, with a verdict + (`coincident` REFUTES "collision isn't where the visual is"; + `no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch` + each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate + those cases — it carries no plane normal, no plane height, no terrain + sample and no provenance. +- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from + a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan) + beside the same objects' visual mesh boxes (magenta) and the terrain + surface (yellow). Settles "visual versus collision" by eye instead of by + log. `ACDREAM_WIRE_RADIUS=` sets the window (default 30). + TEMPORARY, with the #337 probe family. - `ACDREAM_CAPTURE_RESOLVE=` — live capture of every player-side `PhysicsEngine.ResolveWithTransition` call. Each call appends one JSON Lines record with full inputs, PhysicsBody snapshot before AND diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fa262dd4..b43498d7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,91 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — cause NOT yet established + +**Status:** OPEN — instrumented, not diagnosed. Awaiting the live capture below. +**Severity:** HIGH — walk-through, fall-through, and a hard movement stop on world geometry. +**Filed:** 2026-08-06, user-reported in live play after #334's fix landed. +**Component:** physics / collision — possibly geometry data rather than movement code. + +### Symptoms, all from the user in live play + +1. Walks **up** a rock face onto a plateau fine, then **cannot pass at the top** — wedged, position frozen. +2. Jumping is **"swallowed half way by the rock"** — the body sinks into the visual geometry. +3. **A monster corpse falls straight through the rock.** + +Symptom 3 is the load-bearing one. A corpse is a plain physics body with no +player-specific movement logic, so a fall-through there cannot be explained by +anything in the player's controller. + +### What is already RULED OUT + +`ACDREAM_PROBE_REACH` (`334-fix-gate.log`, and the earlier +`334-neftet-probe.log`). At the frozen position: `blocked=0`, and **every** +candidate returns `tested-ok` — including the landblock's own rock mesh +`gfx=0x010046DE` in cell `0x8766002B`. **No object is blocking the player.** +That probe can only see shadow objects, so it has ruled out its own domain and +can say nothing about terrain or the transition. + +Two diagnoses have already been refuted by measurement on this defect's +lineage: the broadphase reach filter (#333/AP-158) and the edge-slide family. +Do not open a third by reasoning from the source. + +### The remaining candidates — and what is NOT yet established + +- **(a) terrain** is what supports/blocks the body (walkable slope limit, + step-up refusal, terrain Z). +- **(b)** a **collision mesh placed somewhere other than its visual**, so the + body interacts with geometry that is not where the rock is drawn. +- **(c)** the **transition wedging** despite an unobstructed path. + +(b) is the current working hypothesis and is **NOT ESTABLISHED**. It is +plausible — a corpse falling through and a jump sinking in are both what +absent-or-displaced collision looks like — but no measurement supports it yet, +and the instruments below were built to REFUTE it, not to confirm it. + +Possibly relevant, possibly coincidence: landblock `0x8766` carries the +**largest single collision owner in the game**, an 81-cell (9×9) footprint +measured during #334 — larger than anything else by a wide margin. + +### Instruments (2026-08-06 — TEMPORARY, strip with the physics-probe family) + +`ACDREAM_PROBE_RESOLVE` alone does **not** separate (a), (b) and (c): it prints +a three-value contact-plane token, no plane normal, no plane height, no terrain +sample and no plane provenance, so all three candidates produce the same line. +Two additions close that: + +- **`ACDREAM_PROBE_SUPPORT=1`** → `[support]` + `[geom]`. + - `[support]`, one per resolve **per body** (players AND corpses): samples the + outdoor terrain independently at the body's own out-XY and prints the + contact plane's own height at that same XY. `support=terrain` / + `support=object` / `support=none` is then a measurement, not an inference, + and `cpSrc=` names the code site that wrote the plane so provenance and + classification cross-check each other. + - `[geom]`, once per GfxObj that comes near the mover: compares the object's + physics-BSP vertex cloud against its visual mesh AABB in the same local + frame. `verdict=coincident` **refutes (b)** for that object outright; + `no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch` + each name a specific data defect. +- **`ACDREAM_WIRE_MESH=1`** upgrades the existing F2 collision overlay from a + broadphase proxy cylinder to the objects' real physics-BSP polygon edges + (cyan) beside their visual mesh boxes (magenta) and the terrain surface + (yellow). Settles "visual versus collision" by eye. + +### How to read the capture + +| Observation | What it means | +|---|---| +| `[geom] verdict=no-physics-bsp` or `empty-physics-bsp` on the rock | The rock has **no collision geometry**. All three symptoms follow; nothing on the movement side needs explaining. | +| `[geom] verdict=displaced` | **(b) confirmed.** Fix the placement/registration transform. | +| `[geom] verdict=coincident` on every nearby object | **(b) refuted.** The cause is (a) or (c); read `[support]`. | +| `[support] support=terrain` while standing on the visible plateau | (a): terrain, not the rock, is the support — terrain Z near the plateau top is the thing to look at. | +| `[support] support=object` with `cpAboveTerr` ≈ the plateau height | The rock IS supporting the body; the wedge is (c). | +| `[support] stalled=true ok=true` with `cpWalkable=true` | (c): the transition accepts the move and advances nothing. | +| `[support] support=none` on the corpse throughout its fall | Nothing ever contacts it — consistent with absent collision, and `[geom]` says whose. | +| **No `[support]` line at all** for the corpse's guid while it visibly falls | The client is not simulating that body — the descent is server-driven or presentational, and the client-side collision path is not the place to look. An absence here is a real answer, not a gap in the capture. | +| `[support] cpWalkable=false` at the freeze | Slope-limit refusal — compare `cpNz` against `floorZ` on the same line. | + ## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX **Status:** OPEN diff --git a/src/AcDream.App/Rendering/CollisionMeshWireframe.cs b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs new file mode 100644 index 00000000..5954e4a8 --- /dev/null +++ b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs @@ -0,0 +1,372 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +/// +/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337 +/// probe family). +/// +/// +/// The F2 collision overlay predating this class drew, for a BSP object, a +/// proxy cylinder sized from the object's registered BROADPHASE radius. That +/// answers "where does the collision system think this object roughly is" and +/// nothing more. The open question in Neftet is a different one — whether an +/// object's collision SURFACES are where its visual mesh is drawn — and a +/// proxy sphere cannot answer it in either direction. +/// +/// +/// +/// This draws the actual geometry instead, in three colours that are meant to +/// be read against each other: +/// +/// Cyan — the object's real physics-BSP polygon edges, in world +/// space. These are the surfaces a body can stand on or be stopped by. +/// Where the cyan mesh sits away from the rock you can see, the collision +/// is displaced; where a visible rock has no cyan on it at all, it has no +/// collision geometry. +/// Magenta — the same object's VISUAL mesh bounding box, from +/// the same prepared assets the renderer draws from. It is the reference +/// the cyan is judged against, so the comparison does not depend on the +/// eye's guess about where the visual "really" is. +/// Yellow — the outdoor terrain surface under the player, as a +/// grid of the physics engine's own sampled heights. If a body is resting +/// on the yellow rather than on cyan, terrain is holding it up and the +/// object's collision is not involved at all. +/// +/// Dim orange keeps the old broadphase proxy visible so nothing the previous +/// overlay showed has been taken away. +/// +/// +/// +/// Geometry is resolved through the SAME prepared collision accessors the +/// resolver queries (PhysicsDataCache.GetFlatGfxObj / +/// GetVisualBounds) and placed with the SAME world transform the +/// collision probes use, so this cannot draw a shape the collision system does +/// not actually hold. Reading the geometry by a second route is how AP-156 +/// managed to report a sphere the registry never emitted. +/// +/// +/// +/// Pure reads. Nothing here mutates physics, registry, or render state; the +/// caller owns the frame. +/// +/// +internal sealed class CollisionMeshWireframe +{ + // Colours, in the order the class comment lists them. + private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f); + private static readonly Vector3 VisualColor = new(1f, 0f, 1f); + private static readonly Vector3 TerrainColor = new(1f, 1f, 0f); + private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f); + + /// + /// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's + /// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock + /// 0x8766 carries the largest single collision owner measured in the game + /// (an 81-cell footprint), and an uncapped walk of it would be the one + /// place this overlay falls over. + /// + private const int MaxLines = 40_000; + + /// Per-object polygon ceiling, so one enormous formation cannot + /// consume the whole budget and hide every other object near it. + private const int MaxPolygonsPerObject = 4_000; + + /// Half-width in metres of the terrain grid drawn under the + /// player, and its sample spacing. + private const float TerrainGridHalfWidth = 12f; + private const float TerrainGridStep = 2f; + + private readonly PhysicsEngine _physics; + + public CollisionMeshWireframe(PhysicsEngine physics) + => _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + + /// + /// Emit the overlay for everything within + /// of + /// . Returns what it drew so the caller can + /// report a capped frame rather than silently showing partial geometry. + /// + public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre) + { + ArgumentNullException.ThrowIfNull(lines); + + float radius = RenderingDiagnostics.CollisionMeshWireframeRadius; + float radiusSquared = radius * radius; + var budget = new LineBudget(MaxLines); + + int objects = 0; + int polygons = 0; + int withoutGeometry = 0; + + PhysicsDataCache? cache = _physics.DataCache; + + foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) + { + // Objects register their part ORIGIN, which for a BSP part is + // routinely nowhere near the geometry itself (376 of the 973 + // installed physics-BSP parts sit further from their own bounding + // centre than half their radius). Admitting on origin distance + // ALONE would drop exactly the large displaced-centre formations + // this overlay exists to look at, so the object's own radius is + // added to the window. + float reach = radius + shadow.Radius; + if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach) + continue; + + objects++; + + if (shadow.CollisionType != ShadowCollisionType.BSP) + { + DrawBroadphaseProxy(lines, in shadow, budget); + continue; + } + + FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId); + int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget); + polygons += drawn; + if (drawn == 0) withoutGeometry++; + + DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget); + DrawBroadphaseProxy(lines, in shadow, budget); + } + + DrawTerrainGrid(lines, centre, budget); + + return new CollisionMeshWireframeStats( + ObjectsConsidered: objects, + PolygonsDrawn: polygons, + ObjectsWithoutPhysicsGeometry: withoutGeometry, + LinesDrawn: budget.Used, + Capped: budget.Capped); + } + + /// + /// Walk the object's physics BSP and emit one closed edge loop per polygon + /// the tree actually indexes. Polygons the tree does not reference are NOT + /// drawn: no query can reach them, so showing them would overstate the + /// collision surface. Returns the polygon count emitted. + /// + private static int DrawPhysicsPolygons( + DebugLineRenderer lines, + in ShadowEntry shadow, + FlatGfxObjCollisionAsset? asset, + Vector3 centre, + float radiusSquared, + LineBudget budget) + { + FlatPhysicsBsp? bsp = asset?.PhysicsBsp; + if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0) + return 0; + + FlatPolygonTable table = bsp.PolygonTable; + ImmutableArray vertices = table.Vertices; + int emitted = 0; + + foreach (FlatPhysicsBspNode node in bsp.Nodes) + { + FlatIndexRange indices = node.PolygonIndexRange; + for (int i = indices.Start; i < indices.EndExclusive; i++) + { + if (emitted >= MaxPolygonsPerObject || budget.Exhausted) + return emitted; + + int polygonIndex = bsp.PolygonIndexStream[i]; + if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue; + + FlatIndexRange span = table.Polygons[polygonIndex].VertexRange; + if (span.Count < 2) continue; + + Vector3 first = ToWorld(vertices[span.Start], in shadow); + // Per-polygon distance rejection, AFTER the world transform: + // a big object admitted by the object-level window still only + // needs the faces near the player drawn. + if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue; + + Vector3 previous = first; + for (int v = span.Start + 1; v < span.EndExclusive; v++) + { + Vector3 current = ToWorld(vertices[v], in shadow); + if (!budget.TryAdd()) return emitted; + lines.AddLine(previous, current, PhysicsColor); + previous = current; + } + + if (span.Count > 2) + { + if (!budget.TryAdd()) return emitted; + lines.AddLine(previous, first, PhysicsColor); + } + + emitted++; + } + } + + return emitted; + } + + /// + /// The visual mesh box, placed with the SAME transform as the physics + /// polygons above. It is drawn as the object's own rotated box (eight + /// transformed corners, twelve edges) rather than as a world-axis-aligned + /// box, so a rotated object's magenta lines still bound its actual visual. + /// + private static void DrawVisualBounds( + DebugLineRenderer lines, + in ShadowEntry shadow, + GfxObjVisualBounds? visual, + LineBudget budget) + { + if (visual is null || budget.Exhausted) return; + + Vector3 min = visual.Min; + Vector3 max = visual.Max; + Span corners = + [ + ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow), + ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow), + ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow), + ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow), + ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow), + ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow), + ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow), + ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow), + ]; + + ReadOnlySpan edges = + [ + 0, 1, 1, 2, 2, 3, 3, 0, + 4, 5, 5, 6, 6, 7, 7, 4, + 0, 4, 1, 5, 2, 6, 3, 7, + ]; + + for (int e = 0; e < edges.Length; e += 2) + { + if (!budget.TryAdd()) return; + lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor); + } + } + + /// + /// The registered broadphase shape — what the pre-#337 overlay showed, and + /// what the collision system's reach filter measures against. Kept so this + /// overlay is a superset of the one it replaces. + /// + private static void DrawBroadphaseProxy( + DebugLineRenderer lines, + in ShadowEntry shadow, + LineBudget budget) + { + // AddCylinder emits a fixed 36 lines. Reserve them together so a + // partial ring cannot be drawn. + if (!budget.TryAdd(36)) return; + + if (shadow.CollisionType == ShadowCollisionType.Cylinder) + { + float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f; + lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor); + return; + } + + lines.AddCylinder( + shadow.Position - new Vector3(0f, 0f, shadow.Radius), + shadow.Radius, + shadow.Radius * 2f, + BroadphaseColor); + } + + /// + /// The terrain surface under the player, sampled through the physics + /// engine's own height resolver — the same numbers the resolver grounds + /// against, not a re-derivation. Drawn as a grid rather than as the single + /// containing triangle so the slope around the player reads at a glance. + /// + private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget) + { + int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep); + float originX = centre.X - TerrainGridHalfWidth; + float originY = centre.Y - TerrainGridHalfWidth; + + for (int ix = 0; ix <= steps; ix++) + { + for (int iy = 0; iy <= steps; iy++) + { + float x = originX + ix * TerrainGridStep; + float y = originY + iy * TerrainGridStep; + float? z = _physics.SampleTerrainZ(x, y); + if (z is null) continue; + + var here = new Vector3(x, y, z.Value); + + if (ix < steps) + { + float nx = x + TerrainGridStep; + if (_physics.SampleTerrainZ(nx, y) is { } nz) + { + if (!budget.TryAdd()) return; + lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor); + } + } + + if (iy < steps) + { + float ny = y + TerrainGridStep; + if (_physics.SampleTerrainZ(x, ny) is { } nz2) + { + if (!budget.TryAdd()) return; + lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor); + } + } + } + } + } + + /// + /// The one placement formula, matching the [resolve-bldg] probe's + /// world transform for a shadow part + /// (TransitionTypes.FindObjCollisionsInCell): scale in the part's + /// own frame, then rotate, then translate to the registered position. + /// + private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow) + => shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation); + + /// + /// Mutable line counter shared across the draw. A class rather than a + /// struct so the per-shape helpers can be static and still share it + /// without ref-plumbing through every signature. + /// + private sealed class LineBudget(int limit) + { + public int Used { get; private set; } + + public bool Capped { get; private set; } + + public bool Exhausted => Used >= limit; + + public bool TryAdd(int count = 1) + { + if (Used + count > limit) + { + Capped = true; + return false; + } + Used += count; + return true; + } + } +} + +/// What one emitted. +/// is the interesting one: a +/// non-zero count means objects near the player carry no reachable collision +/// polygons at all. +internal readonly record struct CollisionMeshWireframeStats( + int ObjectsConsidered, + int PolygonsDrawn, + int ObjectsWithoutPhysicsGeometry, + int LinesDrawn, + bool Capped); diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs index e36f8a0c..e3dbb540 100644 --- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -64,6 +64,10 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics private readonly DebugVmRenderFactsPublisher _debugVm; private readonly bool _debugVmConsumerActive; private int _debugDrawLogCount; + // #337 (TEMPORARY): built on first use so the ordinary overlay path and + // every headless/no-window host pay nothing for it. + private CollisionMeshWireframe? _meshWireframe; + private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false); public WorldSceneDiagnosticsController( WorldRenderDiagnostics diagnostics, @@ -215,6 +219,18 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics if (!_state.CollisionWireframesVisible || _lines is null) return; + // #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the + // broadphase-proxy overlay below with the objects' real physics-BSP + // polygon edges beside their visual mesh boxes and the terrain surface + // — see CollisionMeshWireframe for why the proxy cannot answer the + // question this mode exists for. Default off; F2 keeps its old + // behaviour otherwise. + if (RenderingDiagnostics.CollisionMeshWireframeEnabled) + { + DrawCollisionMesh(in camera); + return; + } + _lines.Begin(); int drawn = 0; foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) @@ -255,6 +271,50 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics _lines.Flush(camera.Camera.View, camera.Projection); } + /// + /// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on + /// the player when there is one, else on the camera, so the fly-camera + /// mode can inspect geometry too. + /// + private void DrawCollisionMesh(in WorldCameraFrame camera) + { + Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller + ? controller.Position + : camera.Position; + + _meshWireframe ??= new CollisionMeshWireframe(_physics); + + _lines!.Begin(); + CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre); + + if (_mode.IsPlayerMode && _player.Controller is { } player) + { + _lines.AddCylinder( + player.Position, + DebugVmRenderFactsPublisher.PlayerCollisionRadius, + 1.8f, + new Vector3(1f, 0f, 0f)); + } + + _lines.Flush(camera.Camera.View, camera.Projection); + + // A capped frame is showing PARTIAL geometry, which would otherwise be + // indistinguishable from an object that has none — exactly the + // misreading this overlay exists to prevent. Say so, throttled, rather + // than letting the picture lie. + if (stats != _lastMeshStats) + { + _lastMeshStats = stats; + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " + + "noPhysicsGeometry={5} lines={6} capped={7}", + centre.X, centre.Y, centre.Z, + stats.ObjectsConsidered, stats.PolygonsDrawn, + stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped)); + } + } + private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn) { if (_debugDrawLogCount >= 5) diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index e3afc37a..ae785f2e 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1370,6 +1370,551 @@ public static class PhysicsDiagnostics blocked, currPos.X, currPos.Y, currPos.Z, now)); } + // ----------------------------------------------------------------------- + // [support] / [geom] — #337 "what is holding this body up, and is the + // collision geometry where the visual geometry is?" (2026-08-06 — + // TEMPORARY, strip with the physics-probe family). + // + // WHY A NEW FAMILY RATHER THAN MORE [resolve]. + // ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out + // position + cell, ok, groundedIn, a THREE-VALUE contact-plane token + // (valid / lastKnown / none), the collision normal + responsible entity if + // something was hit, and one walkable-polygon bool. That is enough to say + // THAT the body stopped. It cannot say WHAT held it up, because it prints + // no plane normal, no plane height, no terrain sample, and no attribution + // for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain + // holding the body, (b) an object surface holding it somewhere other than + // where the rock is drawn, and (c) an unobstructed transition that simply + // fails to advance all produce the SAME [resolve] line. Two diagnoses this + // campaign have already been refuted by measurement; a probe that cannot + // separate the remaining three is not worth the launch. + // + // WHAT SEPARATES THEM. + // [support] — per resolve, per body (players AND corpses/NPCs, which is + // what makes the fall-through case observable at all). It samples the + // OUTDOOR TERRAIN independently at the body's own out-XY and prints + // the contact plane's own height at that same XY. Two independent + // heights at one point: + // cpZ@out == terrZ → terrain is the support, whatever set it. + // cpZ@out >> terrZ → an object surface is the support. + // cpValid=false → nothing is; the body is in free fall. + // `cpSrc` names the code site that wrote the plane, so the classifier + // and the provenance are cross-checkable rather than one inferring + // the other. + // [geom] — once per GfxObj that comes near the mover. Compares the + // object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in + // the same local frame. If the collision geometry is absent, empty, + // displaced, or the wrong size, this line says so directly. That is + // the working hypothesis's refutation test: `verdict=coincident` + // kills "the collision isn't where the visual is" outright, and no + // amount of movement-side evidence is then needed to rule it out. + // + // Neither line gates, orders, or mutates anything. Both are pure reads. + // ----------------------------------------------------------------------- + + /// + /// Initial state from ACDREAM_PROBE_SUPPORT=1. Enables the + /// [support] and [geom] lines described above. Zero cost when + /// off (one static bool read per resolve and per collision candidate). + /// TEMPORARY — strip with the rest of the physics-probe family. + /// + public static bool ProbeSupportEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1"; + + /// Vertical agreement window, in metres, inside which the contact + /// plane's height and the terrain's height at the same XY are called the + /// same surface. + public const float SupportSameSurfaceZ = 0.05f; + + /// Straight-up-component agreement window inside which the contact + /// plane's tilt and the terrain triangle's tilt are called the same + /// surface. 0.02 is roughly 1 degree near flat. + public const float SupportSameSurfaceNormalZ = 0.02f; + + private static readonly object _supportGate = new(); + private static readonly Dictionary _supportSeen = new(); + private static readonly HashSet _geomSeen = new(); + + // Contact-plane provenance latch. Ten distinct sites call + // CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat), + // cell BSP, three water paths, and a straight-up fallback — and the plane + // they write is indistinguishable once stored, so [support]'s + // classification would have no independent cross-check. + // + // This deliberately does NOT live on CollisionInfo. That object's stored + // members are compared member-for-member by the flat/graph differential + // referee and by the transition-scratch reset poison test; adding a + // diagnostic field there makes both oracles report a difference that is + // not a difference, and the only way to keep them green is to teach them + // to skip a member — which is how a referee quietly stops refereeing. + // [ThreadStatic] because a headless host ticks several sessions in + // parallel and physics is synchronous within each. + [ThreadStatic] private static string? _contactPlaneSourceMember; + [ThreadStatic] private static int _contactPlaneSourceLine; + + /// + /// Clear the provenance latch. Call once per resolve, before the sweep, so + /// a resolve that establishes no plane reports none rather than the + /// previous resolve's answer. No-op unless + /// . + /// + public static void BeginContactPlaneAttribution() + { + if (!ProbeSupportEnabled) return; + _contactPlaneSourceMember = null; + _contactPlaneSourceLine = 0; + } + + /// + /// Record the site asserting a contact plane. Called by + /// CollisionInfo.SetContactPlane with compiler-supplied literals; + /// self-guarded, so it is a single flag test when the probe is off and + /// allocates nothing when it is on. + /// + public static void RecordContactPlaneSource(string member, int line) + { + if (!ProbeSupportEnabled) return; + _contactPlaneSourceMember = member; + _contactPlaneSourceLine = line; + } + + /// + /// member:line of the last site to assert a contact plane since + /// , or "none". + /// + public static string ContactPlaneSource => + _contactPlaneSourceMember is { Length: > 0 } member + ? string.Concat( + member, + ":", + _contactPlaneSourceLine.ToString( + System.Globalization.CultureInfo.InvariantCulture)) + : "none"; + + /// + /// Classify what is under the body. Pure function so the live probe and any + /// offline reader agree on the vocabulary. + /// + /// none — no contact plane: the body is unsupported. + /// terrain — the contact plane sits at the terrain's height + /// AND shares its tilt. + /// object — the contact plane sits clear of the terrain: some + /// collision surface other than the ground is the support. + /// coplanar-tilt-mismatch — same height, different tilt. + /// Reported as its own answer rather than folded into either, because + /// it is exactly what a collision mesh laid flat against the ground + /// would look like and guessing between the two would be the third + /// unverified diagnosis this campaign. + /// no-terrain — no outdoor terrain under this XY (indoors, + /// or the landblock is not resident): the comparison is unavailable + /// and is said so rather than defaulted. + /// + /// + public static string ClassifySupport( + bool contactPlaneValid, + bool terrainSampled, + float contactPlaneZAtXY, + float contactPlaneNormalZ, + float terrainZ, + float terrainNormalZ) + { + if (!contactPlaneValid) return "none"; + if (!terrainSampled) return "no-terrain"; + + bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ; + bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ; + + if (sameHeight && sameTilt) return "terrain"; + if (sameHeight) return "coplanar-tilt-mismatch"; + return "object"; + } + + /// + /// Evaluate a plane's height at a given XY. Returns + /// when the plane is near-vertical, where a height is not defined — a wall + /// is never a floor, and reporting a huge number for one would read as a + /// displaced surface. + /// + public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z) + { + float nz = plane.Normal.Z; + if (MathF.Abs(nz) < 1e-4f) + { + z = float.NaN; + return false; + } + z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz; + return true; + } + + /// + /// One [support] line. Self-guards on + /// . + /// + /// + /// Volume control: per mover, the line re-emits IMMEDIATELY on any change + /// in the state signature — the support classification, the ok / contact / + /// walkable / stalled bits, a 0.1 m change in the body's height, or a + /// 0.1 m change in its height above terrain — and otherwise at most once + /// per 250 ms. A body falling through geometry therefore produces a line + /// every 10 cm of descent, and a body standing still produces four lines a + /// second. Nothing is aggregated away. + /// + /// + /// + /// is never omitted: + /// feedback_probe_identity_attribution — a per-entity probe without + /// an identity produced a wrong root cause once already, and this capture + /// deliberately covers several bodies at once. + /// + /// + public static void LogSupport( + uint moverId, + bool isPlayer, + Vector3 inPos, + uint inCell, + Vector3 targetPos, + Vector3 outPos, + uint outCell, + bool ok, + bool groundedIn, + bool contact, + bool onWalkable, + bool contactPlaneValid, + Plane contactPlane, + uint contactPlaneCellId, + bool contactPlaneIsWater, + string contactPlaneSource, + bool lastKnownValid, + Plane lastKnownPlane, + bool terrainSampled, + float terrainZ, + Vector3 terrainNormal, + uint terrainCellId, + bool terrainIsWater, + bool walkablePolygon, + bool lastWalkablePolygon, + float stepUpHeight, + float stepDownHeight, + Vector3 velocity) + { + if (!ProbeSupportEnabled) return; + + float cpZ = float.NaN; + bool cpZDefined = contactPlaneValid + && TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ); + if (!cpZDefined) cpZ = float.NaN; + + float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN; + float terrNz = terrainSampled ? terrainNormal.Z : float.NaN; + + string support = ClassifySupport( + contactPlaneValid && cpZDefined, + terrainSampled, + cpZ, + cpNz, + terrainZ, + terrNz); + + float commanded = Vector3.Distance(inPos, targetPos); + float moved = Vector3.Distance(inPos, outPos); + // "The body was told to move and did not." The 1 cm floor is the + // resolver's own no-op scale, not a tuned threshold. + bool stalled = commanded > 0.01f && moved <= 0.01f; + + float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN; + float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN; + + long now = Environment.TickCount64; + long signature = support.GetHashCode(); + signature = signature * 31 + (ok ? 1 : 0); + signature = signature * 31 + (groundedIn ? 1 : 0); + signature = signature * 31 + (contact ? 1 : 0); + signature = signature * 31 + (onWalkable ? 1 : 0); + signature = signature * 31 + (contactPlaneValid ? 1 : 0); + signature = signature * 31 + (stalled ? 1 : 0); + signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f); + signature = signature * 31 + (float.IsNaN(zAboveTerrain) + ? 0 + : (long)MathF.Floor(zAboveTerrain * 10f)); + + lock (_supportGate) + { + if (_supportSeen.TryGetValue(moverId, out var prev) + && prev.Signature == signature + && now - prev.Ms < 250) + { + return; + } + _supportSeen[moverId] = (now, signature); + } + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " + + "in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " + + "tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " + + "ok={15} cmd={16:F3} moved={17:F3} stalled={18} " + + "groundedIn={19} contact={20} onWalkable={21} " + + "cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " + + "cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " + + "cpZatOut={32:F3} " + + "lkcpValid={33} lkcpNz={34:F4} " + + "terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " + + "terrCell=0x{39:X8} terrWater={40} " + + "zAboveTerr={41:F3} cpAboveTerr={42:F3} " + + "walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " + + "vel=({47:F3},{48:F3},{49:F3})", + moverId, isPlayer, now, support, + inPos.X, inPos.Y, inPos.Z, inCell, + targetPos.X, targetPos.Y, targetPos.Z, + outPos.X, outPos.Y, outPos.Z, outCell, + ok, commanded, moved, stalled, + groundedIn, contact, onWalkable, + contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater, + contactPlaneValid ? contactPlane.Normal.X : float.NaN, + contactPlaneValid ? contactPlane.Normal.Y : float.NaN, + cpNz, cpNz, PhysicsGlobals.FloorZ, + contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ, + cpZ, + lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN, + terrainSampled, terrainZ, terrNz, + terrainSampled && terrNz >= PhysicsGlobals.FloorZ, + terrainCellId, terrainIsWater, + zAboveTerrain, cpAboveTerrain, + walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight, + velocity.X, velocity.Y, velocity.Z)); + } + + /// + /// Ask whether [geom] has already been emitted for this GfxObj. + /// The line is a property of the ASSET, not of any moment, so once per + /// process is the whole story and re-emitting it would bury the + /// [support] stream. + /// + public static bool ShouldLogGeometry(uint gfxObjId) + { + if (!ProbeSupportEnabled) return false; + lock (_supportGate) + { + return _geomSeen.Add(gfxObjId); + } + } + + /// + /// One [geom] line: is this object's collision geometry where its + /// visual geometry is? Caller MUST have claimed the id through + /// . + /// + /// + /// The verdict vocabulary, and what each one settles: + /// + /// no-physics-bsp / empty-physics-bsp — the object + /// has no collision polygons at all. Everything a body does around it + /// follows from that one fact and no movement-side theory is needed. + /// no-visual-bounds — the comparison could not be made. Said + /// out loud rather than silently treated as agreement. + /// displaced — collision and visual are the same size but + /// sit in different places. This is the working hypothesis, and this + /// token is the only thing that confirms it. + /// extent-mismatch — same place, different size. + /// coincident — collision and visual agree. This REFUTES the + /// working hypothesis for this object, and the cause is then on the + /// movement side (terrain support, or the transition itself). + /// + /// + /// + public static void LogGeometry( + uint gfxObjId, + uint entityId, + int bspNodeCount, + int bspPolygonCount, + int bspVertexCount, + Vector3 rootSphereOrigin, + float rootSphereRadius, + bool physicsBoundsValid, + Vector3 physicsMin, + Vector3 physicsMax, + bool visualBoundsValid, + Vector3 visualMin, + Vector3 visualMax, + float visualRadius, + Vector3 entityWorldPosition, + float entityScale, + float registeredRadius) + { + Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero; + Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero; + Vector3 physCentre = physicsBoundsValid + ? (physicsMin + physicsMax) * 0.5f + : Vector3.Zero; + Vector3 visCentre = visualBoundsValid + ? (visualMin + visualMax) * 0.5f + : Vector3.Zero; + + float centreDelta = physicsBoundsValid && visualBoundsValid + ? Vector3.Distance(physCentre, visCentre) + : float.NaN; + + // Tolerances are deliberately loose: this line answers "same place, + // same size?" at the scale of a rock formation, not to the millimetre. + // A physics hull is a coarse stand-in for the render mesh, so a + // half-metre of centre drift or a 2x extent ratio is normal; what this + // is looking for is the pathological case. + float centreTolerance = visualBoundsValid + ? MathF.Max(0.5f, visualRadius * 0.25f) + : 0.5f; + + bool extentMismatch = false; + if (physicsBoundsValid && visualBoundsValid) + { + for (int axis = 0; axis < 3; axis++) + { + float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z; + float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z; + // Flat axes (a floor plate) legitimately have ~0 extent in one + // dimension on both sides; only compare where the visual has + // real size. + if (v < 0.1f) continue; + float ratio = p / v; + if (ratio is < 0.5f or > 2.0f) extentMismatch = true; + } + } + + string verdict = + bspNodeCount == 0 ? "no-physics-bsp" + : bspPolygonCount == 0 ? "empty-physics-bsp" + : !visualBoundsValid ? "no-visual-bounds" + : !physicsBoundsValid ? "no-physics-bounds" + : centreDelta > centreTolerance ? "displaced" + : extentMismatch ? "extent-mismatch" + : "coincident"; + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " + + "bspNodes={4} bspPolys={5} bspVerts={6} " + + "rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " + + "physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " + + "physExt=({18:F3},{19:F3},{20:F3}) " + + "visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " + + "visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " + + "centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " + + "objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " + + "physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]", + gfxObjId, verdict, entityId, Environment.TickCount64, + bspNodeCount, bspPolygonCount, bspVertexCount, + rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z, + rootSphereRadius, registeredRadius, + physicsMin.X, physicsMin.Y, physicsMin.Z, + physicsMax.X, physicsMax.Y, physicsMax.Z, + physExtent.X, physExtent.Y, physExtent.Z, + visualMin.X, visualMin.Y, visualMin.Z, + visualMax.X, visualMax.Y, visualMax.Z, + visExtent.X, visExtent.Y, visExtent.Z, visualRadius, + centreDelta, centreTolerance, extentMismatch, + entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z, + entityScale, + // Rotation is NOT applied to these two world Z ranges: an + // axis-aligned box is not rotation-invariant, so a rotated object + // would report a box that is merely indicative. Both sides get the + // SAME treatment, so their AGREEMENT (the thing being measured) + // stays exact regardless. + entityWorldPosition.Z + physicsMin.Z * entityScale, + entityWorldPosition.Z + physicsMax.Z * entityScale, + entityWorldPosition.Z + visualMin.Z * entityScale, + entityWorldPosition.Z + visualMax.Z * entityScale)); + } + + /// + /// Resolve the collision-vs-visual comparison for one GfxObj straight from + /// the SAME prepared assets the resolver itself queries, and emit its + /// [geom] line. Going through the production accessors is the point: + /// AP-156's lesson was that a probe reading geometry by a second route can + /// report a shape the registry never emitted. Caller MUST have claimed the + /// id through . + /// + /// + /// The physics box is measured over the vertices of the polygons the BSP + /// actually indexes, not over the whole polygon table — a table can carry + /// rows no node references, and including those would report collision + /// geometry that no query can ever reach. + /// + /// + public static void LogGeometryFromAssets( + uint gfxObjId, + uint entityId, + FlatGfxObjCollisionAsset? flat, + GfxObjVisualBounds? visual, + Vector3 entityWorldPosition, + float entityScale, + float registeredRadius) + { + int nodeCount = 0; + int polygonCount = 0; + int vertexCount = 0; + Vector3 rootOrigin = Vector3.Zero; + float rootRadius = 0f; + bool physBoundsValid = false; + var physMin = new Vector3(float.PositiveInfinity); + var physMax = new Vector3(float.NegativeInfinity); + + FlatPhysicsBsp? bsp = flat?.PhysicsBsp; + if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0) + { + nodeCount = bsp.Nodes.Length; + rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin; + rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius; + + FlatPolygonTable table = bsp.PolygonTable; + foreach (FlatPhysicsBspNode node in bsp.Nodes) + { + FlatIndexRange range = node.PolygonIndexRange; + for (int i = range.Start; i < range.EndExclusive; i++) + { + int polygonIndex = bsp.PolygonIndexStream[i]; + if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue; + + polygonCount++; + FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange; + for (int v = vertices.Start; v < vertices.EndExclusive; v++) + { + Vector3 p = table.Vertices[v]; + vertexCount++; + physMin = Vector3.Min(physMin, p); + physMax = Vector3.Max(physMax, p); + physBoundsValid = true; + } + } + } + } + + if (!physBoundsValid) + { + physMin = Vector3.Zero; + physMax = Vector3.Zero; + } + + LogGeometry( + gfxObjId: gfxObjId, + entityId: entityId, + bspNodeCount: nodeCount, + bspPolygonCount: polygonCount, + bspVertexCount: vertexCount, + rootSphereOrigin: rootOrigin, + rootSphereRadius: rootRadius, + physicsBoundsValid: physBoundsValid, + physicsMin: physMin, + physicsMax: physMax, + visualBoundsValid: visual is not null, + visualMin: visual?.Min ?? Vector3.Zero, + visualMax: visual?.Max ?? Vector3.Zero, + visualRadius: visual?.Radius ?? 0f, + entityWorldPosition: entityWorldPosition, + entityScale: entityScale, + registeredRadius: registeredRadius); + } + /// /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). /// Emits one [tp-probe] line per teleport-pipeline event with a @@ -1586,6 +2131,14 @@ public static class PhysicsDiagnostics _reachSeenObj.Clear(); _reachSeenQuery.Clear(); } + ProbeSupportEnabled = false; + _contactPlaneSourceMember = null; + _contactPlaneSourceLine = 0; + lock (_supportGate) + { + _supportSeen.Clear(); + _geomSeen.Clear(); + } ProbeTeleportEnabled = false; ProbeRemoteTeleportEnabled = false; ProbeRemoteLandingEnabled = false; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 74f33484..4668b1f1 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1913,6 +1913,15 @@ public sealed class PhysicsEngine ? PhysicsResolveCapture.Snapshot(body) : null; + // #337 (2026-08-06 — TEMPORARY): arm the [support] probe's + // contact-plane provenance latch for this resolve, ahead of everything + // including the carried-plane seed below. The seed is itself one of + // the ten sites that assert a plane, so it stamps its own name and a + // capture can read `cpSrc=ResolveWithTransition:` as "carried + // from the body, nothing re-derived it this resolve" without needing a + // sentinel value for that case. No-op when the probe is off. + PhysicsDiagnostics.BeginContactPlaneAttribution(); + var transition = RentTransition(); try { @@ -2299,6 +2308,67 @@ public sealed class PhysicsEngine $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); } + // #337 [support] probe (2026-08-06 — TEMPORARY, strip with the + // physics-probe family). Runs for EVERY body, not just the player: + // a corpse sinking through geometry is a plain physics body with + // no player-specific logic, so it is the cheapest possible control + // on whether the movement code or the geometry is at fault, and it + // is invisible to any player-filtered probe. + // + // The terrain sample below is INDEPENDENT of whatever the sweep + // decided — it asks the landblock directly what the ground height + // is under the body's own out-XY. Pairing that with the contact + // plane's height at the same XY is what separates "terrain is + // holding this body up" from "some object surface is". Read-only: + // SampleTerrainWalkable takes no locks, mutates nothing, and is + // not on the resolve's committed path. + if (PhysicsDiagnostics.ProbeSupportEnabled) + { + Vector3 outPos = sp.CheckPos; + TerrainWalkableSample? terrain = + SampleTerrainWalkable(outPos.X, outPos.Y); + + bool terrainSampled = terrain.HasValue + && PhysicsDiagnostics.TryPlaneZAt( + terrain.Value.Plane, outPos.X, outPos.Y, out _); + float terrainZ = float.NaN; + if (terrainSampled) + { + PhysicsDiagnostics.TryPlaneZAt( + terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ); + } + + PhysicsDiagnostics.LogSupport( + moverId: movingEntityId, + isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0, + inPos: currentPos, + inCell: cellId, + targetPos: targetPos, + outPos: outPos, + outCell: sp.CheckCellId, + ok: ok, + groundedIn: isOnGround, + contact: transition.ObjectInfo.Contact, + onWalkable: transition.ObjectInfo.OnWalkable, + contactPlaneValid: ci.ContactPlaneValid, + contactPlane: ci.ContactPlane, + contactPlaneCellId: ci.ContactPlaneCellId, + contactPlaneIsWater: ci.ContactPlaneIsWater, + contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource, + lastKnownValid: ci.LastKnownContactPlaneValid, + lastKnownPlane: ci.LastKnownContactPlane, + terrainSampled: terrainSampled, + terrainZ: terrainZ, + terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero, + terrainCellId: terrain?.CellId ?? 0u, + terrainIsWater: terrain?.IsWater ?? false, + walkablePolygon: sp.HasWalkablePolygon, + lastWalkablePolygon: sp.HasLastWalkablePolygon, + stepUpHeight: stepUpHeight, + stepDownHeight: stepDownHeight, + velocity: body?.Velocity ?? Vector3.Zero); + } + // Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell. // Emits before the ResolveResult is built so it shows what BOTH paths would return. // No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 34b5da29..ac3632c8 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -443,8 +443,32 @@ public sealed class CollisionInfo /// internal int ContactPlaneWriteCount { get; private set; } - public void SetContactPlane(Plane plane, uint cellId, bool isWater = false) + public void SetContactPlane( + Plane plane, + uint cellId, + bool isWater = false, + // #337 [support] attribution — recorded on PhysicsDiagnostics, not on + // this object; see the comment in the body for why. Compiler-supplied + // literals: no call site passes these explicitly and none needs to. + [System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "", + [System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0) { + // #337 attribution (2026-08-06 — TEMPORARY, strip with the probe + // family). Recorded ABOVE the no-op guard on purpose: the meaning is + // "the last site that ASSERTED this plane", not "the site that first + // differed from the previous value" — a sweep that re-derives the + // identical plane it was seeded with has still told you where the + // plane comes from, and that is the fact the capture needs. + // + // It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's + // stored members are compared member-for-member by the flat/graph + // differential referee and by the scratch-reset poison test; a + // diagnostic field there is state those oracles must then be taught to + // ignore, which is how a referee stops refereeing. The latch is + // [ThreadStatic] and is armed once per resolve — see + // PhysicsDiagnostics.BeginContactPlaneAttribution. + PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine); + // A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes // issue #96 (per-tick CP-write blowup) without removing the // PhysicsEngine.cs L622 seed that step_up depends on. When the @@ -3755,6 +3779,26 @@ public sealed class Transition foreach (ShadowEntry obj in nearbyObjs.Entries) { + // #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the + // physics-probe family). Emitted here, at the TOP of the candidate + // loop, so it covers every object the mover comes near regardless + // of what the exemptions and the reach filter later do with it — + // an object whose collision geometry is absent or displaced must + // be reported even when nothing ever tests it. Once per GfxObj per + // process: the line describes an ASSET, not a moment. + if (obj.CollisionType == ShadowCollisionType.BSP + && PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId)) + { + PhysicsDiagnostics.LogGeometryFromAssets( + gfxObjId: obj.GfxObjId, + entityId: obj.EntityId, + flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId), + visual: engine.DataCache.GetVisualBounds(obj.GfxObjId), + entityWorldPosition: obj.Position, + entityScale: obj.Scale, + registeredRadius: obj.Radius); + } + // Self-skip — fix #42 (2026-05-05). Mirrors retail // CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt // 308931: `physobj != arg2->object_info.object` rejects the diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index e459fb1d..ae40f920 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -783,4 +783,52 @@ public static class RenderingDiagnostics /// public static string? FrameHistoryPath { get; } = Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY"); + + // ── #337 collision-mesh wireframe (2026-08-06 — TEMPORARY) ────────────── + // + // The F2 collision overlay already existed, but for a BSP object it drew a + // proxy cylinder sized from the REGISTERED BROADPHASE RADIUS. That shows + // where the collision system thinks the object roughly is; it cannot show + // where the collision SURFACES are, which is the only thing that answers + // "is the collision geometry where the visual geometry is". The knobs + // below turn F2 into the real answer: the actual physics-BSP polygon + // edges, in world space, next to the same object's visual mesh box. + // + // Off by default, so F2 keeps its old cheap behaviour for anyone who wants + // it and this costs nothing until asked for. + + /// + /// When true, the F2 collision overlay draws each nearby object's REAL + /// physics-BSP polygon edges (cyan) and, beside them, the same object's + /// visual mesh bounding box (magenta), plus the terrain triangle under the + /// player (yellow). Any separation between the cyan surfaces and the + /// object you can see is the "collision is not where the visual is" + /// defect, read directly off the screen instead of inferred from a log. + /// Initial state from ACDREAM_WIRE_MESH=1. + /// TEMPORARY — strip with the #337 probe family. + /// + public static bool CollisionMeshWireframeEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1"; + + /// + /// Radius in metres around the player within which + /// resolves polygon geometry. + /// A whole landblock of rock is far more geometry than a line list wants; + /// 30 m covers everything you can wedge against. Override with + /// ACDREAM_WIRE_RADIUS=<metres>. + /// + public static float CollisionMeshWireframeRadius { get; set; } = + ParsePositiveFloat( + Environment.GetEnvironmentVariable("ACDREAM_WIRE_RADIUS"), + fallback: 30f); + + private static float ParsePositiveFloat(string? raw, float fallback) + => float.TryParse( + raw, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out float value) + && value > 0f + ? value + : fallback; } diff --git a/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs b/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs new file mode 100644 index 00000000..742164b1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs @@ -0,0 +1,120 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #337 (2026-08-06 — TEMPORARY, delete with the [support] probe). +/// +/// +/// The [support] line's whole value is its support= verdict: +/// terrain, an object surface, or nothing. If that classifier is wrong, a +/// capture does not merely fail to answer — it answers CONFIDENTLY WRONG, and +/// this campaign has already spent two diagnoses on confident wrong answers. +/// These cover the decision boundaries directly, so the live capture can be +/// read at face value. +/// +/// +public sealed class SupportProbeClassifierTests +{ + private const float FlatNormalZ = 1f; + + [Fact] + public void NoContactPlane_IsUnsupported() + { + Assert.Equal( + "none", + PhysicsDiagnostics.ClassifySupport( + contactPlaneValid: false, + terrainSampled: true, + contactPlaneZAtXY: 100f, + contactPlaneNormalZ: FlatNormalZ, + terrainZ: 100f, + terrainNormalZ: FlatNormalZ)); + } + + [Fact] + public void PlaneAtTerrainHeightAndTilt_IsTerrain() + { + Assert.Equal( + "terrain", + PhysicsDiagnostics.ClassifySupport( + contactPlaneValid: true, + terrainSampled: true, + contactPlaneZAtXY: 41.25f, + contactPlaneNormalZ: 0.94f, + terrainZ: 41.26f, + terrainNormalZ: 0.94f)); + } + + [Fact] + public void PlaneWellAboveTerrain_IsObject() + { + // The rock-plateau shape: the body rests six metres above the ground. + Assert.Equal( + "object", + PhysicsDiagnostics.ClassifySupport( + contactPlaneValid: true, + terrainSampled: true, + contactPlaneZAtXY: 47.5f, + contactPlaneNormalZ: FlatNormalZ, + terrainZ: 41.5f, + terrainNormalZ: 0.9f)); + } + + [Fact] + public void SameHeightDifferentTilt_IsReportedSeparately() + { + // A collision surface lying flat against sloped ground. This must NOT + // collapse into either answer: it is precisely the ambiguous case, and + // guessing between them is what the probe exists to avoid. + Assert.Equal( + "coplanar-tilt-mismatch", + PhysicsDiagnostics.ClassifySupport( + contactPlaneValid: true, + terrainSampled: true, + contactPlaneZAtXY: 41.5f, + contactPlaneNormalZ: 1.0f, + terrainZ: 41.5f, + terrainNormalZ: 0.72f)); + } + + [Fact] + public void NoTerrainUnderTheBody_SaysSoRatherThanGuessing() + { + Assert.Equal( + "no-terrain", + PhysicsDiagnostics.ClassifySupport( + contactPlaneValid: true, + terrainSampled: false, + contactPlaneZAtXY: 12f, + contactPlaneNormalZ: FlatNormalZ, + terrainZ: float.NaN, + terrainNormalZ: float.NaN)); + } + + [Fact] + public void PlaneHeightIsEvaluatedAtTheBodysOwnXy() + { + // A 45-degree ramp through the origin: height must track X, or a body + // standing on a slope would read as displaced from its own support. + var slope = new Plane(Vector3.Normalize(new Vector3(-1f, 0f, 1f)), 0f); + + Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 0f, 0f, out float atOrigin)); + Assert.Equal(0f, atOrigin, 3); + + Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 10f, 0f, out float atTen)); + Assert.Equal(10f, atTen, 3); + } + + [Fact] + public void VerticalPlaneHasNoHeight() + { + // A wall is never a floor. Reporting a height for one would read as a + // wildly displaced surface and manufacture a false positive. + var wall = new Plane(new Vector3(1f, 0f, 0f), -5f); + + Assert.False(PhysicsDiagnostics.TryPlaneZAt(wall, 0f, 0f, out _)); + } +} From 5a1eeace733abcea0090fe874e976efd0f8ae944 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 21:16:31 +0200 Subject: [PATCH 5/9] =?UTF-8?q?docs(physics):=20#337=20diagnosed=20?= =?UTF-8?q?=E2=80=94=20it=20is=20#333's=20query-site=20broadphase,=20not?= =?UTF-8?q?=20the=20mesh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report-only. No production code changed. The collision mesh is present, correctly shaped, correctly placed in the world, and the BSP traversal reaches every part of it. The mover never gets as far as the query. FindObjCollisionsInCell's per-object broadphase measures the mover's distance to the shadow entry's Position — the part ORIGIN — and compares it against obj.Radius, which is the physics-BSP ROOT BOUNDING SPHERE's radius. For 0xC8766009 those two points are 23.556 m apart, so a mover standing on its plateau is inside the real bounding sphere by ~20 m of margin and is still rejected. Same defect AP-156 fixed in the flood and #334 fixed in the registration extent walk, left in place at the query site. Measured, not inferred. An offline replay against the installed DAT reconstructs all eleven landblock-0x8766 owners and matches the live [geom] placement exactly (0xC8766002 at (84.699,100.082,13.000) yaw -45.00 vs the log's objPos + bspCentreOffset). At the position the client fell through, the production swept query returns a hit on poly 31 at 0.037-0.366 m while the filter rejects the candidate: distToOrigin=60.434 > maxReach=59.697, distance to the bounding-sphere CENTRE 37.083 m against a 56.909 m radius. The live capture recorded that rejection 7,225 times with the probe's own wouldAcceptAtCenter=True on every one. Bounded because the dead zone is the shell between maxReach and the true sphere, up to ~23.5 m thick on the far side. movement.Length() is a budget term: a 0.25 m walking step gives shortfall +0.60, a 0.72 m step +0.14, and ~0.86 m passes — which is exactly why jumping over the spot works, walking into it does not, and a corpse falls through. Three hypotheses refuted by measurement, not by argument: - "the rock's own mesh never collides" — true of 0xC8766002 and it is INNOCENT; its geometry is 22.8 m from the wedge and it has zero brute-force hits over a 12,493-point lattice covering the plateau. It is a candidate only because it is a 130x147 m owner. The rock actually walked on is 0xC8766009. - wrong world transform — the offline placement reproduces the runtime exactly, and a uniform displacement cannot produce a bounded pocket. - BSP traversal hole — a referee ran the production walk against brute force at 7,770 on-surface probes across all eleven owners plus 137,423 lattice points. Mismatch 0 everywhere. A 0.5 m hole map also shows continuous upward-facing coverage across the whole wedge region. [geom]'s verdict=coincident was never able to decide this: LogGeometry compares the physics box against the visual box in the object's OWN LOCAL FRAME, so it proves shape agreement and says nothing about world placement. Recorded in the doc so the next reader does not re-trust it. Retail has no per-object distance filter on the BSP branch. Verified instruction-by-instruction with cdb against the PDB-paired v11.4186 binary: CPartArray::FindObjCollisions @0x00518180 is 14 instructions of bare do/while over parts[i]; CPhysicsPart::find_obj_collisions @0x0050d8d0 is 17 instructions of two null checks plus the call to CGfxObj::find_obj_collisions @0x00534700. No compare, no float math in either. The in-tree comment calling the filter a retail analog and response-neutral is wrong on both counts. The support=object cpNz=1.0000 readings inside the rock are not the rock: ValidateTransition:6076 is retail's stationary-fall failsafe manufacturing a flat plane through the sphere bottom, and :5997 is the LastKnownContactPlane restore holding a stale plane. Both are retail-correct responses to a stuck body, and they are why the client believes it is standing while ACE rejects the position. Preferred fix is to delete the pre-check for BSP entries and correct the comment; fallback is to measure to the bounding-sphere centre, which also needs BoundsCenter carried on ShadowEntry. Neither is landed. The reproducer was confirmed to FAIL when un-skipped, with the numbers above — this campaign has caught eleven green tests covering nothing, so a fixture that cannot distinguish the bug is worse than none. Gates: bin/obj deleted, Release build 0 errors, Core suite 4,287 passed / 2 skipped / 0 failed (baseline 4,286/1 plus three new dumps and the one deliberately skipped reproducer). Co-Authored-By: Claude Opus 4.8 --- docs/ISSUES.md | 24 +- .../2026-08-06-337-neftet-wedge-mechanism.md | 232 +++++ ...sue337NeftetRockGeometryInspectionTests.cs | 928 ++++++++++++++++++ 3 files changed, 1182 insertions(+), 2 deletions(-) create mode 100644 docs/research/2026-08-06-337-neftet-wedge-mechanism.md create mode 100644 tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b43498d7..9311bf3d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,9 +24,29 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — cause NOT yet established +## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — DIAGNOSED, fix not landed -**Status:** OPEN — instrumented, not diagnosed. Awaiting the live capture below. +**Status:** OPEN — **mechanism proven offline 2026-08-06**, fix proposed and +awaiting approval. It is **#333**: the per-object broadphase in +`Transition.FindObjCollisionsInCell` measures to the shadow entry's part +ORIGIN and compares against the BSP ROOT BOUNDING SPHERE's radius. Those are +23.6 m apart for `0xC8766009` / `gfx=0x01004751`, so a mover on the plateau is +rejected before the query it would have passed. Retail has no such filter +(`CPartArray::FindObjCollisions` @0x00518180 and +`CPhysicsPart::find_obj_collisions` @0x0050d8d0 verified instruction-by- +instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014 +`tested-ok` and zero hits, is **innocent** — its geometry is 22.8 m away. +Full evidence + the proposed fix: +[`docs/research/2026-08-06-337-neftet-wedge-mechanism.md`](research/2026-08-06-337-neftet-wedge-mechanism.md). +Reproducer + offline replay: +`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs` +(the acceptance gate is the skipped +`TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`). + +**Historical framing below is superseded by that document** — in particular +"the mesh never collides" was true only of the innocent neighbour, and both +the wrong-transform and BSP-traversal-hole hypotheses are refuted by +measurement. **Severity:** HIGH — walk-through, fall-through, and a hard movement stop on world geometry. **Filed:** 2026-08-06, user-reported in live play after #334's fix landed. **Component:** physics / collision — possibly geometry data rather than movement code. diff --git a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md new file mode 100644 index 00000000..54cad1ec --- /dev/null +++ b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md @@ -0,0 +1,232 @@ +# #337 — the Neftet plateau wedge: mechanism, measured + +**Date:** 2026-08-06 +**Status:** mechanism proven offline; fix proposed, NOT landed. +**Reproducer:** `tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs` +**Related:** #333 (filed: the broadphase reach filter has AP-156's defect at +the query site), #334 (`13fcf381`, registration extent walk), AP-156 +(`b52967de`, flood sphere placement). + +--- + +## Verdict in one paragraph + +The collision mesh is present, correctly shaped, correctly placed in the +world, and the BSP traversal reaches every part of it. **The mover never gets +as far as the query.** `Transition.FindObjCollisionsInCell`'s per-object +broadphase measures the mover's distance to the shadow entry's `Position` — +the part **origin** — and compares it against `obj.Radius`, which is the +physics-BSP **root bounding sphere's radius**. For this rock those two points +are 23.6 m apart, so a mover standing on the plateau is inside the real +bounding sphere by ~20 m of margin and is still rejected. Retail has no such +filter at all. Everything else in the symptom set — the wedge, the sink, the +corpse fall-through, ACE's spawn refusal, the 16,278 m/s rejection, the +character vanishing from a retail observer's view — is downstream of that one +rejection. + +--- + +## What was refuted, and by what measurement + +### 1. "The rock's own mesh never collides" — TRUE for `0xC8766002`, and it is INNOCENT + +`0xC8766002` / `gfx=0x010046DE` really does report 11,014 `tested-ok` with +zero `tested-adjusted`, `tested-collided` or `tested-slid` across the whole +capture. That is correct behaviour: **its geometry is 22.8 m away from the +wedge point.** Over a 30 × 30 × 12 m lattice covering the whole plateau +(12,493 points) it has **zero** brute-force hits — it simply is not there. + +It is a candidate in that cell only because it is a 130 × 147 m owner whose +registration legitimately spans the cell. The probe families that reach it are +working exactly as designed. This object was a red herring. + +The neighbours do collide: `0xC8766003` 767 adjusted / 26 collided, +`0xC8766009` 1,135 adjusted / 708 slid / 58 collided. The rock the player +actually walks on is **`0xC8766009` / `gfx=0x01004751`**. + +### 2. Wrong world transform — REFUTED + +The offline reconstruction from the installed DAT reproduces the runtime +placement exactly: `0xC8766002` at `(84.699, 100.082, 13.000)` yaw −45.00° +against the live `[geom]` line's `objPos=(84.70,100.08,13.00)` and its +`bspCentreOffset` (which implies −45.00°). All eleven owners match. The +transform is right. + +### 3. BSP traversal hole / bounding-sphere early-out — REFUTED + +A referee ran the production walk (`FlatBspQuery.SphereIntersectsPoly`, +node bounding-sphere early-outs and all) against brute force over every +polygon the tree indexes, at 7,770 probe points placed on both faces of every +polygon of all eleven owners, plus 137,423 lattice points over the plateau. + +**Mismatch = 0 everywhere.** There is no pocket the traversal cannot reach. + +### 4. Missing / degenerate geometry, a hole in the walking surface — REFUTED + +A 0.5 m hole map over the plateau shows continuous upward-facing physics +coverage across the whole wedge region; the only gaps are outside the rock's +footprint. At the wedge XY the column is closed: an up-facing polygon at +z = 50.985 (nz = +0.9805) over a down-facing one at z = 40.597 (nz = −0.9325), +i.e. 10.4 m of solid rock, with the player's feet at z = 50.011 — **0.975 m +inside it.** + +### 5. `[geom] verdict=coincident` — confirmed to mean less than it looks + +`LogGeometry` compares `physicsMin/Max` against `visualMin/Max`, both taken +from the same GfxObj asset in the object's **own local frame**. No world +transform enters the comparison. `coincident` proves shape agreement only. +It happens to be true here, but it could never have been the discriminator. + +--- + +## The mechanism, measured + +### The frame-by-frame (337-support.log, cell `0x8766002B`) + +Landblock-local coordinates; the log's Y carries a +576 m live-centre offset, +removed here. Player feet position; Setup `0x02000001` gives sphere[0] at +feet + 0.475 with r = 0.480 and sphere[1] at feet + 1.350. + +| t | feet z | surface z at that XY | what happened | +|---|---|---|---| +| …218906–219296 | 51.096 | 51.081 | standing correctly (delta +0.015). **Every horizontal move of 0.25–0.53 m returns `moved=0.000, stalled=True`** on an 11° walkable slope. This is the wedge. | +| 219328 | 51.096 → 51.389 | | player jumps to escape | +| …–219937 | → 54.638 | | free rise, `support=none` | +| …–221125 | 54.638 → 50.021 | 51.183 | **falls straight through the surface.** Six consecutive resolves accept the full commanded step with no contact plane. | +| 221156 | 50.021 | | descent finally blocked, still no contact plane | +| 221671 | 49.908 | 51.127 | `cpSrc=ValidateTransition:6076` — retail's stationary-fall failsafe **manufactures** a flat plane at z = 49.903, 1.2 m below the real rock surface | + +The `support=object cpNz=1.0000` readings inside the rock are not the rock. +`ValidateTransition:6076` is retail's `FramesStationaryFall > 1` synthetic +up-plane through the sphere bottom; `ValidateTransition:5997` is retail's +`LastKnownContactPlane` restore, i.e. a **stale** plane retained from frames +when the object was still admitted. Both are retail-correct responses to a +stuck body — which is why the client believes it is standing while ACE +believes the position is invalid. + +### The replay: the query would have hit + +`Issue337NeftetRockGeometryInspectionTests.ReplayTheDescentThatFellThrough…` +runs each of those six descent steps as a swept sphere against `0xC8766009`'s +real BSP: + +``` +CONTROL climb step ent=0xC8766009 sphere[0] swept=True(poly 22) static=True nearest=0.070 m +CONTROL stand step ent=0xC8766009 sphere[0] swept=True(poly 26) static=True nearest=0.003 m +FALL 2 51.269->50.961 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.248 m +FALL 3 50.961->50.670 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.037 m +FALL 4 50.670->50.335 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.366 m +``` + +The geometry is right there and the production primitive returns the hit. +Path 6 of `FlatBspQuery.FindCollisionsCore` (the airborne dispatch) would have +called `path.SetCollide(...)` and returned `Adjusted`, blocking the fall. +**It was never called.** + +### Why it was never called + +`Transition.FindObjCollisionsInCell` (`src/AcDream.Core/Physics/TransitionTypes.cs`, +the `maxReach` test in the candidate loop): + +```csharp +Vector3 deltaToCurr = currPos - obj.Position; // ← part ORIGIN +... +float maxReach = sphereRadius + obj.Radius // ← BSP ROOT SPHERE radius + + movement.Length() + 2f; +if (distToCurr > maxReach) continue; +``` + +For `0xC8766009`: origin `(159.107, 36.629, 0.005)`, root bounding sphere +centred at local `(−1.753, 14.259, 18.667)` with radius 56.909 — i.e. the +sphere centre sits **23.556 m** from the origin the filter measures to. + +At the fall position, measured (reproducer output): + +``` +distToOrigin = 60.434 m > maxReach = 59.697 m → REJECTED +distance to the BSP bounding-sphere CENTRE = 37.083 m ≪ 56.909 m radius +``` + +The live capture recorded the same thing 7,225 times for this owner, and the +probe's own `wouldAcceptAtCenter` column says `True` on **every** rejection: + +``` +currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=59.64 shortfall=+0.60 acceptAtCentre=True move=0.255 +currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=60.10 shortfall=+0.14 acceptAtCentre=True move=0.715 +``` + +### Why it is bounded, and why jumping over it works + +The dead zone is the shell between `distOrigin = maxReach` and the true +bounding sphere. Because the sphere centre is offset 23.556 m from the origin, +that shell is up to ~23.5 m thick on the far side of the object — here it +covers the top of the plateau and nothing else. Everywhere closer to the +origin, the same mesh collides normally. That is the "fails in a bounded +region, works elsewhere on the same object" property. + +`movement.Length()` is a term in the budget. A walking step of 0.25 m gives +shortfall +0.60 (rejected); a step of 0.72 m gives +0.14; a step of ~0.86 m +passes. **A jump's larger per-frame movement inflates the budget and lets the +object back through the filter.** That is why jumping over the spot works and +walking into it does not, and why a corpse — small per-frame movement — falls +straight through. + +--- + +## Retail + +There is no per-object distance filter on retail's BSP branch. Verified +instruction-by-instruction with cdb against the PDB-paired v11.4186 binary +(`C:\Users\erikn\Downloads\acclient.exe`, `check_exe_pdb.py` → `MATCH`, +GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`): + +- `acclient!CPartArray::FindObjCollisions` @ **0x00518180** — 14 instructions: + a bare `do/while` over `parts[i]` calling `CPhysicsPart::find_obj_collisions` + and breaking on `!= OK`. No compare, no float math. +- `acclient!CPhysicsPart::find_obj_collisions` @ **0x0050d8d0** — 17 + instructions: null-check `gfxobj`, null-check `gfxobj->physics_bsp` + (`[ecx+78h]`), `SPHEREPATH::cache_localspace_sphere`, + `CGfxObj::find_obj_collisions` @ 0x00534700. No compare, no float math. + +`CPhysicsObj::FindObjCollisions` @ 0x0050f050 reaches that pair through +`CPartArray::FindObjCollisions` at 0x0050f192; its cylsphere/sphere loops +(0x0050f1c4 / 0x0050f251) call the real `intersects_sphere` per primitive — +they are tests, not pre-filters. Retail's only spatial rejection is the BSP +node bounding-sphere test inside the walk, which is correctly centred. + +The in-tree comment claiming the filter is "the analog of the part +sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions` — +response-neutral, pure perf" is **wrong on both counts.** + +--- + +## Proposed fix + +**Preferred — remove the per-object distance pre-check for BSP entries.** +Retail has none, and the BSP walk's own root node bounding-sphere test is the +correctly-centred early-out that makes it unnecessary. Size: delete ~10 lines +in `Transition.FindObjCollisionsInCell` plus the probe's `rejected-reach` +branch; correct the false retail-analog comment in the same commit. No +divergence-register row is created; if #333's row exists it is deleted. + +**Fallback if a perf gate demands a filter** — measure to the bounding-sphere +centre, the AP-156 correction applied at the query site: +`obj.Position + Vector3.Transform(BoundsCenter * Scale, obj.Rotation)`. +`ShadowShape.BoundsCenter` already carries this value; `ShadowEntry` does not, +so this variant also touches `ShadowEntry` and both registration paths +(`Register` and `RegisterMultiPart`). Larger, and it keeps a non-retail +construct that then needs a register row. + +Acceptance gate: un-skip +`Issue337NeftetRockGeometryInspectionTests.TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`. +Verified to fail today with the numbers above. + +--- + +## Separate observation, not part of this defect + +Setup `0x02000001` authors `StepUpHeight = 0.600` and +`StepDownHeight = 1.500`. The live `[support]` lines show the player resolving +with `stepUp=0.400 stepDown=0.400`. A 1.5 m step-down is what keeps a mover +attached to a descending slope; 0.4 m is not. Worth its own investigation — +it does not cause this wedge, and it was not chased here. diff --git a/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs new file mode 100644 index 00000000..0bc8d4d8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs @@ -0,0 +1,928 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Text; +using AcDream.Core.Physics; +using AcDream.Core.Tests.Conformance; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #337 offline replay (TEMPORARY — strip with the physics-probe family). +/// +/// +/// Reconstructs, from the installed DAT alone, the exact world placement of +/// every landblock-static collision owner in Neftet landblock 0x8766, and +/// asks the one question [geom] cannot answer: the [geom] probe +/// compares the physics-BSP vertex cloud against the visual mesh box in the +/// object's OWN LOCAL FRAME, so verdict=coincident proves only that the +/// two agree on SHAPE. It says nothing about WHERE the object sits in the +/// world. This test places the geometry in the world and measures. +/// +/// +/// +/// Live measurement being replayed (337-support.log / 334-fix-gate.log): +/// the player wedges at (134.314, 55.716, 50.011) in cell 0x8766002B, and +/// owner 0xC8766002 / gfx 0x010046DE was tested 11,014 times there with +/// 0 adjusted, 0 collided, 0 slid — while its two neighbours 0xC8766003 and +/// 0xC8766009 adjusted 767 and 1,135 times against the same mover. +/// +/// +/// Dat-data dependent; SKIPs cleanly without ACDREAM_DAT_DIR. +/// +public sealed class Issue337NeftetRockGeometryInspectionTests +{ + private const uint Landblock = 0x8766_0000u; + private const uint GfxObjMask = 0x01000000u; + private const uint SetupMask = 0x02000000u; + private const uint TypeMask = 0xFF000000u; + + /// The wedge point, landblock-local, from the live capture. + private static readonly Vector3 Wedge = new(134.313934f, 55.716248f, 50.010666f); + + private readonly ITestOutputHelper _out; + + public Issue337NeftetRockGeometryInspectionTests(ITestOutputHelper o) => _out = o; + + private sealed record Placed( + uint EntityId, + uint DatId, + uint GfxObjId, + Vector3 Position, + Quaternion Rotation, + List WorldPolygons, + List LocalPolygons, + FlatPhysicsBsp Bsp, + Vector3 WorldMin, + Vector3 WorldMax, + int BspNodes, + int BspPolys); + + [Fact] + public void DumpNeftetLandblockStaticsAndProbeTheWedgePoint() + { + System.Globalization.CultureInfo.CurrentCulture = + System.Globalization.CultureInfo.InvariantCulture; + + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + + var sb = new StringBuilder(); + sb.AppendLine(Inv( + $"landblock 0x{Landblock:X8}: Objects={info!.Objects.Count} Buildings={info.Buildings.Count}")); + + uint lbX = (Landblock >> 24) & 0xFFu; + uint lbY = (Landblock >> 16) & 0xFFu; + uint counter = 0; + + var cache = new PhysicsDataCache(); + var placed = new List(); + + foreach (var stab in info.Objects) + { + if (!IsSupported(stab.Id)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + placed.AddRange(Place(dats, cache, entityId, stab.Id, + stab.Frame.Origin, stab.Frame.Orientation)); + } + + foreach (var building in info.Buildings) + { + if (!IsSupported(building.ModelId)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + placed.AddRange(Place(dats, cache, entityId, building.ModelId, + building.Frame.Origin, building.Frame.Orientation)); + } + + sb.AppendLine($"placed collision parts: {placed.Count}"); + sb.AppendLine(); + sb.AppendLine("--- every part: world box, and the wedge point in ITS OWN local frame ---"); + foreach (var p in placed.OrderBy(p => p.EntityId).ThenBy(p => p.GfxObjId)) + { + Vector3 local = Vector3.Transform( + Wedge - p.Position, Quaternion.Inverse(p.Rotation)); + bool inWorldBox = Inside(p.WorldMin, p.WorldMax, Wedge); + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} dat=0x{p.DatId:X8} gfx=0x{p.GfxObjId:X8} " + + $"pos=({p.Position.X:F3},{p.Position.Y:F3},{p.Position.Z:F3}) " + + $"rot=({p.Rotation.W:F4},{p.Rotation.X:F4},{p.Rotation.Y:F4},{p.Rotation.Z:F4}) " + + $"yawDeg={YawDegrees(p.Rotation):F2} " + + $"nodes={p.BspNodes} polys={p.BspPolys} " + + $"worldMin=({p.WorldMin.X:F2},{p.WorldMin.Y:F2},{p.WorldMin.Z:F2}) " + + $"worldMax=({p.WorldMax.X:F2},{p.WorldMax.Y:F2},{p.WorldMax.Z:F2}) " + + $"wedgeLocal=({local.X:F2},{local.Y:F2},{local.Z:F2}) " + + $"wedgeInWorldBox={inWorldBox}")); + } + + sb.AppendLine(); + sb.AppendLine(Inv( + $"--- vertical column through the wedge XY ({Wedge.X:F3},{Wedge.Y:F3}) ---")); + sb.AppendLine("(every physics polygon whose WORLD XY projection contains that point,"); + sb.AppendLine(" with the Z of the polygon's plane at that XY — this is the surface"); + sb.AppendLine(" a falling body would land on and the ceiling it would be under)"); + + var hits = new List<(uint Ent, uint Gfx, float Z, float Nz, int PolyIndex)>(); + foreach (var p in placed) + { + for (int i = 0; i < p.WorldPolygons.Count; i++) + { + Vector3[] poly = p.WorldPolygons[i]; + if (!XyContains(poly, Wedge.X, Wedge.Y)) continue; + if (!TryPlaneZ(poly, Wedge.X, Wedge.Y, out float z, out float nz)) continue; + hits.Add((p.EntityId, p.GfxObjId, z, nz, i)); + } + } + + foreach (var h in hits.OrderByDescending(h => h.Z)) + { + sb.AppendLine(Inv( + $" z={h.Z,9:F3} normalZ={h.Nz,7:F4} ent=0x{h.Ent:X8} gfx=0x{h.Gfx:X8} poly#{h.PolyIndex}")); + } + if (hits.Count == 0) + sb.AppendLine(" (no physics polygon covers that XY at all)"); + + sb.AppendLine(); + sb.AppendLine(Inv( + $"player sphere centre is at z={Wedge.Z:F3}")); + var above = hits.Where(h => h.Z > Wedge.Z).OrderBy(h => h.Z).ToList(); + var below = hits.Where(h => h.Z <= Wedge.Z).OrderByDescending(h => h.Z).ToList(); + sb.AppendLine(Inv( + $" nearest surface BELOW: {(below.Count == 0 ? "none" : $"z={below[0].Z:F3} ent=0x{below[0].Ent:X8} gfx=0x{below[0].Gfx:X8} (gap {Wedge.Z - below[0].Z:F3} m)")}")); + sb.AppendLine(Inv( + $" nearest surface ABOVE: {(above.Count == 0 ? "none" : $"z={above[0].Z:F3} ent=0x{above[0].Ent:X8} gfx=0x{above[0].Gfx:X8} (gap {above[0].Z - Wedge.Z:F3} m)")}")); + + sb.AppendLine(); + sb.AppendLine("--- nearest physics polygon to the wedge point, per owner ---"); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + float best = float.MaxValue; + int bestIndex = -1; + for (int i = 0; i < p.WorldPolygons.Count; i++) + { + float d = DistanceToPolygon(p.WorldPolygons[i], Wedge); + if (d < best) { best = d; bestIndex = i; } + } + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} nearestPolyDist={best:F3} m (poly#{bestIndex})")); + } + + // ------------------------------------------------------------------ + // REFEREE. Two independent answers to the SAME question, per owner: + // (a) the production BSP walk, FlatBspQuery.SphereIntersectsPoly — + // the Path-5 static-overlap primitive the resolver actually runs, + // node bounding-sphere early-outs and all; + // (b) brute force over every polygon the tree indexes. + // They must agree. Where (b) says "a polygon is inside the sphere" and + // (a) says "no", the geometry is present and the TRAVERSAL cannot reach + // it — a bounded pocket, which is exactly the reported symptom. Where + // both say no, the mesh genuinely is not there and the owner is + // innocent. + // + // Sample points are taken ON the mesh (vertices + centroids, nudged + // both ways along the polygon normal) so the referee covers the whole + // surface rather than an arbitrary lattice, and any disagreement + // localises to a named polygon. + // ------------------------------------------------------------------ + const float SphereRadius = 0.48f; // the live mover's radius + sb.AppendLine(); + sb.AppendLine(Inv( + $"--- referee: BSP walk vs brute force, sphere r={SphereRadius:F3} ---")); + + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + int samples = 0, mismatch = 0, bothHit = 0; + var mmMin = new Vector3(float.PositiveInfinity); + var mmMax = new Vector3(float.NegativeInfinity); + var firstMismatches = new List(); + + foreach (Vector3 probe in SurfaceProbes(p.LocalPolygons, SphereRadius)) + { + samples++; + bool walk = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, probe, SphereRadius, out _, out _); + bool brute = BruteHit(p.LocalPolygons, probe, SphereRadius); + if (walk == brute) { if (walk) bothHit++; continue; } + mismatch++; + Vector3 world = p.Position + Vector3.Transform(probe, p.Rotation); + mmMin = Vector3.Min(mmMin, world); + mmMax = Vector3.Max(mmMax, world); + if (firstMismatches.Count < 8) + { + firstMismatches.Add(Inv( + $" local=({probe.X:F2},{probe.Y:F2},{probe.Z:F2}) " + + $"world=({world.X:F2},{world.Y:F2},{world.Z:F2}) " + + $"walk={walk} brute={brute}")); + } + } + + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} polys={p.BspPolys} " + + $"samples={samples} bothHit={bothHit} MISMATCH={mismatch}")); + if (mismatch > 0) + { + sb.AppendLine(Inv( + $" mismatch world box=({mmMin.X:F2},{mmMin.Y:F2},{mmMin.Z:F2})..({mmMax.X:F2},{mmMax.Y:F2},{mmMax.Z:F2})")); + foreach (string line in firstMismatches) sb.AppendLine(line); + } + } + + // ------------------------------------------------------------------ + // The exact live query, replayed. The wedge point, plus every point on + // a 1 m lattice through the plateau, both answers side by side. + // ------------------------------------------------------------------ + sb.AppendLine(); + sb.AppendLine("--- the live wedge query, replayed per owner ---"); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + Vector3 local = Vector3.Transform( + Wedge - p.Position, Quaternion.Inverse(p.Rotation)); + bool walk = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, local, SphereRadius, out ushort id, out _); + bool brute = BruteHit(p.LocalPolygons, local, SphereRadius); + float nearest = float.MaxValue; + foreach (var poly in p.LocalPolygons) + nearest = MathF.Min(nearest, DistanceToPolygon(poly, local)); + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} " + + $"walkHit={walk} (poly {id}) bruteHit={brute} nearestPoly={nearest:F3} m")); + } + + sb.AppendLine(); + sb.AppendLine("--- 1 m lattice over the plateau (x 120..150, y 40..70, z 44..56) ---"); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + int lat = 0, latWalk = 0, latBrute = 0, latMismatch = 0; + var missMin = new Vector3(float.PositiveInfinity); + var missMax = new Vector3(float.NegativeInfinity); + for (float x = 120f; x <= 150f; x += 1f) + for (float y = 40f; y <= 70f; y += 1f) + for (float z = 44f; z <= 56f; z += 1f) + { + var world = new Vector3(x, y, z); + Vector3 local = Vector3.Transform( + world - p.Position, Quaternion.Inverse(p.Rotation)); + lat++; + bool walk = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, local, SphereRadius, out _, out _); + bool brute = BruteHit(p.LocalPolygons, local, SphereRadius); + if (walk) latWalk++; + if (brute) latBrute++; + if (walk == brute) continue; + latMismatch++; + missMin = Vector3.Min(missMin, world); + missMax = Vector3.Max(missMax, world); + } + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} points={lat} " + + $"walkHits={latWalk} bruteHits={latBrute} MISMATCH={latMismatch}" + + (latMismatch > 0 + ? $" box=({missMin.X:F1},{missMin.Y:F1},{missMin.Z:F1})..({missMax.X:F1},{missMax.Y:F1},{missMax.Z:F1})" + : string.Empty))); + } + + // ------------------------------------------------------------------ + // The recorded live track, replayed. Each row is a position the client + // actually held, taken from 337-support.log; the Y values on the climb + // rows carry the +576 m live-centre offset that log was written in + // (live centre 0x8763, three landblocks south of 0x8766), removed here. + // For each, the walkable surface directly above/below it. + // ------------------------------------------------------------------ + sb.AppendLine(); + sb.AppendLine("--- recorded live positions vs the surface at their own XY ---"); + (string Tag, Vector3 P, float CpNz)[] track = + [ + ("climb cpNz=0.7401", new Vector3(185.53f, 617.52f - 576f, 3.839f), 0.7401f), + ("climb cpNz=0.7078", new Vector3(172.63f, 600.23f - 576f, 17.910f), 0.7078f), + ("climb cpNz=0.8640", new Vector3(154.04f, 596.93f - 576f, 32.543f), 0.8640f), + ("climb cpNz=0.8216", new Vector3(143.69f, 611.83f - 576f, 43.352f), 0.8216f), + ("climb cpNz=0.9532", new Vector3(138.38f, 621.53f - 576f, 48.567f), 0.9532f), + ("STALL cpNz=0.9805", new Vector3(130.81f, 625.88f - 576f, 51.097f), 0.9805f), + ("stand cpNz=0.9532", new Vector3(134.08f, 623.63f - 576f, 50.074f), 0.9532f), + ("stand cpNz=0.9532", new Vector3(135.77f, 625.86f - 576f, 49.994f), 0.9532f), + ("stand cpNz=1.0000", new Vector3(131.38f, 627.49f - 576f, 49.908f), 1.0000f), + ("STALL cpNz=0.9805", new Vector3(131.68f, 627.58f - 576f, 51.096f), 0.9805f), + ("WEDGE support=none", Wedge, float.NaN), + ]; + + foreach (var row in track) + { + var col = Column(placed, row.P.X, row.P.Y); + var up = col.Where(c => c.Nz > 0f).OrderByDescending(c => c.Z).ToList(); + var upBelowHead = up.Where(c => c.Z <= row.P.Z + 0.05f).ToList(); + string surf = up.Count == 0 + ? "NO UPWARD SURFACE IN COLUMN" + : Inv($"topUp z={up[0].Z:F3} (ent=0x{up[0].Ent:X8}) delta={row.P.Z - up[0].Z:+0.000;-0.000}"); + sb.AppendLine(Inv( + $" {row.Tag} p=({row.P.X:F2},{row.P.Y:F2},{row.P.Z:F3}) " + + $"colPolys={col.Count} upFacing={up.Count} atOrBelowFeet={upBelowHead.Count} {surf}")); + } + + // ------------------------------------------------------------------ + // HOLE MAP. Over the plateau, at 0.5 m XY resolution, does ANY + // upward-facing physics polygon exist above z=35? A bounded region + // with none is a hole in the rock's walking surface — a body that + // reaches it stops being supported, sinks, and a corpse dropped on it + // falls through, while a jump that clears it lands fine on the far + // side. That is the reported symptom exactly. + // ------------------------------------------------------------------ + sb.AppendLine(); + sb.AppendLine("--- hole map: upward-facing physics coverage over the plateau ---"); + sb.AppendLine(" (0.5 m XY grid, x 118..152, y 38..72; '.'=covered above z=35, " + + "'#'=NO upward surface, '*'=the wedge XY)"); + int covered = 0, holes = 0; + var holeMin = new Vector2(float.PositiveInfinity); + var holeMax = new Vector2(float.NegativeInfinity); + for (float y = 72f; y >= 38f; y -= 0.5f) + { + var line = new StringBuilder(Inv($" y={y,6:F1} ")); + for (float x = 118f; x <= 152f; x += 0.5f) + { + var col = Column(placed, x, y); + bool hasUp = col.Any(c => c.Nz > 0f && c.Z > 35f); + bool isWedge = MathF.Abs(x - Wedge.X) < 0.25f + && MathF.Abs(y - Wedge.Y) < 0.25f; + if (hasUp) covered++; + else + { + holes++; + holeMin = Vector2.Min(holeMin, new Vector2(x, y)); + holeMax = Vector2.Max(holeMax, new Vector2(x, y)); + } + line.Append(isWedge ? '*' : hasUp ? '.' : '#'); + } + sb.AppendLine(line.ToString()); + } + sb.AppendLine(Inv( + $" covered={covered} holes={holes}" + + (holes > 0 + ? $" holeBox=({holeMin.X:F1},{holeMin.Y:F1})..({holeMax.X:F1},{holeMax.Y:F1})" + : string.Empty))); + + string outPath = Path.Combine(Path.GetTempPath(), "issue337-neftet-geometry.txt"); + File.WriteAllText(outPath, sb.ToString()); + _out.WriteLine(sb.ToString()); + _out.WriteLine($"(also written to {outPath})"); + } + + // ---------------------------------------------------------------- helpers + + /// + /// #337 descent replay. The live capture shows the player standing on the + /// plateau at feet z=51.096 (surface ~51.08), jumping to escape a + /// horizontal stall, and then falling STRAIGHT THROUGH that same surface — + /// four consecutive resolves accept a full-length downward step with no + /// contact plane, ending 1.1 m inside solid rock. This replays those exact + /// steps against the exact mesh, with a control step from the climb where + /// the client DID land correctly on the same mesh. + /// + [Fact] + public void ReplayTheDescentThatFellThroughTheSurface() + { + System.Globalization.CultureInfo.CurrentCulture = + System.Globalization.CultureInfo.InvariantCulture; + + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + + uint lbX = (Landblock >> 24) & 0xFFu; + uint lbY = (Landblock >> 16) & 0xFFu; + uint counter = 0; + var cache = new PhysicsDataCache(); + var placed = new List(); + foreach (var stab in info!.Objects) + { + if (!IsSupported(stab.Id)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + placed.AddRange(Place(dats, cache, entityId, stab.Id, + stab.Frame.Origin, stab.Frame.Orientation)); + } + + var sb = new StringBuilder(); + + // The player's verbatim dat sphere list — the same rows + // PhysicsEngine hands to SpherePath.InitPath (TS-46). + var playerSetup = dats.Get(0x0200_0001u); + Assert.NotNull(playerSetup); + FlatSetupCollision playerFlat = + FlatCollisionAssetBuilder.FlattenSetup(playerSetup!); + sb.AppendLine("--- player setup 0x02000001 collision volume ---"); + sb.AppendLine(Inv( + $" height={playerFlat.Height:F3} radius={playerFlat.Radius:F3} " + + $"stepUp={playerFlat.StepUpHeight:F3} stepDown={playerFlat.StepDownHeight:F3} " + + $"spheres={playerFlat.Spheres.Length} cylinders={playerFlat.Cylinders.Length}")); + for (int i = 0; i < playerFlat.Spheres.Length; i++) + { + var s = playerFlat.Spheres[i]; + sb.AppendLine(Inv( + $" sphere[{i}] origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}")); + } + for (int i = 0; i < playerFlat.Cylinders.Length; i++) + { + var c = playerFlat.Cylinders[i]; + sb.AppendLine(Inv( + $" cylsphere[{i}] origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}")); + } + + // Sphere rows actually used. The live reach probe recorded + // sphereR=0.480, so this asserts the replay is using the same volume + // the client used rather than a lookalike. + var rows = playerFlat.Spheres.Length > 0 + ? playerFlat.Spheres.ToArray() + : playerFlat.Cylinders + .Select(c => new FlatCollisionSphere(c.Origin, c.Radius)) + .ToArray(); + + sb.AppendLine(); + sb.AppendLine("--- surface column at the fall XY ---"); + foreach ((string tag, float x, float y) in new[] + { + ("stand-before-jump", 131.683f, 627.581f - 576f), + ("fall/landing ", 131.13f, 627.61f - 576f), + ("final wedge ", 131.38f, 627.49f - 576f), + ("login wedge ", Wedge.X, Wedge.Y), + }) + { + var col = Column(placed, x, y).OrderByDescending(c => c.Z).ToList(); + sb.AppendLine(Inv($" {tag} xy=({x:F3},{y:F3}) polys={col.Count}")); + foreach (var c in col) + { + sb.AppendLine(Inv( + $" z={c.Z,9:F3} nz={c.Nz,8:F4} ent=0x{c.Ent:X8} gfx=0x{c.Gfx:X8}")); + } + } + + // The steps, feet-space, straight from 337-support.log (Y carries the + // +576 m live-centre offset that log was written in). + (string Tag, Vector3 From, Vector3 To, bool ClientHit)[] steps = + [ + // Control: the climb. The client accepted these with a contact + // plane from StepSphereDown, i.e. it DID land on this mesh. + ("CONTROL climb step", new Vector3(143.69f, 611.83f - 576f, 43.352f), + new Vector3(143.69f, 611.83f - 576f, 42.852f), true), + ("CONTROL stand step", new Vector3(135.77f, 625.86f - 576f, 49.994f), + new Vector3(135.77f, 625.86f - 576f, 49.494f), true), + // The failing descent, verbatim, four consecutive resolves. + ("FALL 1 51.389->51.269", new Vector3(131.13f, 627.61f - 576f, 51.389f), + new Vector3(131.13f, 627.61f - 576f, 51.269f), false), + ("FALL 2 51.269->50.961", new Vector3(131.13f, 627.61f - 576f, 51.269f), + new Vector3(131.13f, 627.61f - 576f, 50.961f), false), + ("FALL 3 50.961->50.670", new Vector3(131.13f, 627.61f - 576f, 50.961f), + new Vector3(131.13f, 627.61f - 576f, 50.670f), false), + ("FALL 4 50.670->50.335", new Vector3(131.13f, 627.61f - 576f, 50.670f), + new Vector3(131.13f, 627.61f - 576f, 50.335f), false), + ("FALL 5 50.335->50.021", new Vector3(131.13f, 627.61f - 576f, 50.335f), + new Vector3(131.13f, 627.61f - 576f, 50.021f), false), + ("FALL 6 50.021->49.656 (client BLOCKED here)", + new Vector3(131.13f, 627.61f - 576f, 50.021f), + new Vector3(131.13f, 627.61f - 576f, 49.656f), false), + ]; + + sb.AppendLine(); + sb.AppendLine("--- swept-sphere descent replay against every owner ---"); + foreach (var step in steps) + { + sb.AppendLine(Inv( + $" {step.Tag} (client recorded a contact plane: {step.ClientHit})")); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + var invRot = Quaternion.Inverse(p.Rotation); + for (int i = 0; i < rows.Length; i++) + { + Vector3 worldFrom = step.From + rows[i].Origin; + Vector3 worldTo = step.To + rows[i].Origin; + Vector3 localTo = Vector3.Transform(worldTo - p.Position, invRot); + Vector3 localFrom = Vector3.Transform(worldFrom - p.Position, invRot); + Vector3 localMove = localTo - localFrom; + + bool swept = FlatBspQuery.SphereIntersectsPolyWithTime( + p.Bsp, localTo, rows[i].Radius, localMove, + out ushort sweptId, out _, out float sweptTime); + bool stat = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, localTo, rows[i].Radius, out ushort statId, out _); + float nearest = float.MaxValue; + foreach (var poly in p.LocalPolygons) + nearest = MathF.Min(nearest, DistanceToPolygon(poly, localTo)); + if (!swept && !stat && nearest > 3f) continue; // far away, silent + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} sphere[{i}] r={rows[i].Radius:F3} " + + $"swept={swept}(poly {sweptId} t={sweptTime:F4}) " + + $"static={stat}(poly {statId}) nearestPoly={nearest:F3} m")); + } + } + } + + string outPath = Path.Combine(Path.GetTempPath(), "issue337-descent-replay.txt"); + File.WriteAllText(outPath, sb.ToString()); + _out.WriteLine(sb.ToString()); + _out.WriteLine($"(also written to {outPath})"); + } + + /// + /// #337 REPRODUCER. At the position the client fell through the plateau, + /// the BSP query returns a hit — the geometry is present, correctly + /// placed, and reachable by the production traversal. This asserts that + /// load-bearing fact, which must hold both before and after any fix; if it + /// ever stops holding, the diagnosis in + /// docs/research/2026-08-06-337-neftet-wedge-mechanism.md is void. + /// + [Fact] + public void TheBspQueryReturnsAHitAtThePositionTheClientFellThrough() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; // CI without dats — the sibling dumps skip too. + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + + Placed rock = ResolveOwner(dats, info!, 0xC876_6009u); + + // Feet position and per-sphere layout, verbatim from the live capture + // (337-support.log t=303221015..303221125) and Setup 0x02000001. + var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f); + var footSphereOffset = new Vector3(0f, 0f, 0.475f); + const float SphereRadius = 0.48f; + + Vector3 local = Vector3.Transform( + feet + footSphereOffset - rock.Position, + Quaternion.Inverse(rock.Rotation)); + + bool hit = FlatBspQuery.SphereIntersectsPoly( + rock.Bsp, local, SphereRadius, out ushort polygonId, out _); + + Assert.True( + hit, + "The rock's physics BSP must report the plateau surface under the " + + "mover at the position the live client fell through it. If this " + + "fails, the defect is in the geometry or the traversal after all."); + Assert.NotEqual(0, polygonId); + } + + /// + /// #337 REPRODUCER — currently FAILING, hence skipped. + /// + /// + /// The per-object broadphase in + /// Transition.FindObjCollisionsInCell (TransitionTypes.cs, the + /// maxReach test) measures the mover's distance to the shadow + /// entry's Position — the part ORIGIN — and compares it against + /// obj.Radius, which is the physics-BSP ROOT BOUNDING SPHERE's + /// radius. For this rock those two are 23.6 m apart, so a mover standing + /// on its plateau is inside the bounding sphere by ~20 m of margin and + /// still fails the test. It is the same defect AP-156 fixed in the flood + /// and #334 fixed in the registration extent walk, left in place at the + /// query site (filed as #333). + /// + /// + /// + /// Retail has no such filter. CPartArray::FindObjCollisions + /// @0x00518180 is a bare loop over parts and + /// CPhysicsPart::find_obj_collisions @0x0050d8d0 does two null + /// checks and calls CGfxObj::find_obj_collisions @0x00534700 — + /// verified instruction-by-instruction against the PDB-paired + /// v11.4186 binary. Neither contains a compare or any float math. The + /// only spatial rejection retail performs is the BSP node bounding-sphere + /// test inside the walk, which is correctly centred. + /// + /// + /// Un-skip this as the acceptance gate for the fix. + /// + [Fact(Skip = "#337: fails until the query-site broadphase measures to the " + + "BSP bounding-sphere centre (or is removed, as retail has none).")] + public void TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + Placed rock = ResolveOwner(dats, info!, 0xC876_6009u); + + var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f); + Vector3 currPos = feet + new Vector3(0f, 0f, 0.475f); + const float SphereRadius = 0.48f; + const float Movement = 0.308f; // the live step length + + float ownerRadius = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Radius; + + // Verbatim from the production predicate. + float distToOrigin = (currPos - rock.Position).Length(); + float maxReach = SphereRadius + ownerRadius + Movement + 2f; + + Assert.True( + distToOrigin <= maxReach, + $"broadphase rejected a candidate the mover is standing on: " + + $"distToOrigin={distToOrigin:F3} > maxReach={maxReach:F3}. " + + $"Measured to the BSP bounding-sphere CENTRE it is " + + $"{(currPos - (rock.Position + Vector3.Transform(rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Origin, rock.Rotation))).Length():F3} m, " + + $"comfortably inside the same radius."); + } + + private static Placed ResolveOwner( + DatCollection dats, LandBlockInfo info, uint wantedEntityId) + { + uint lbX = (Landblock >> 24) & 0xFFu; + uint lbY = (Landblock >> 16) & 0xFFu; + uint counter = 0; + var cache = new PhysicsDataCache(); + foreach (var stab in info.Objects) + { + if (!IsSupported(stab.Id)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + if (entityId != wantedEntityId) continue; + var parts = Place(dats, cache, entityId, stab.Id, + stab.Frame.Origin, stab.Frame.Orientation).ToList(); + Assert.Single(parts); + return parts[0]; + } + throw new InvalidOperationException( + $"landblock static 0x{wantedEntityId:X8} not found."); + } + + /// + /// Pass-through. Concatenated interpolated fragments cannot bind to + /// , so the test method pins + /// CurrentCulture to invariant instead and this marks the call + /// sites that depend on it. + /// + private static string Inv(string s) => s; + + private static uint LandblockStaticEntityIdAllocatorAllocate( + uint lbX, uint lbY, ref uint counter) + => LandblockStaticEntityIdAllocator.Allocate(lbX, lbY, ref counter); + + private static bool IsSupported(uint id) + { + uint type = id & TypeMask; + return type == GfxObjMask || type == SetupMask; + } + + private static IEnumerable Place( + DatCollection dats, + PhysicsDataCache cache, + uint entityId, + uint datId, + Vector3 origin, + Quaternion orientation) + { + var parts = new List<(uint GfxObjId, Vector3 LocalPos, Quaternion LocalRot)>(); + + if ((datId & TypeMask) == GfxObjMask) + { + parts.Add((datId, Vector3.Zero, Quaternion.Identity)); + } + else + { + var setup = dats.Get(datId); + if (setup is null) yield break; + for (int i = 0; i < setup.Parts.Count; i++) + { + Vector3 lp = Vector3.Zero; + Quaternion lr = Quaternion.Identity; + if (!setup.PlacementFrames.TryGetValue( + DatReaderWriter.Enums.Placement.Resting, out var pf) + && !setup.PlacementFrames.TryGetValue( + DatReaderWriter.Enums.Placement.Default, out pf)) + { + pf = setup.PlacementFrames.Values.FirstOrDefault(); + } + if (pf?.Frames is not null && i < pf.Frames.Count) + { + lp = pf.Frames[i].Origin; + lr = pf.Frames[i].Orientation; + } + parts.Add((setup.Parts[i], lp, lr)); + } + } + + foreach (var part in parts) + { + var gfx = dats.Get(part.GfxObjId); + if (gfx is null) continue; + cache.CacheGfxObj(part.GfxObjId, gfx); + FlatGfxObjCollisionAsset flat = + FlatCollisionAssetBuilder.FlattenGfxObj(gfx); + FlatPhysicsBsp bsp = flat.PhysicsBsp; + if (bsp.RootIndex < 0 || bsp.Nodes.Length == 0) continue; + + Vector3 partWorldPos = origin + Vector3.Transform(part.LocalPos, orientation); + Quaternion partWorldRot = orientation * part.LocalRot; + + var seen = new HashSet(); + var worldPolys = new List(); + var localPolys = new List(); + var min = new Vector3(float.PositiveInfinity); + var max = new Vector3(float.NegativeInfinity); + + foreach (var node in bsp.Nodes) + { + var range = node.PolygonIndexRange; + for (int i = range.Start; i < range.EndExclusive; i++) + { + int pi = bsp.PolygonIndexStream[i]; + if ((uint)pi >= (uint)bsp.PolygonTable.Polygons.Length) continue; + if (!seen.Add(pi)) continue; + var poly = bsp.PolygonTable.Polygons[pi]; + var vr = poly.VertexRange; + var verts = new Vector3[vr.Count]; + var lverts = new Vector3[vr.Count]; + for (int v = 0; v < vr.Count; v++) + { + Vector3 local = bsp.PolygonTable.Vertices[vr.Start + v]; + Vector3 world = partWorldPos + + Vector3.Transform(local, partWorldRot); + lverts[v] = local; + verts[v] = world; + min = Vector3.Min(min, world); + max = Vector3.Max(max, world); + } + worldPolys.Add(verts); + localPolys.Add(lverts); + } + } + + if (worldPolys.Count == 0) continue; + + yield return new Placed( + entityId, datId, part.GfxObjId, partWorldPos, partWorldRot, + worldPolys, localPolys, bsp, min, max, + bsp.Nodes.Length, worldPolys.Count); + } + } + + /// + /// Points just off the mesh surface, both sides, at every vertex and + /// centroid. A sphere centred here demonstrably contains mesh, so the + /// production walk must report a hit. + /// + private static IEnumerable SurfaceProbes( + List polygons, float radius) + { + float nudge = radius * 0.5f; + foreach (Vector3[] poly in polygons) + { + if (poly.Length < 3) continue; + Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]); + float len = n.Length(); + if (len < 1e-9f) continue; + n /= len; + + Vector3 centroid = Vector3.Zero; + foreach (Vector3 v in poly) centroid += v; + centroid /= poly.Length; + + yield return centroid + n * nudge; + yield return centroid - n * nudge; + foreach (Vector3 v in poly) + { + // Pull slightly toward the centroid so a vertex probe sits on + // the polygon rather than exactly on its corner. + Vector3 inset = Vector3.Lerp(v, centroid, 0.15f); + yield return inset + n * nudge; + yield return inset - n * nudge; + } + } + } + + private static bool BruteHit(List polygons, Vector3 centre, float radius) + { + foreach (Vector3[] poly in polygons) + { + if (DistanceToPolygon(poly, centre) < radius - 1e-4f) + return true; + } + return false; + } + + /// + /// Every physics polygon whose world XY projection contains + /// (, ), with the Z of its plane + /// at that XY and the Z component of its normal. + /// + private static List<(uint Ent, uint Gfx, float Z, float Nz)> Column( + List placed, float x, float y) + { + var result = new List<(uint, uint, float, float)>(); + foreach (var p in placed) + { + if (x < p.WorldMin.X || x > p.WorldMax.X + || y < p.WorldMin.Y || y > p.WorldMax.Y) continue; + foreach (Vector3[] poly in p.WorldPolygons) + { + if (!XyContains(poly, x, y)) continue; + if (!TryPlaneZ(poly, x, y, out float z, out float nz)) continue; + result.Add((p.EntityId, p.GfxObjId, z, nz)); + } + } + return result; + } + + private static bool Inside(Vector3 min, Vector3 max, Vector3 p) + => p.X >= min.X && p.X <= max.X + && p.Y >= min.Y && p.Y <= max.Y + && p.Z >= min.Z && p.Z <= max.Z; + + private static float YawDegrees(Quaternion q) + { + // Rotation of +X about Z only; indicative for the mostly-Z-only + // orientations landblock stabs carry. + Vector3 x = Vector3.Transform(Vector3.UnitX, q); + return MathF.Atan2(x.Y, x.X) * 180f / MathF.PI; + } + + private static bool XyContains(Vector3[] poly, float x, float y) + { + bool inside = false; + for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++) + { + float yi = poly[i].Y, yj = poly[j].Y; + if ((yi > y) == (yj > y)) continue; + float t = (y - yi) / (yj - yi); + float xAt = poly[i].X + t * (poly[j].X - poly[i].X); + if (x < xAt) inside = !inside; + } + return inside; + } + + private static bool TryPlaneZ(Vector3[] poly, float x, float y, out float z, out float nz) + { + z = 0f; nz = 0f; + if (poly.Length < 3) return false; + Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]); + float len = n.Length(); + if (len < 1e-9f) return false; + n /= len; + if (MathF.Abs(n.Z) < 1e-6f) return false; + nz = n.Z; + // n · (P - poly0) = 0 -> z = poly0.z - (n.x*(x-p0.x) + n.y*(y-p0.y)) / n.z + z = poly[0].Z - (n.X * (x - poly[0].X) + n.Y * (y - poly[0].Y)) / n.Z; + return true; + } + + private static float DistanceToPolygon(Vector3[] poly, Vector3 p) + { + float best = float.MaxValue; + for (int i = 2; i < poly.Length; i++) + best = MathF.Min(best, DistanceToTriangle(poly[0], poly[i - 1], poly[i], p)); + if (poly.Length == 2) + best = MathF.Min(best, DistanceToSegment(poly[0], poly[1], p)); + return best; + } + + private static float DistanceToTriangle(Vector3 a, Vector3 b, Vector3 c, Vector3 p) + { + Vector3 ab = b - a, ac = c - a, ap = p - a; + float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap); + if (d1 <= 0 && d2 <= 0) return Vector3.Distance(p, a); + Vector3 bp = p - b; + float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp); + if (d3 >= 0 && d4 <= d3) return Vector3.Distance(p, b); + float vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) + return Vector3.Distance(p, a + ab * (d1 / (d1 - d3))); + Vector3 cp = p - c; + float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp); + if (d6 >= 0 && d5 <= d6) return Vector3.Distance(p, c); + float vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) + return Vector3.Distance(p, a + ac * (d2 / (d2 - d6))); + float va = d3 * d6 - d5 * d4; + if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) + return Vector3.Distance(p, b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)))); + float denom = 1f / (va + vb + vc); + return Vector3.Distance(p, a + ab * (vb * denom) + ac * (vc * denom)); + } + + private static float DistanceToSegment(Vector3 a, Vector3 b, Vector3 p) + { + Vector3 ab = b - a; + float t = Vector3.Dot(p - a, ab) / MathF.Max(1e-9f, Vector3.Dot(ab, ab)); + t = Math.Clamp(t, 0f, 1f); + return Vector3.Distance(p, a + ab * t); + } +} From ea83b043dfaaf96cf579ae614154c5f37f59c701 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 22:16:53 +0200 Subject: [PATCH 6/9] fix(physics): delete the query-site broadphase reach filter (#333, closing #337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transition.FindObjCollisionsInCell discarded a shadow candidate when |currPos - obj.Position| > sphereRadius + obj.Radius + movement.Length() + 2f obj.Position is the part ORIGIN; obj.Radius is the physics-BSP ROOT BOUNDING SPHERE's radius, measured about a centre AP-156 established is frequently metres from that origin (376 of 973 installed physics-BSP parts sit further from their part origin than half their own radius, worst 20.762 m). Geometry deep inside the real bounding sphere was therefore thrown away before BSPQuery ever ran: solid near the origin, permeable in a bounded shell beyond it. For the Neftet rock 0xC8766009 / gfx=0x01004751 the two points are 23.556 m apart, which is #337 — wedged on the plateau, jumps sinking into the mesh, corpses falling through. A live capture recorded 7,225 rejections on that one owner, every single one with wouldAcceptAtCenter=True. Deleted rather than re-centred. Retail has no distance pre-filter, disassembled from the PDB-paired v11.4186 binary (CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from Binary Ninja: CObjCell::find_obj_collisions @0x0052b750 walks shadow_object_list and calls CPhysicsObj::FindObjCollisions (0x0052b78b) UNCONDITIONALLY; its only early-out is insert_type == INITIAL_PLACEMENT_INSERT (0x0052b759). CPhysicsObj::FindObjCollisions @0x0050f050 contains no float compare at all. CPartArray::FindObjCollisions @0x00518180 is a bare do/while over parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null checks plus a call. Retail's only spatial rejection is the BSP node bounding-sphere test inside the walk — correctly centred, which is exactly what the deleted filter was not. Re-centring it (carry BoundsCenter on ShadowEntry) would have preserved an invention retail does not have, including a +2f slack and a movement.Length() term with no retail counterpart, and left a second reach budget to be tuned forever. Retail's own cross-cell slack constant is F_EPSILON = 0.0002 m, not 2 m. The method's comment claimed the filter was "the analog of the part sorting-sphere early-outs inside retail's CPhysicsObj::FindObjCollisions — response-neutral, pure perf". Both halves were false and cost #333 and #337; it is replaced by the disassembly above. Gate: Issue333BroadphaseReachFilterTests drives the production path end-to-end (ResolveWithTransition -> FindObjCollisionsInCell -> CollisionTraversal) on a DAT-free fixture so it runs everywhere, as a discriminating pair. Sabotage-verified: restore the pre-check and OffCentreBspFloorStopsAFallingMover reaches z=37.800 — exactly the unobstructed fall, blockedAtLeastOnce=False — while CentredBspFloorStopsAFallingMover keeps passing. Without the control a fixture unable to fall would pass the first test for the wrong reason. Issue337's skipped TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn asserted the now-deleted predicate and could never have gone green; it is rewritten as installed-DAT evidence pinning BOTH halves of the diagnosis and is no longer skipped. Perf measured, not assumed (Release, synthetic all-BSP cell, per ResolveWithTransition): at 38 candidates — the live maximum — 10.61 us -> 16.68 us (1.57x); at a deliberately unreachable 200, 17.34 -> 39.48 us (2.28x); ~0.16 us per additional candidate tested. Over 19,701 live [reach-q] samples the in-cell count is p50 = 9, p99 = 32, max 38. The ACDREAM_PROBE_REACH rejectedReach column is kept and is now structurally 0, so a post-fix capture stays comparable with the pre-fix one; dropping it would make the two incomparable. AP-158 retired (110 active AP rows). #333 and #337 closed pending the user's live acceptance at Neftet. Solution suite 11,231 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 66 ++++- .../retail-divergence-register.md | 4 +- .../2026-08-06-337-neftet-wedge-mechanism.md | 83 ++++-- .../Physics/PhysicsDiagnostics.cs | 27 +- src/AcDream.Core/Physics/TransitionTypes.cs | 79 ++++-- .../Issue333BroadphaseReachFilterTests.cs | 240 ++++++++++++++++++ ...sue337NeftetRockGeometryInspectionTests.cs | 64 +++-- 7 files changed, 478 insertions(+), 85 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9311bf3d..5de3df89 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,10 +24,16 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — DIAGNOSED, fix not landed +## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance -**Status:** OPEN — **mechanism proven offline 2026-08-06**, fix proposed and -awaiting approval. It is **#333**: the per-object broadphase in +**Status:** FIXED 2026-08-06 by #333's fix — the query-site broadphase reach +filter is **deleted**, because retail has none. Awaiting the user's live +acceptance at the Neftet plateau; the offline gate is +`Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover`, +sabotage-verified (restore the filter and it falls straight through to the +unobstructed height while the centred control keeps passing). + +The mechanism, proven offline 2026-08-06, is **#333**: the per-object broadphase in `Transition.FindObjCollisionsInCell` measures to the shadow entry's part ORIGIN and compares against the BSP ROOT BOUNDING SPHERE's radius. Those are 23.6 m apart for `0xC8766009` / `gfx=0x01004751`, so a mover on the plateau is @@ -39,9 +45,13 @@ instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014 Full evidence + the proposed fix: [`docs/research/2026-08-06-337-neftet-wedge-mechanism.md`](research/2026-08-06-337-neftet-wedge-mechanism.md). Reproducer + offline replay: -`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs` -(the acceptance gate is the skipped -`TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`). +`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs`. +Its installed-DAT evidence row is +`TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn`, which pins +BOTH halves of the diagnosis for this rock — origin-measured distance outside +the old budget, centre-measured distance comfortably inside the same radius. +The production gate is the separate DAT-free +`Issue333BroadphaseReachFilterTests`. **Historical framing below is superseded by that document** — in particular "the mesh never collides" was true only of the innocent neighbour, and both @@ -156,8 +166,41 @@ Two additions close that: ## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested -**Status:** OPEN -**Severity:** high for the tall-prop population. It is the gate immediately +**Status:** FIXED 2026-08-06 — **the filter is deleted, not re-centred.** +Re-centring it would have kept an invention retail does not have; the +disassembly below establishes that retail walks the cell's shadow list +unconditionally. Cell membership IS retail's broad phase, and the BSP walk's +own root-node bounding-sphere test — correctly centred, which is exactly what +this filter was not — is the early-out that made a second one unnecessary. +**AP-158 is retired** by the same commit. + +This closes **#337** (the Neftet plateau: wedged at the top, jumps sink in, +corpses fall through), whose mechanism this is. + +**Gates.** `Issue333BroadphaseReachFilterTests` drives the production path +end-to-end (`ResolveWithTransition` → `FindObjCollisionsInCell` → +`CollisionTraversal`) on a DAT-free fixture so it runs everywhere, and is +sabotage-verified as a discriminating pair: restore the `maxReach` pre-check +and `OffCentreBspFloorStopsAFallingMover` falls straight through to the +unobstructed 37.800 while `CentredBspFloorStopsAFallingMover` keeps passing — +so it cannot pass for the trivial reason that the fixture is unable to fall. + +**Perf, measured rather than assumed.** Deleting a filter costs whatever the +candidates it used to reject now cost. Measured in Release on a synthetic +all-BSP cell, per `ResolveWithTransition`: + +| candidates in cell | with filter | without | delta | +|---|---|---|---| +| 38 (the live max) | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) | +| 200 (5× worse than anything observed) | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) | + +≈0.16 µs per additional candidate actually tested. The live population is the +bound that matters: over 19,701 `[reach-q]` samples in the Neftet and outdoor +captures (`334-fix-gate.log`, `334-neftet-probe.log`, `334-neftet.log`) the +in-cell candidate count is **p50 = 9, p99 = 32, max 38**. The 200-object row is +included only to show the curve is linear, not to suggest it is reachable. + +**Severity (when open):** high for the tall-prop population. It was the gate immediately downstream of the AP-156 membership fix, so that fix alone may not be enough to make the worst objects block. **Filed:** 2026-08-06 at the AP-156 fix (commit `b52967de`), which surfaced it. @@ -199,7 +242,12 @@ offset above the filter's roughly 2.5 m walking budget, and **46** above 5 m. At a test scale of 1.75 those offsets become 4.4 m and 8.75 m against an unchanged budget. -### Consequence for the AP-156 connected gate — read this before running it +### Consequence for the AP-156 connected gate — SUPERSEDED by the fix above + +*The caveat below applied while this issue was open. It no longer holds: the +filter is gone, so AP-156's connected gate is now expected to show its benefit +on tall props, and a null result there IS evidence against AP-156. Retained for +the record.* **Tall props may show NO VISIBLE CHANGE at all until this issue is fixed, and a null result there is EXPECTED rather than evidence against AP-156.** AP-156 puts diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c1b2bc6c..f0c8b2f7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -163,7 +163,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 111 active rows (AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 110 active rows (AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -186,7 +186,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-155 | **Filed 2026-08-06 at the AP-152 retirement; NARROWED 2026-08-06 to its static-publication half alone.** Its flood half was bundled here with a different code path, a different population and a different gate — the exact fault the C4 handoff warns about — and its direction was recorded BACKWARDS; both are now split out as AP-156. **Static paths emit a Setup Sphere as a height-capped CYLINDER.** `LandblockPhysicsPublisher.cs:1030-1037` and `LandblockPhysicsContentBuilder.cs:683-690` both convert a Setup Sphere to `ShadowCollisionType.Cylinder` with `CylHeight = radius * 2f` and the origin shifted down by one radius; the live path emits a true `ShadowCollisionType.Sphere`, produced at exactly ONE site in `src/` (`ShadowShapeBuilder.cs`). Retail tests a Setup Sphere with `CSphere::intersects_sphere` @0x00537a80 / @0x00537fd0 (two overloads) in both cases — 3-D distance, no height clamp. The static paths also derive "has BSP" from `entity.MeshRefs` (the render mesh list) where the live path derives it from `setup.Parts` plus the effective post-`AnimPartChanged` identities; the two sources can disagree. | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1030-1037`; `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:683-690` | Affects static props only and changes their collision geometry over a much larger population than AP-152's 172, so it needs its own count and its own gate. Deliberately not folded into the AP-152 or AP-156 commits. | A static prop whose Setup carries a Sphere blocks over a height-clamped cylinder instead of a true sphere, and rests one radius lower than the authored origin. | `CSphere::intersects_sphere` 0x00537a80 / 0x00537fd0 | | AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. **OUTDOOR HALF CLOSED 2026-08-06 by #334 (see AP-159 for what remains).** | **RISK COLUMN CORRECTED 2026-08-06 at the #334 fix — as written below it was FALSE, and its falsity is what let #334 sit unnoticed inside this row.** It generalised the INDOOR direction (sphere-vs-portal-plane, over-inclusive) to the whole residual. The OUTDOOR direction was the opposite and strictly worse: acdream routed BSP-bearing objects through `CObjCell::find_cell_list`, whose outdoor expansion is a hard-capped ±1-cell 3×3 for ANY radius, so every formation wider than one 24 m land cell was MISSED in its outer cells — a user-observed loss of collision, not extra candidates. Original text, retained for the record: *"A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed."* That statement now holds only for the indoor half, which is AP-159. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | | AP-157 | **Filed 2026-08-06 at the AP-152 retail-conformance review (finding F4) — an unregistered substitution that predates AP-152 and was stepped over when its neighbours were filed.** `CPhysicsObj::calc_cross_cells`' THIRD branch (`0x005152dc` -> `CPartArray::GetSortingSphere` @0x00518b00 -> `CObjCell::find_cell_list` @0x0052b990) floods from ONE authored whole-object sphere: `GetSortingSphere` returns `[partArray+0x54] + 0x70`, i.e. `CSetup::sorting_sphere` (acclient.h: `CSetup` carries `CSphere sorting_sphere` immediately after `step_up_height`), and that overload takes a single sphere with no cap. acdream's `only == null` branch floods from EVERY non-BSP, non-Cylinder shape instead — the Setup's per-part `Spheres` array. Different DAT field, different cardinality, different extent. 4,154 of 5,935 installed Setups carry a non-zero `SortingSphere` and `DatReaderWriter.Setup` already exposes it, so this is available rather than blocked. Same site, second item: `BuildFloodSpheres` collapses a Cylinder to one sphere at its BASE point with the cylinder radius and IGNORES `CylHeight` entirely, where retail's `CObjCell::find_cell_list` @0x0052b9f0 is handed the `CCylSphere` array as `(low_pt, radius, height)`. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`, the `anyCyl` and `only == null` branches) | Deliberately NOT folded into the AP-156 fix. It is a different branch of `calc_cross_cells`, reached only by objects with neither a physics BSP nor a CylSphere, so its population is disjoint from the 172 AP-152 Setups and its live gate is a different set of objects. Bundling it would make the AP-156 connected gate un-attributable — which is exactly how AP-155 came to carry two lifecycles under one id. | Sorting-sphere half: an object with several authored Spheres floods from all of them rather than from the one authored whole-object sphere — usually wider (max 5 Spheres on any installed Setup, so retail's 10-cap is never the difference), but a `sorting_sphere` LARGER than every per-part Sphere would make acdream under-inclusive, the #98 / #168 direction. CylHeight half: a tall thin cylinder floods a sphere of its radius at its base and can miss the cells its upper half occupies. | `CPhysicsObj::calc_cross_cells` 0x00515230 (0x005152dc / 0x005152e3 / 0x005152fb); `CPartArray::GetSortingSphere` 0x00518b00 (`[+0x54]+0x70`); `CObjCell::find_cell_list` 0x0052b990 (sorting sphere) / 0x0052b9f0 (cylsphere, `(low_pt, radius, height)`) | -| AP-158 | **Filed 2026-08-06 at the AP-156 fix review (finding A2) — an UNREGISTERED INVENTION, not a port, that predates AP-156 and is issue #333.** The shadow broadphase discards a candidate outright when `distToCurr > sphereRadius + obj.Radius + movement.Length() + 2f`. **Retail has no distance pre-filter at all.** `CObjCell::find_obj_collisions` @0x0052b750, disassembled from the PDB-paired binary for this row rather than inherited: it early-returns `OK_TS` only when `sphere_path.insert_type == INITIAL_PLACEMENT_INSERT` (`0x0052b759 cmp dword [ebx+0x174],2` / `0x0052b765 je 0x52b7a0`), then walks `shadow_object_list` (`[cell+0xc8]`, count `[cell+0xc4]`) and calls `CPhysicsObj::FindObjCollisions` (`0x0052b78b call 0x50f050`) on every entry whose `physobj` is unparented (`[physobj+0x40] == 0`) and is not the mover itself — UNCONDITIONALLY. There is no distance test in the function. Neither the `+ 2f` slack nor the `movement.Length()` term has a retail counterpart; retail's own cross-cell slack constant is `F_EPSILON` = 1.9999999e-4 m (`0x0052cb5f fld dword [0x7c8c70]`), 0.0002 m and not 2 m. **Second half of the defect:** the filter measures `currPos - obj.Position`, i.e. from the PART ORIGIN, while `obj.Radius` is the BSP root bounding-sphere radius measured about a centre that AP-156 established is frequently metres away — `ShadowEntry` does not carry the `BoundsCenter` that `ShadowShape` now does. A mover touching the geometry is up to `d + R + r` from the part origin and is admitted only when `d <= movement + 2`, roughly 2.5 m for a walking player. | `src/AcDream.Core/Physics/TransitionTypes.cs:3757-3765`; `ShadowEntry` (`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:2735`) carries no `BoundsCenter` | Deliberately NOT folded into the AP-156 commit: different code path (collision query, not cell membership) and it needed its own retail question answered, which this row answers. The minimal fix is mechanical — carry `BoundsCenter` on `ShadowEntry` and measure from `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`; only whether to keep the `+ 2f` slack at all is genuinely open. | **This is the gate immediately downstream of AP-156, and it can mask AP-156's entire visible benefit.** 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above the ~2.5 m budget and 46 above 5 m; at a test scale of 1.75 those become 4.4 m and 8.75 m against an unchanged budget. Worked case: Setup 0x02000255, one part, root sphere origin (0.000, -0.007, 9.911), radius 10.522 — a player against its upper half is ~20.4 m from the part origin while `maxReach` is ~13.5 m. Discarded before `BSPQuery` ever runs. A tall prop that still does not block after AP-156 is THIS row, not a failure of AP-156. | `CObjCell::find_obj_collisions` 0x0052b750 (0x0052b759 / 0x0052b765 / 0x0052b788 / 0x0052b78b), pseudo-C 308916-308940; `CEnvCell::find_transit_cells` 0x0052cae0 (`F_EPSILON` at 0x0052cb5f -> 0x7c8c70); issue #333 | +| ~~AP-158~~ | **RETIRED 2026-08-06 — the filter is DELETED, not re-centred, and this row's own disassembly is why.** The minimal fix this row proposed (carry `BoundsCenter` on `ShadowEntry` and measure from the true centre) was deliberately NOT taken: it would have preserved an invention retail does not have, kept a `+ 2f` slack and a `movement.Length()` term with no retail counterpart, and left a second reach budget to be tuned forever. `Transition.FindObjCollisionsInCell` now walks the cell's shadow list with no distance pre-check at all, as `CObjCell::find_obj_collisions` @0x0052b750 does. Cell membership is retail's broad phase, and the BSP walk's own root-node bounding-sphere test — centred correctly, which is precisely what this filter was not — is the early-out that made a second one unnecessary. **This retirement closes #333 and #337** (the Neftet plateau: wedged at the top, jumps sinking into the mesh, corpses falling through), whose mechanism it was. **The row's predicted symptom was observed live before it was fixed**, which is the strongest confirmation a register row gets: it predicted a tall prop AP-156 had just placed correctly would still not block, and the user reported exactly that at Neftet. **PERF, MEASURED rather than assumed** (Release, synthetic all-BSP cell, per `ResolveWithTransition`): at 38 candidates — the live maximum — 10.61 µs → 16.68 µs (+6.07, 1.57×); at a deliberately unreachable 200, 17.34 µs → 39.48 µs (2.28×); ≈ 0.16 µs per additional candidate tested. Over 19,701 live `[reach-q]` samples the in-cell candidate count is p50 = 9, p99 = 32, max 38, so the first row is the bound that matters. **Original text, retained for the record:** **Filed 2026-08-06 at the AP-156 fix review (finding A2) — an UNREGISTERED INVENTION, not a port, that predates AP-156 and is issue #333.** The shadow broadphase discards a candidate outright when `distToCurr > sphereRadius + obj.Radius + movement.Length() + 2f`. **Retail has no distance pre-filter at all.** `CObjCell::find_obj_collisions` @0x0052b750, disassembled from the PDB-paired binary for this row rather than inherited: it early-returns `OK_TS` only when `sphere_path.insert_type == INITIAL_PLACEMENT_INSERT` (`0x0052b759 cmp dword [ebx+0x174],2` / `0x0052b765 je 0x52b7a0`), then walks `shadow_object_list` (`[cell+0xc8]`, count `[cell+0xc4]`) and calls `CPhysicsObj::FindObjCollisions` (`0x0052b78b call 0x50f050`) on every entry whose `physobj` is unparented (`[physobj+0x40] == 0`) and is not the mover itself — UNCONDITIONALLY. There is no distance test in the function. Neither the `+ 2f` slack nor the `movement.Length()` term has a retail counterpart; retail's own cross-cell slack constant is `F_EPSILON` = 1.9999999e-4 m (`0x0052cb5f fld dword [0x7c8c70]`), 0.0002 m and not 2 m. **Second half of the defect:** the filter measures `currPos - obj.Position`, i.e. from the PART ORIGIN, while `obj.Radius` is the BSP root bounding-sphere radius measured about a centre that AP-156 established is frequently metres away — `ShadowEntry` does not carry the `BoundsCenter` that `ShadowShape` now does. A mover touching the geometry is up to `d + R + r` from the part origin and is admitted only when `d <= movement + 2`, roughly 2.5 m for a walking player. | `src/AcDream.Core/Physics/TransitionTypes.cs:3757-3765`; `ShadowEntry` (`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:2735`) carries no `BoundsCenter`; **RETIRED:** the pre-check is gone from `FindObjCollisionsInCell` and `ShadowEntry` needs no `BoundsCenter`. Tests `Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover` (production path end-to-end, DAT-free, sabotage-verified against its `CentredBspFloorStopsAFallingMover` control — restore the pre-check and the mover falls straight through to the unobstructed 37.800 while the control still blocks) and `Issue337NeftetRockGeometryInspectionTests.TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn` (installed-DAT evidence, both halves of the diagnosis) | Deliberately NOT folded into the AP-156 commit: different code path (collision query, not cell membership) and it needed its own retail question answered, which this row answers. The minimal fix is mechanical — carry `BoundsCenter` on `ShadowEntry` and measure from `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`; only whether to keep the `+ 2f` slack at all is genuinely open. | **RETIRED — no residual.** The `rejectedReach` column of the `ACDREAM_PROBE_REACH` family is kept and is now structurally 0, precisely so a post-fix capture is directly comparable with the pre-fix one that recorded 7,225 rejections on a single owner, every one with `wouldAcceptAtCenter=True`. Original risk text, retained for the record: **This is the gate immediately downstream of AP-156, and it can mask AP-156's entire visible benefit.** 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above the ~2.5 m budget and 46 above 5 m; at a test scale of 1.75 those become 4.4 m and 8.75 m against an unchanged budget. Worked case: Setup 0x02000255, one part, root sphere origin (0.000, -0.007, 9.911), radius 10.522 — a player against its upper half is ~20.4 m from the part origin while `maxReach` is ~13.5 m. Discarded before `BSPQuery` ever runs. A tall prop that still does not block after AP-156 is THIS row, not a failure of AP-156. | `CObjCell::find_obj_collisions` 0x0052b750 (0x0052b759 / 0x0052b765 / 0x0052b788 / 0x0052b78b), pseudo-C 308916-308940; `CEnvCell::find_transit_cells` 0x0052cae0 (`F_EPSILON` at 0x0052cb5f -> 0x7c8c70); issue #333 | | AP-159 | **Filed 2026-08-06 at the #334 fix - the INDOOR half of AP-156's traversal residual, now the whole of it.** #334 ported retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path so a physics-BSP object's OUTDOOR membership is the filled land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans (`CLandCell::add_all_outside_cells` @0x00533360 -> `add_cell_block` @0x005331d0). The INDOOR arm of that same walk is NOT ported: retail's part-array `CEnvCell::find_transit_cells` @0x0052cae0 admits a neighbour cell on a BOX test - `CPhysicsPart::GetBoundingBox` @0x0050d600 -> `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) -> `Plane::intersect_box` @0x005aa170 (`0x0052cc05`), then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 -> `BSPTREE` @0x0053c880 - where acdream keeps `CellTransit.FindTransitCellsSphere`'s sphere-vs-portal-plane test, fed from the SAME per-part `CGfxObj::physics_sphere` values retail uses for its cheap `eps = F_EPSILON + radius` pre-reject at `0x0052cb65`. The outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0) is on the same sphere input for the same reason. Deferred deliberately: closing it needs a new BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations plus their exact referee, which is a separately gateable change with no bearing on #334's outdoor defect. Filed as issue #335. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts`, indoor arm); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`) | The sphere set is exact (AP-156) and the sphere is a strictly LOOSER admitter than the box for a convex part, so the indoor set is a superset of retail's. Retail is itself conservative here in four compounding ways (render-mesh AABB over physics hull, axis-aligned re-fit after rotation, filled rectangle over per-cell test, one rectangle unioned across parts), so an over-inclusive indoor set is the same direction retail errs in. | A cell whose portal plane a part's sphere straddles but whose box does not joins the object's shadow set: extra broadphase candidates, never a missed one. This is AP-156's original risk statement, which is true of the indoor half and was false of the outdoor half. | `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cbdd / 0x0052cbf9 / 0x0052cc05 / 0x0052cc5a); `Plane::intersect_box` 0x005aa170; `CCellStruct::box_intersects_cell` 0x00533910; `CEnvCell::check_building_transit` 0x0052c5d0 | | ~~AP-152~~ | **RETIRED 2026-08-06 (the commit that filed it is one day old; this retirement corrects four statements in it).** `ShadowShapeBuilder.FromSetup` now DISPATCHES instead of unioning: a step-0 gate derived from the parts suppresses steps 1 and 2 whenever any part's EFFECTIVE GfxObj carries a physics BSP. Retail's priority, re-disassembled from the PDB-paired binary for this commit rather than inherited: `CPhysicsObj::FindObjCollisions` @0x0050f050 tests `HAS_PHYSICS_BSP_PS` FIRST (`0x0050f165 test dword [esi+0xa8],0x10000` / `0x0050f16f je 0x50f1a2`) and leaves the BSP branch through the UNCONDITIONAL `0x0050f19d jmp 0x50f2b0`, which is past the CylSphere loop at 0x50f1a2 AND the Sphere loop at 0x50f21d; a CylSphere-bearing object that survives its loop RETURNS (`0x0050f1d6 jae 0x50f317`); a Setup with zero spheres returns the seeded OK_TS (`0x0050f22f je 0x50f31b`). **BSP wins.** **CORRECTION 1 — the row's risk statement was FALSE as written.** It predicted "catching or stopping on a doorway sill". acdream did not test the extra primitive either: `Transition.BspOnlyDispatch` (`TransitionTypes.cs:1348`, landed 2026-05-25 as A6.P7) already skipped BOTH primitive branches (`:3911`, `:3954`) whenever the target's wire `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` (`WorldObject_Networking.cs:665-668`). The row's own anchor column cites the flag it failed to notice acdream was already keying on. So this retirement is NOT a collision-response change; the live half was CELL MEMBERSHIP, which had no such guard (see AP-155). **CORRECTION 2 — "the affected primitives are small and centred at the part origin" was FALSE in both halves.** The largest is `0x02001741`'s CylSphere at **r = 6.714 m**; `0x0200086E`'s Sphere is r = 5.842 m with origin (0.759, 0.165, 5.842), nowhere near the part origin. **CORRECTION 3 — the cottage door's "~14 cm base Sphere" was the wrong field.** `0x020019FF`'s Sphere radius is **0.100 m** at origin (0, 0, 0.018); `0.141` is `Setup.Radius`, which AP-22 had just finished proving is never collision geometry. **CORRECTION 4 — the row named ONE pinning test where TWO existed.** `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` also failed under the exclusive rule; both are corrected, neither deleted. Population re-measured independently at 172 of 5,935 (73 CylSphere+BSP, 99 Sphere+BSP; 530 carry a physics-BSP part), agreeing exactly with the filing commit's separate sweep, and now pinned by an installed-DAT test with external bucket controls. | RETIRED — `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0 gate + `EffectivePartGfxObjId`, shared with step 3 so the two can never read different identities); `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_EmitsBspPartsOnly`, `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, `FromSetup_DispatchGateReadsTheEffectivePartIdentities`); `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` (`CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape` — no App fixture combined a primitive with a BSP part before); `tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs` (population). `Transition.BspOnlyDispatch` is deliberately KEPT: retail genuinely dispatches at the query site too, and it guards against a future additive producer. | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f); `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 / 0x0051528f) -> `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPhysicsPart::find_obj_collisions` 0x0050d8d0; `CPartArray::CacheHasPhysicsBSP` 0x00518110; evidence `docs/research/2026-08-06-ap152-contract.md` | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | diff --git a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md index 54cad1ec..c32b3747 100644 --- a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md +++ b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md @@ -1,7 +1,9 @@ # #337 — the Neftet plateau wedge: mechanism, measured **Date:** 2026-08-06 -**Status:** mechanism proven offline; fix proposed, NOT landed. +**Status:** mechanism proven offline; **fix LANDED 2026-08-06 — the preferred +option below was taken, the filter is deleted.** Awaiting the user's live +acceptance at the Neftet plateau. **Reproducer:** `tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs` **Related:** #333 (filed: the broadphase reach filter has AP-156's defect at the query site), #334 (`13fcf381`, registration extent walk), AP-156 @@ -200,26 +202,71 @@ response-neutral, pure perf" is **wrong on both counts.** --- -## Proposed fix +## The fix, as landed -**Preferred — remove the per-object distance pre-check for BSP entries.** -Retail has none, and the BSP walk's own root node bounding-sphere test is the -correctly-centred early-out that makes it unnecessary. Size: delete ~10 lines -in `Transition.FindObjCollisionsInCell` plus the probe's `rejected-reach` -branch; correct the false retail-analog comment in the same commit. No -divergence-register row is created; if #333's row exists it is deleted. +**Taken: the preferred option — the per-object distance pre-check is DELETED**, +for BSP and primitive entries alike. Retail has none, and the BSP walk's own +root-node bounding-sphere test is the correctly-centred early-out that makes a +second one unnecessary. The comment that called the filter "the analog of the +part sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions` +— response-neutral, pure perf" was false in both halves and is replaced by the +disassembly that refutes it. **AP-158 is retired**; no new register row is +created, because the code no longer diverges. -**Fallback if a perf gate demands a filter** — measure to the bounding-sphere -centre, the AP-156 correction applied at the query site: -`obj.Position + Vector3.Transform(BoundsCenter * Scale, obj.Rotation)`. -`ShadowShape.BoundsCenter` already carries this value; `ShadowEntry` does not, -so this variant also touches `ShadowEntry` and both registration paths -(`Register` and `RegisterMultiPart`). Larger, and it keeps a non-retail -construct that then needs a register row. +The fallback — measuring to the bounding-sphere centre, which would have needed +`BoundsCenter` on `ShadowEntry` and both registration paths — was NOT taken. It +would have kept a construct retail does not have, including a `+ 2f` slack and +a `movement.Length()` term with no retail counterpart, and left a second reach +budget to be tuned forever. -Acceptance gate: un-skip -`Issue337NeftetRockGeometryInspectionTests.TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`. -Verified to fail today with the numbers above. +### Gates + +`tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs` drives +the production path end-to-end (`ResolveWithTransition` → +`FindObjCollisionsInCell` → `CollisionTraversal`) on a DAT-free fixture, so it +runs everywhere rather than only where the installed DATs are present. It is a +discriminating PAIR, sabotage-verified: with the `maxReach` pre-check restored, +`OffCentreBspFloorStopsAFallingMover` fails — the mover reaches z=37.800, which +is exactly the unobstructed fall, with `blockedAtLeastOnce=False` — while +`CentredBspFloorStopsAFallingMover` keeps passing. Without the control row, a +fixture that simply could not fall would pass the first test for the wrong +reason. + +The installed-DAT evidence for THIS rock is +`Issue337NeftetRockGeometryInspectionTests.TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn` +(previously the skipped `TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`, +which asserted the now-deleted predicate and could never have gone green). It +pins both halves of the diagnosis: the origin-measured distance OUTSIDE the old +budget, and the centre-measured distance comfortably INSIDE the same radius. If +a future DAT or transform change makes either false, the mechanism recorded here +no longer describes this object. + +### Perf — measured, not assumed + +Deleting a filter costs whatever the candidates it used to reject now cost. +Measured in Release on a synthetic all-BSP cell, per `ResolveWithTransition`: + +| candidates in cell | with filter | without | delta | +|---|---|---|---| +| 38 — the live maximum | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) | +| 200 — 5× anything observed | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) | + +≈ 0.16 µs per additional candidate actually tested; the curve is linear, and +the 200-object row is included only to show that, not to suggest it is +reachable. The live population is the bound that matters: over **19,701** +`[reach-q]` samples across the Neftet and outdoor captures (`334-fix-gate.log`, +`334-neftet-probe.log`, `334-neftet.log`) the in-cell candidate count is +**p50 = 9, p99 = 32, max 38**. Retail pays the same cost and shipped without a +filter. + +### Probe columns kept deliberately + +`rejectedReach` on the `[reach-q]` line and the origin-vs-centre distance pair +on `[reach-obj]` are RETAINED and are now structurally zero / purely +informational. That is the point: a post-fix capture reading `rejectedReach=0` +is directly comparable with the pre-fix capture that recorded **7,225** +rejections on a single owner, every one of them with `wouldAcceptAtCenter=True`. +Dropping the columns would make the two captures incomparable. --- diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index ae785f2e..4c470455 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1158,7 +1158,13 @@ public static class PhysicsDiagnostics // confirm any one of them: // // (a) the object IS a candidate in the cell but the broadphase reach - // filter rejects it before its BSP is consulted (AP-158 / #333); + // filter rejects it before its BSP is consulted (AP-158 / #333). + // CONFIRMED and CLOSED 2026-08-06: this was the cause of #337, and + // the filter is now DELETED — retail has none. `rejectedReach` is + // retained as a structurally-zero column so a post-fix capture is + // directly comparable with the pre-fix one that recorded 7,225 + // rejections on one owner, every single one with + // wouldAcceptAtCenter=True; // (b) the object is NOT in the cell's candidate set at all — a // membership / registration failure (AP-156's territory, which did // not fix this, or the static publication path never registered it); @@ -1199,7 +1205,9 @@ public static class PhysicsDiagnostics /// [reach-obj] — one per candidate, carrying its identity /// (mover guid, target entity id, GfxObj id, cell) and its /// disposition: exempt-self, exempt-missile, - /// rejected-reach, exempt-rule, + /// exempt-rule + /// (rejected-reach is retired — #333 deleted the filter that + /// produced it), /// exempt-ethereal-stepdown, no-shape, /// bsp-only-skip, or tested:<result>. For BSP /// candidates it also carries the origin-measured distance the filter @@ -1250,8 +1258,10 @@ public static class PhysicsDiagnostics /// What it should have measured: the distance to /// the target's physics-BSP root sphere CENTRE. Negative when not /// applicable. - /// The filter's admission threshold, 2 m slack - /// included. + /// What the deleted filter's admission threshold WOULD + /// have been, 2 m slack included. Since #333 no live predicate reads it; it + /// is kept so the acceptance capture shows which candidates the old filter + /// would have thrown away. /// The same threshold WITHOUT the slack — the /// honest conservative bound once the real centre is used. public static void LogReachCandidate( @@ -1318,10 +1328,13 @@ public static class PhysicsDiagnostics /// /// Shadow entries the cell yielded, before any /// exemption. Zero here at a spot with visible geometry is outcome (b). - /// Candidates that survived the exemptions and were - /// measured by the reach filter. + /// Candidates that survived the exemptions and went + /// on to a shape dispatch. Before #333 this was "and were measured by the + /// reach filter"; the filter is gone, so the two are now the same set. /// Of those, how many the reach filter - /// rejected — outcome (a). + /// rejected — outcome (a). Structurally 0 since #333 deleted the + /// filter, and retained precisely so that a post-fix capture reading 0 + /// is comparable against the pre-fix capture that read 7,225. /// Candidates that passed the filter but resolved to /// no usable shape — outcome (c). /// Candidates that actually reached a shape test. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index ac3632c8..556998c4 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3719,9 +3719,41 @@ public sealed class Transition /// /// /// - /// The per-object distance pre-check below is the analog of the part - /// sorting-sphere early-outs inside retail's - /// CPhysicsObj::FindObjCollisions — response-neutral, pure perf. + /// There is no per-object distance pre-check here, because retail has + /// none. An earlier comment on this method claimed the filter that used + /// to sit in the candidate loop was "the analog of the part sorting-sphere + /// early-outs inside retail's CPhysicsObj::FindObjCollisions — + /// response-neutral, pure perf". Both halves were false, and it cost + /// issues #333 and #337. Disassembled from the PDB-paired v11.4186 binary + /// (CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32): + /// + /// CObjCell::find_obj_collisions @0x0052b750 walks + /// shadow_object_list and calls CPhysicsObj::FindObjCollisions + /// (@0x0050f050, 0x0052b78b call) UNCONDITIONALLY; its only + /// early-out is insert_type == INITIAL_PLACEMENT_INSERT + /// (0x0052b759 cmp dword [ebx+0x174],2). + /// CPhysicsObj::FindObjCollisions @0x0050f050 contains no + /// float compare at all. Its BSP branch reaches + /// CPartArray::FindObjCollisions (0x0050f18d call 0x518180) + /// directly; the cylsphere and sphere loops are bounded only by + /// GetNumCylsphere/GetNumSphere counts and call the real + /// CCylSphere::intersects_sphere / CSphere::intersects_sphere + /// per primitive — tests, not pre-filters. There are no "sorting-sphere + /// early-outs" in it. + /// CPartArray::FindObjCollisions @0x00518180 is a bare + /// do/while over parts[i], and + /// CPhysicsPart::find_obj_collisions @0x0050d8d0 does two null + /// checks (gfxobj, then gfxobj->physics_bsp at + /// [ecx+0x78]), SPHEREPATH::cache_localspace_sphere, and + /// CGfxObj::find_obj_collisions @0x00534700. Neither contains a + /// compare or any float math. + /// + /// Retail's only spatial rejection is the BSP node bounding-sphere test + /// INSIDE the walk, which is centred on the node's own sphere origin and + /// is therefore correct where the deleted filter was not: it measured to + /// the part ORIGIN and compared against the BSP ROOT SPHERE's radius, two + /// points that are 23.556 m apart for the Neftet rock of #337. See + /// docs/research/2026-08-06-337-neftet-wedge-mechanism.md. /// /// private TransitionState FindObjCollisionsInCell(PhysicsEngine engine, uint cellId) @@ -3774,7 +3806,13 @@ public sealed class Transition using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); // #334 probe tallies — see LogReachQuery for what each one decides. - int rExempt = 0, rReached = 0, rRejected = 0, rNoShape = 0, + // `rejectedReach` is deliberately still REPORTED and structurally 0 + // since #333 deleted the filter: a run of the acceptance capture whose + // [reach-q] lines read rejectedReach=0 where the pre-fix capture read + // 7,225 rejections is the evidence the filter is gone, and dropping the + // column would make the two captures incomparable. + const int rRejected = 0; + int rExempt = 0, rReached = 0, rNoShape = 0, rTested = 0, rBlocked = 0; foreach (ShadowEntry obj in nearbyObjs.Entries) @@ -3836,25 +3874,12 @@ public sealed class Transition continue; } - // Broad-phase: can the moving sphere reach this object? - Vector3 deltaToCurr = currPos - obj.Position; - float distToCurr; - if (obj.CollisionType == ShadowCollisionType.Cylinder) - distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y); - else - distToCurr = deltaToCurr.Length(); - float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f; + // NO BROAD-PHASE DISTANCE FILTER — see the method's remarks. Retail + // walks the cell's shadow list unconditionally (#333, closing #337). + // Cell membership IS the broad phase, and the BSP walk's own root + // node bounding-sphere test — correctly centred, unlike the deleted + // filter — is the early-out that made this one unnecessary. if (reachProbe) rReached++; - if (distToCurr > maxReach) - { - if (reachProbe) - { - rRejected++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "rejected-reach", sphereRadius, movement.Length(), currPos); - } - continue; - } // Commit C 2026-04-29 — retail exemption block at the top of // CPhysicsObj::FindObjCollisions @@ -4338,10 +4363,12 @@ public sealed class Transition ? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y) : dOrigin.Length(); - // World-space offset from the part ORIGIN (what the filter measures - // against) to the BSP root sphere CENTRE (what it should measure - // against). Zero for non-BSP shapes, whose Position already IS their - // centre. + // World-space offset from the part ORIGIN (what the DELETED filter + // measured against) to the BSP root sphere CENTRE (what it should have + // measured against). Both distances are still emitted after #333: the + // pair is what proved the diagnosis, and it stays comparable across the + // pre-fix and post-fix captures. Zero for non-BSP shapes, whose + // Position already IS their centre. Vector3 bspCentreOffset = Vector3.Zero; if (obj.CollisionType == ShadowCollisionType.BSP) { diff --git a/tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs b/tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs new file mode 100644 index 00000000..22589e9b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue333BroadphaseReachFilterTests.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #333 / #337 regression — the query-site broadphase reach filter. +/// +/// +/// Transition.FindObjCollisionsInCell used to discard a shadow candidate +/// when |currPos - obj.Position| > sphereRadius + obj.Radius + +/// movement.Length() + 2f. obj.Position is the part ORIGIN while +/// obj.Radius is the physics-BSP ROOT BOUNDING SPHERE's radius, measured +/// about a centre that is frequently metres away from that origin (AP-156: +/// 376 of 973 installed physics-BSP parts sit further from their part origin +/// than half their own radius, worst 20.762 m). Geometry deep inside the real +/// bounding sphere was therefore rejected before BSPQuery ever ran — +/// solid near the origin, permeable in a bounded shell beyond it. That is the +/// mechanism of #337 (the Neftet plateau: wedged on top, jumps sink in, +/// corpses fall through), measured in +/// docs/research/2026-08-06-337-neftet-wedge-mechanism.md. +/// +/// +/// +/// Retail has no such filter. Disassembled from the PDB-paired v11.4186 +/// binary: CObjCell::find_obj_collisions @0x0052b750 calls +/// CPhysicsObj::FindObjCollisions unconditionally, +/// CPhysicsObj::FindObjCollisions @0x0050f050 has no float compare at +/// all, CPartArray::FindObjCollisions @0x00518180 is a bare loop over +/// parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null +/// checks plus CGfxObj::find_obj_collisions @0x00534700. +/// +/// +/// +/// This fixture is deliberately DAT-free so the gate runs everywhere, and it +/// drives the PRODUCTION path end-to-end +/// (PhysicsEngine.ResolveWithTransition → +/// Transition.FindObjCollisionsInCellCollisionTraversal) +/// rather than re-computing a predicate in the test. Sabotage: restore the +/// maxReach pre-check and +/// fails while keeps passing — +/// the pair discriminates "the filter is gone" from "the fixture cannot fall". +/// +/// +public sealed class Issue333BroadphaseReachFilterTests +{ + private const uint LandblockId = 0xA9B60000u; + private const uint CellId = LandblockId | 0x0001u; + private const uint EntityId = 0x333BEEF1u; + private const uint GfxObjId = 0x333BEEF2u; + private const ushort FloorPolyId = 1; + + private const float SphereRadius = 0.48f; + private const float SphereHeight = 1.835f; // human Setup 0x02000001 + private const float StepUpHeight = 0.60f; + private const float StepDownHeight = 0.04f; + + /// Part origin. The filter measured to THIS point. + private static readonly Vector3 PartOrigin = new(12f, 12f, 0f); + + /// + /// Root bounding-sphere radius the registry publishes for this object, + /// and the radius of the single BSP leaf node. + /// + private const float RootSphereRadius = 6f; + + /// + /// #333 REGRESSION. A floor slab whose geometry — and whose BSP root + /// bounding sphere — sit 40 m above the part origin must stop a mover + /// falling onto it. + /// + /// + /// With the deleted filter the mover was 40.48 m from the part origin + /// against a budget of 0.48 + 6 + ~0.3 + 2 = 8.78 m, so the candidate was + /// discarded and the mover fell straight through. Measured to the root + /// sphere's own centre it is 1.48 m against a 6 m radius — inside by + /// 4.5 m of margin. Retail's BSP node test uses that centre; the filter + /// used the origin. + /// + /// + [Fact] + public void OffCentreBspFloorStopsAFallingMover() + { + var engine = BuildEngineWithFloorSlab(slabLocalZ: 40f); + + // Precondition, so a membership failure cannot masquerade as the + // defect this test is about. + Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(CellId)); + + var (finalFeetZ, blocked) = DropOnto(engine, startFeetZ: 41.4f); + + Assert.True( + finalFeetZ > 39.5f, + $"the mover fell through a floor slab 40 m above its owner's part " + + $"origin: feet reached z={finalFeetZ:F3}, and an unobstructed " + + $"fall would have reached 37.8. blockedAtLeastOnce={blocked}. " + + $"This is #333 — the query-site broadphase discarded the " + + $"candidate before BSPQuery ever ran."); + } + + /// + /// CONTROL. The identical slab, this time AT the part origin, so the + /// deleted filter admitted it. It blocked before the fix and must still + /// block after. Without this row, a fixture that simply cannot fall would + /// pass for the wrong + /// reason. + /// + [Fact] + public void CentredBspFloorStopsAFallingMover() + { + var engine = BuildEngineWithFloorSlab(slabLocalZ: 0f); + + Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(CellId)); + + var (finalFeetZ, blocked) = DropOnto(engine, startFeetZ: 1.4f); + + Assert.True( + finalFeetZ > -0.5f, + $"the control slab at the part origin must still stop the mover; " + + $"feet reached z={finalFeetZ:F3}, blockedAtLeastOnce={blocked}."); + } + + /// + /// Drop the mover straight down in 0.30 m steps for 12 ticks. Returns the + /// final FEET height and whether any tick reported a collision normal. + /// + private static (float FinalFeetZ, bool Blocked) DropOnto( + PhysicsEngine engine, float startFeetZ) + { + var pos = new Vector3(PartOrigin.X, PartOrigin.Y, startFeetZ); + bool blocked = false; + + for (int tick = 0; tick < 12; tick++) + { + Vector3 target = pos - new Vector3(0f, 0f, 0.30f); + var result = engine.ResolveWithTransition( + pos, target, CellId, + SphereRadius, SphereHeight, + StepUpHeight, StepDownHeight, + isOnGround: false, + body: null, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0); + + blocked |= result.CollisionNormalValid; + pos = result.Position; + } + + return (pos.Z, blocked); + } + + /// + /// One static BSP owner at carrying a single + /// horizontal floor polygon (8 × 8 m, normal +Z) at + /// in the owner's local frame, with the BSP + /// root node's bounding sphere centred on the slab — exactly the + /// origin-vs-centre offset the DAT authors for large formations. + /// + private static PhysicsEngine BuildEngineWithFloorSlab(float slabLocalZ) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // Flat terrain far below: it must never be what stops the mover. + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock( + landblockId: LandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: System.Array.Empty(), + portals: System.Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + var floorVerts = new[] + { + new Vector3(-4f, -4f, slabLocalZ), + new Vector3( 4f, -4f, slabLocalZ), + new Vector3( 4f, 4f, slabLocalZ), + new Vector3(-4f, 4f, slabLocalZ), + }; + var floorNormal = new Vector3(0f, 0f, 1f); + var floorPoly = new ResolvedPolygon + { + Vertices = floorVerts, + Plane = new Plane(floorNormal, -Vector3.Dot(floorNormal, floorVerts[0])), + NumPoints = 4, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, slabLocalZ), + Radius = RootSphereRadius, + }, + }; + leaf.Polygons.Add(FloorPolyId); + + var physics = new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = leaf }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = new Dictionary { [FloorPolyId] = floorPoly }, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, slabLocalZ), + Radius = RootSphereRadius, + }, + }; + cache.RegisterGfxObjForTest(GfxObjId, physics); + + // Registration publishes the ROOT SPHERE'S RADIUS about the PART + // ORIGIN — the exact pairing the deleted filter then mis-measured. + engine.ShadowObjects.Register( + entityId: EntityId, + gfxObjId: GfxObjId, + worldPos: PartOrigin, + rotation: Quaternion.Identity, + radius: RootSphereRadius, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + cylHeight: 0f, + scale: 1.0f, + state: 0x1u, // STATIC_PS + flags: EntityCollisionFlags.None); + + return engine; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs index 0bc8d4d8..b8e9daf9 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs @@ -583,19 +583,19 @@ public sealed class Issue337NeftetRockGeometryInspectionTests } /// - /// #337 REPRODUCER — currently FAILING, hence skipped. + /// #337 EVIDENCE — the installed-DAT measurement that condemned the + /// query-site broadphase, pinned so the diagnosis stays checkable. /// /// - /// The per-object broadphase in - /// Transition.FindObjCollisionsInCell (TransitionTypes.cs, the - /// maxReach test) measures the mover's distance to the shadow - /// entry's Position — the part ORIGIN — and compares it against - /// obj.Radius, which is the physics-BSP ROOT BOUNDING SPHERE's - /// radius. For this rock those two are 23.6 m apart, so a mover standing - /// on its plateau is inside the bounding sphere by ~20 m of margin and - /// still fails the test. It is the same defect AP-156 fixed in the flood + /// The deleted per-object filter in + /// Transition.FindObjCollisionsInCell measured the mover's distance + /// to the shadow entry's Position — the part ORIGIN — and compared + /// it against obj.Radius, which is the physics-BSP ROOT BOUNDING + /// SPHERE's radius. For this rock those two points are 23.6 m apart, so a + /// mover standing on its plateau is inside the bounding sphere by ~20 m of + /// margin and still failed the test. Same defect AP-156 fixed in the flood /// and #334 fixed in the registration extent walk, left in place at the - /// query site (filed as #333). + /// query site — filed as #333. /// /// /// @@ -606,14 +606,24 @@ public sealed class Issue337NeftetRockGeometryInspectionTests /// verified instruction-by-instruction against the PDB-paired /// v11.4186 binary. Neither contains a compare or any float math. The /// only spatial rejection retail performs is the BSP node bounding-sphere - /// test inside the walk, which is correctly centred. + /// test inside the walk, which is correctly centred. #333 therefore + /// deleted the filter outright rather than re-centring it. /// /// - /// Un-skip this as the acceptance gate for the fix. + /// + /// This test asserts the DATA, not the production predicate — the + /// production gate is + /// Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover, + /// which drives ResolveWithTransition end-to-end. Both halves must + /// hold for the diagnosis to be the one recorded: the origin-measured + /// distance OUTSIDE the old budget, the centre-measured distance + /// comfortably INSIDE the same radius. If a future DAT or transform change + /// makes either false, the recorded mechanism no longer describes this + /// object and the research note needs revisiting. + /// /// - [Fact(Skip = "#337: fails until the query-site broadphase measures to the " - + "BSP bounding-sphere centre (or is removed, as retail has none).")] - public void TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn() + [Fact] + public void TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn() { string? datDir = ConformanceDats.ResolveDatDir(); if (datDir is null) return; @@ -628,19 +638,27 @@ public sealed class Issue337NeftetRockGeometryInspectionTests const float SphereRadius = 0.48f; const float Movement = 0.308f; // the live step length - float ownerRadius = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Radius; + var root = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere; + float ownerRadius = root.Radius; - // Verbatim from the production predicate. + // Verbatim from the deleted predicate. float distToOrigin = (currPos - rock.Position).Length(); float maxReach = SphereRadius + ownerRadius + Movement + 2f; Assert.True( - distToOrigin <= maxReach, - $"broadphase rejected a candidate the mover is standing on: " - + $"distToOrigin={distToOrigin:F3} > maxReach={maxReach:F3}. " - + $"Measured to the BSP bounding-sphere CENTRE it is " - + $"{(currPos - (rock.Position + Vector3.Transform(rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Origin, rock.Rotation))).Length():F3} m, " - + $"comfortably inside the same radius."); + distToOrigin > maxReach, + $"the old filter is supposed to have REJECTED this candidate: " + + $"distToOrigin={distToOrigin:F3} vs maxReach={maxReach:F3}."); + + // What retail's BSP node test measures instead. + Vector3 centre = rock.Position + + Vector3.Transform(root.Origin, rock.Rotation); + float distToCentre = (currPos - centre).Length(); + + Assert.True( + distToCentre <= ownerRadius, + $"the mover must be inside the root bounding SPHERE it was standing " + + $"on: distToCentre={distToCentre:F3} > radius={ownerRadius:F3}."); } private static Placed ResolveOwner( From 3171f43002452d0257ebaaf252898140267d0d47 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 22:17:28 +0200 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20file=20#338=20=E2=80=94=20player=20?= =?UTF-8?q?steps=20at=200.400=20where=20Setup=200x02000001=20authors=200.6?= =?UTF-8?q?00/1.500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotted in the #337 [support] capture and deliberately left out of that fix so the fix stayed falsifiable. Filed with what is NOT established attached: whether retail reads the authored Setup field at all is the first question, and the entry says to grep named-retail before touching anything. The #337 lineage already burned two diagnoses reasoned from source. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 40 +++++++++++++++++++ .../2026-08-06-337-neftet-wedge-mechanism.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5de3df89..2a7cfe43 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,46 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #338 — The player resolves with stepUp/stepDown 0.400 where Setup 0x02000001 authors 0.600 / 1.500 + +**Status:** OPEN +**Severity:** unknown until measured, plausibly medium. A 1.5 m step-down is +what keeps a mover attached to a descending slope; 0.4 m is not, so this is a +candidate contributor to descent/edge feel — but that link is NOT established +and must not be assumed. +**Filed:** 2026-08-06, spotted in the #337 `[support]` capture while chasing a +different defect. Deliberately not chased there: it does not cause the Neftet +wedge, and folding it in would have made that fix unfalsifiable. +**Component:** physics / movement. + +### The observation + +The human Setup `0x02000001` authors `StepUpHeight = 0.600` and +`StepDownHeight = 1.500`. The live `[support]` probe lines show the player +resolving with `stepUp=0.400 stepDown=0.400`. + +### What is NOT yet established + +- Whether the authored Setup values are what retail feeds `CPhysicsObj`, or + whether retail also substitutes constants at this seam. **Grep + `named-retail` for `get_stepup_height` / `get_stepdown_height` and their + callers before touching anything** — the authored DAT field being ignored is + only a defect if retail reads it. +- Where the 0.400 comes from: a hardcoded default, a stance/posture-dependent + value, or a value that never got wired from the Setup. +- Whether it has any observable consequence. Do not open this by reasoning + from the source; the #337 lineage already burned two diagnoses that way. + +### Related + +Sits next to #32 (local-player cliff edge-slide), which has its own research +at `38db9fff` and needs a live `ACDREAM_DUMP_EDGE_SLIDE=1` capture before a +fix. If both turn out to touch descent feel, do NOT bundle them — they have +different mechanisms and need separate gates. + +--- + + ## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance **Status:** FIXED 2026-08-06 by #333's fix — the query-site broadphase reach diff --git a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md index c32b3747..404a7e3c 100644 --- a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md +++ b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md @@ -270,7 +270,7 @@ Dropping the columns would make the two captures incomparable. --- -## Separate observation, not part of this defect +## Separate observation, not part of this defect — now filed as #338 Setup `0x02000001` authors `StepUpHeight = 0.600` and `StepDownHeight = 1.500`. The live `[support]` lines show the player resolving From a069094770cc99a023c220a9dadc8d7412cc5a60 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 22:33:42 +0200 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20open=20Campaign=20S=20=E2=80=94=20c?= =?UTF-8?q?ollision=20shape=20&=20response=20fidelity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans the twelve remaining collision-domain items. Slice boundaries come from file coupling, not from how the symptoms group: AP-157, the AP-156 residual and AP-159/#335 all edit ShadowObjectRegistry (two of them the same function), so they are ONE slice against a pinned contract per feedback_dont_parallelize_coupled_plan_slices. Ordering is membership-before-query, because we just watched a membership fix (AP-156) be made invisible by a query-site defect (AP-158) directly downstream of it. Every slice opens with a measurement that can cancel it. In this domain over the last two weeks: AP-155's recorded direction was inverted, AP-156's risk column was wrong (and that is why #334 hid inside it), AP-22 described an unreachable branch, and #331's headline claim was refuted. One row in four was materially wrong about its own population, direction, or existence. Two items are deliberately reclassified. AP-83/AP-91 are NOT fixes — the x87 PerfectClip tails do not decompile, so there is no retail text to port; the honest deliverable is proving the branch unreachable. AD-65's "live lead for #269" framing is retracted: #269 closed 2026-07-31 and AD-65's sign is opposite to that symptom. #32 and #338 are pre-work, not slices — both are blocked on a measurement far cheaper than the fix, and #32 is the outstanding half of the user's original two-bug report, so it does not sit behind six slices. Live gates batched into four sessions; #330 needs none and is the parallel track for whenever a gate is blocking. Co-Authored-By: Claude Opus 5 --- .../2026-08-06-collision-fidelity-campaign.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/plans/2026-08-06-collision-fidelity-campaign.md diff --git a/docs/plans/2026-08-06-collision-fidelity-campaign.md b/docs/plans/2026-08-06-collision-fidelity-campaign.md new file mode 100644 index 00000000..0e15d580 --- /dev/null +++ b/docs/plans/2026-08-06-collision-fidelity-campaign.md @@ -0,0 +1,247 @@ +# Campaign S — collision shape & response fidelity + +**Opened:** 2026-08-06, immediately after #333/#337 closed (`ea83b043`). +**Status:** PLANNED, not started. +**Scope:** the twelve remaining collision-domain items — five shape/membership +divergences, three resolution-math divergences, two undecodable-math rows, and +three open bugs. +**SSOT while active:** this file. Digest: +`claude-memory/project_physics_collision_digest.md`. + +--- + +## Why a campaign and not twelve tickets + +Three of these rows edit the same two functions. `AP-157` and the `AP-156` +residual both live in `ShadowObjectRegistry.BuildFloodSpheres`; `AP-159`/`#335` +lives one call away in `CellTransit.BuildShadowCellSetFromParts` and in +`ShadowObjectRegistry.BuildBspPartSpheres`. Shipping them as separate tickets +means three review cycles over the same code and three chances to reintroduce +each other's bugs. This project already has a written rule about exactly that +shape of work: **shared-file slices are ONE agent against a pinned contract** +(`feedback_dont_parallelize_coupled_plan_slices`). + +The second reason is ordering. **Membership gaps mask query gaps.** We just +watched it happen: AP-156 put geometry into the right cell and AP-158 threw it +away one layer down, so AP-156's entire visible benefit was invisible until +#333 landed. Anything upstream of the query has to be correct before a gate on +the downstream math means anything. + +--- + +## The governing lesson from the last campaign + +**The register rows are leads, not specifications. Measure before you fix.** +The evidence, all from the last two weeks: + +- **AP-155(b) recorded the flood approximation as OVER-inclusive**, and used + that direction as the reason it was safe to defer. Measured, it was + UNDER-inclusive for 428 of 530 Setups — the opposite, and the dangerous + direction. +- **AP-156's risk column was wrong**, and its wrongness is precisely why #334 + — a user-visible loss of collision — sat inside it unnoticed. +- **AP-22 described an unreachable branch.** 0 of 5,935 installed Setups could + satisfy its guard. The correct fix was deletion, not a port. +- **#331's headline claim was refuted outright.** The behaviour was already + retail-faithful. + +So one row in four, in this exact domain, was materially wrong about its own +population, direction, or existence. **Every slice below opens with a +measurement that can cancel it.** A slice that measures its population at zero +closes as "row deleted", and that is a success, not a wasted slice. + +--- + +## Slice order + +### Pre-work — two cheap unblocks, before the campaign proper + +Both are the user's own outstanding reports and both are blocked on a +measurement that costs far less than the fix. Neither is a campaign slice. + +**PW-1 — #338, the step heights.** Setup `0x02000001` authors +`StepUpHeight = 0.600` / `StepDownHeight = 1.500`; the client resolves with +`0.400` / `0.400`. **First question is whether retail reads the authored field +at all** — grep `named-retail` for the step-height getters and their callers. +If retail substitutes its own constants, 0.4 is correct and #338 closes as a +non-defect. ~30 minutes. Do not touch code before that answer. + +**PW-2 — #32, local-player cliff edge-slide.** This is the *other half of the +original two-bug report* and the thing the user will feel most, so it does not +sit behind six slices. Research is already done (`38db9fff`): +`CollisionInfo.SetContactPlane` latches last-known at all 13 call sites where +retail's `COLLISIONINFO::set_contact_plane` @0x00509d80 — 22 bytes — never +does. Fix is ~20–25 lines, mostly deletion, in 2 files. **Blocked on a live +`ACDREAM_DUMP_EDGE_SLIDE=1` capture**: the report's six-row decision table has +three rows that redirect the fix entirely. Needs the user at the client. + +--- + +### S1 — The flood / membership pipeline + +**Rows:** AP-157, AP-156 residual, AP-159 / #335. +**Files:** `ShadowObjectRegistry.cs` (`BuildFloodSpheres`, `BuildBspPartSpheres`), +`CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm), +`ShadowShapeBuilder.cs`. +**One agent. Pinned contract. Not parallelised.** + +This is the walk-through direction and the largest single win in the list. + +- **AP-159 / #335** — indoors we admit an EnvCell neighbour on a *sphere* test + where retail hands the part array to each cell's own `find_transit_cells` and + tests every part's sphere against that cell's portal planes in cell-local + space. Port the part-array overload. This is the last of AP-156's traversal + residual; the outdoor half already closed with #334. +- **AP-157** — retail's third `calc_cross_cells` branch floods from ONE + `CPartArray::GetSortingSphere`; we flood from every Sphere shape. Our + cylinder flood also ignores `CylHeight`. +- **AP-156 residual** — we scale the flood sphere by entity/part scale; retail's + `CEnvCell::find_transit_cells` reads only `CPhysicsPart::pos` and never + `gfxobj_scale`. Note the asymmetry before changing anything: retail's cross-cell + walk is itself under-inclusive for scaled parts and ours is not, so "match + retail" here means **deliberately adopting a retail bug**. That is a decision + to make explicitly with the user, not silently — over-inclusive is safe, + under-inclusive is the walk-through direction. + +**Opens with:** an installed-DAT sweep giving each row its true population and +direction, measured against a DAT field that is not the one being fixed (the +non-circular-oracle rule that caught AP-156's identically-zero assertion). + +**Gate:** offline differential over installed DATs, plus one live indoor run — +a dungeon with tight rooms and a door. + +--- + +### S2 — Static publication shape fidelity + +**Row:** AP-155. **Files:** `LandblockPhysicsPublisher.cs`, +`LandblockPhysicsContentBuilder.cs`. + +The static-load paths emit an authored Setup Sphere as a height-capped +Cylinder. Different files from S1, so it is separable — but it must land +**after** S1, because S1's flood consumes what these paths produce, and +measuring S2's effect while S1 is in flight would confound both. + +**Gate:** shares S1's live indoor run if S1 and S2 land together; otherwise +offline only, since a shape substitution's population is fully measurable from +the DATs. + +--- + +### S3 — Animated collision pose + +**Row:** AP-84. **Files:** `LiveEntityDefaultPoseResolver.cs`, +`LiveEntityCollisionBuilder.cs`, `ShadowShapeBuilder.cs` (`partPoseOverride`). + +Server entities with a MotionTableId register their BSP part shapes at the +default style's first-cycle LowFrame pose and never update them; retail uses +the live `CPhysicsPart` pose. A door's collision therefore stays where the shut +door was. + +**Why not folded into S1:** different files, different subsystem (animation, not +membership), and it needs a *visual* gate that S1 does not — you have to watch +a door open and then walk through the doorway. + +**Gate:** live. Open a door, walk through, close it, walk into it. + +--- + +### S4 — Push-out math + +**Rows:** AD-65 + AD-66. **File:** `TransitionTypes.cs` (`AdjustOffset`) — both +in the same function, so one slice. + +- **AD-65** — the `collisionAngle > 0` arm substitutes `result -= N * angle` + for retail's `Plane::snap_to_plane`. Recorded effect: downhill XY travel short + by cos²θ (25% at 30°, 50% at 45°). +- **AD-66** — the safety push-out substitutes `radius * ContactPlane.Normal.Z` + for retail's bare `radius`, in both the trigger comparison and the `zDist` + numerator. + +**Correction carried in from the closeout:** AD-65 was previously described as +"a live lead for #269". That framing is **retracted** — #269 closed 2026-07-31 +on the user's own live gate, and AD-65's sign is *opposite* to that symptom. +AD-65 stands on its own merits. (#269's do-not-retry covers friction and jump +chains, which are byte-exonerated; `AdjustOffset` is a different function and +is not covered by it.) + +This is feel, not pass-through. It cannot be gated by a test asserting "did I +fall through" — it needs a movement-feel gate. + +--- + +### S5 — The Sledding flatness constant + +**Row:** AD-55. **File:** `PhysicsBody.cs` (`calc_friction`). + +We compare `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw decomp +literally computes `__fcos(0.17453292519943295)` = cos(10°) ≈ 0.984808. One of +the two is a decode artefact. **Resolve by byte-decoding the constant from the +PDB-paired binary** — there is a documented method for exactly this +(`reference_pe_byte_decode`), and it has already caught one inverted mapping +this project inherited from ACE. + +Cheap. **Batch its live gate with S4's** — both are movement feel on slopes, +and asking for two separate slope-feel sessions wastes the only genuinely +scarce resource in this campaign. + +--- + +### S6 — Containment, NOT a fix + +**Rows:** AP-83, AP-91. + +**These are not portable and should not be listed as fixes.** The PerfectClip +time-of-impact tails in `CCylSphere::collide_with_point` and +`CSphere::collide_with_point` are x87 sequences that do not decompile legibly; +we took them from ACE. There is no retail text to port. Pretending otherwise +would put a "port it" ticket in the backlog forever. + +The honest deliverable is **containment**: prove no production mover sets +PerfectClip, and add a guard or test that fails loudly if one ever does. Then +the rows describe a branch we can show is unreachable, which is the same +resolution AP-22 got. + +--- + +### Parallel track — #330, headless live-entity collision + +The headless host registers no live-entity collision at all: a bot walks +through every NPC and every server-spawned object. The graphical client is +unaffected. + +**Genuinely parallel.** Different host, and — unlike everything above — it +needs **no live gate from the user at all**, because the headless suite can +assert it directly. It is the one item that can proceed while the user is +unavailable, which makes it the right thing to pick up whenever a live gate is +blocking. + +--- + +## Gate economics + +The user's time at the client is the only scarce resource here. Live gates are +therefore **batched, not per-slice**: + +| Session | Covers | What to do in-world | +|---|---|---| +| A | PW-2 capture | drive the cliff edges that misbehave; capture only, no fix yet | +| B | S1 + S2 | a tight dungeon with a door; walk boundaries, jump, drop a corpse | +| C | S3 | doors: open, walk through, close, walk into | +| D | S4 + S5 | slopes: run down, run across, sled, land on inclines | + +Four sessions for the whole campaign. Everything else is offline. + +--- + +## Definition of done + +- Each row either **retired** with its evidence, or **rewritten** with a + corrected population/direction, or **deleted** as describing something that + does not exist. All three are acceptable outcomes. +- No row is closed on a test that re-computes the production expression as its + own oracle. Use an independent DAT field or an independent implementation. +- Every discriminating test is **sabotage-verified**: break the production line + and watch the test redden, in the same session it is written. +- `docs/ISSUES.md` and the divergence register updated in the **same commit** + as the code, per the register's own two binding rules. From bec5c69daf5c7f076db9bc1d814a0bed14d72cbc Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 22:37:43 +0200 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20correct=20the=20C5c=20handoff's=20"?= =?UTF-8?q?nothing=20is=20pushed"=20line=20=E2=80=94=20it=20was=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff's opening said "Nothing is pushed — the branch does not exist on the remote, and there are 388+ unpushed commits ahead of origin/main." All three clauses were wrong, in the direction that would most alarm a successor into thinking the campaign could be lost. main and github/main are both d4e956b4, and git branch --contains 7b3e2895 lists main. The campaign was merged and pushed the same day. The measurement error is recorded because it will recur: this repo has TWO remotes. github is live; origin (git.snakedesert.se) has a cached origin/main ref pointing at f6275f45 whose ref file has not been written since 2026-04-27. main is 412 commits ahead of that three-month-stale ref, which is where "388+ unpushed" came from. Measuring push state against origin/* here without checking the ref's age produces a false alarm every time. Also opens Campaign S as the ACTIVE work ahead of the remaining M4 vendor slices, per user direction: "I think its better to fix these kind of things before we add new stuff. Physics and collisions are vital." Clean-room gate at a0690947, all 43 bin/obj directories deleted first per the closeout's own rule 4: 11,231 passed / 4 skipped / 0 failed across all nine projects. Co-Authored-By: Claude Opus 5 --- .../2026-08-06-c5c-closeout-handoff.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/research/2026-08-06-c5c-closeout-handoff.md b/docs/research/2026-08-06-c5c-closeout-handoff.md index 173a6f10..07e5d6f8 100644 --- a/docs/research/2026-08-06-c5c-closeout-handoff.md +++ b/docs/research/2026-08-06-c5c-closeout-handoff.md @@ -5,8 +5,23 @@ applies and is repeated in §6). Branch `claude/acdream-physics-divergence-5aa784`, 21 commits from `02578441` -to `7b3e2895`. **Nothing is pushed** — the branch does not exist on the remote, -and there are 388+ unpushed commits ahead of `origin/main`. +to `7b3e2895`. + +**CORRECTED 2026-08-06 — the original line here said "Nothing is pushed — the +branch does not exist on the remote, and there are 388+ unpushed commits ahead +of `origin/main`". That was FALSE, and false in the direction that would most +alarm a successor.** The campaign was merged to `main` and pushed the same day: +`main` and `github/main` are both `d4e956b4`, and `git branch --contains +7b3e2895` lists `main`. + +**The measurement error, because it will recur.** This repo has TWO remotes. +`github` (`git@github.com:eriknihlen/acdream.git`) is the live one. `origin` +(`https://git.snakedesert.se/erik/acdream.git`) is a second remote whose cached +ref `origin/main` still points at `f6275f45` and whose ref file has not been +written since **April 27** — three months stale. `main` is 412 commits ahead of +that ref, which is where "388+ unpushed" came from. **Never measure push state +against `origin/*` in this repo without checking the ref's age first**; compare +against `github/main`, or check `git branch --contains `. ---