Compare commits

...

3 commits

Author SHA1 Message Date
Erik
4ba714835d fix #129: cap the punch mark bias's eye-space reach (was unbounded at distance)
The user's "doors/doorways leak through terrain and houses over a
landblock" is the #117 mark-pass bias evaluated in the wrong space.

Mechanism (confirmed analytically, Issue129PunchBiasTests): the punch's
pass-A stencil mark biased the aperture fan toward the viewer by a
CONSTANT 0.0005 NDC. NDC depth is non-linear - a constant NDC bias b
spans ~= b*d^2*(f-n)/(f*n) meters of eye depth at eye distance d. With
retail's znear 0.1 (d4b5c71) that is 0.125 m at 5 m but ~190 m at one
landblock: every hill/house in front of a distant aperture passed the
LEQUAL mark and was far-Z punched -> door-shaped leak through the
occluder. This is exactly the risk AD-18's register row recorded
("an occluder within ~bias in front of a distant aperture gets punched
through") - the symptom-scan rule found it before instrumentation.

Fix: cap the bias's EYE-SPACE span at 0.5 m -
  biasNdc(d) = min(0.0005, capMeters * near / d^2)
in the mark-pass vertex shader (clipPos.w = eye depth), CPU-mirrored as
PortalDepthMaskRenderer.MarkBiasNdc for tests. Below the ~10 m
crossover the constant-NDC term is smaller and wins - bit-identical to
the T5-validated close-range behavior, so the #108 grass coverage that
justified the bias is untouched. Beyond it the punch can never reach an
occluder more than 0.5 m in front of the aperture plane.

Pins (Issue129PunchBiasTests): the old form spans >100 m of eye depth
at a landblock (the leak, kept as documentation of the refuted shape);
the capped form stays <= 0.5 m at every distance 1-400 m and matches
the validated constant bit-for-bit below 10 m.

AD-18 register row updated in the same commit (bias description + the
#129 closure + the residual risk note: door-hugging geometry beyond the
0.5 m cap at >10 m viewing range re-occludes - the cap constant is the
tuning knob if the gate shows residue).

Suites: App 256+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user visual gate at the original spot (+ #108 cellar
re-check up close).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:38:59 +02:00
Erik
6c4b6d64d9 fix #130: doorway-slice scissor cut the aperture's top/right pixel row
The user's "thin strip of background color along the TOP outer edge of a
doorway, looking out from inside" is the landscape-slice scissor box, not
the W=0 clip port.

Mechanism (pinned headlessly, Issue130DoorwayStripTests, 147 eye/gaze
combos at the real Holtburg A9B4 0x0170 exit door):
- BeginDoorwayScissor converted the slice NDC AABB to pixels as
  Floor(origin) + Ceiling(size). The far edge floor(min)+ceil(max-min)
  lands up to ONE PIXEL SHORT of the true top/right edge at unlucky
  fractional alignments (captured: top edge y=0.7938 @1080p -> row 968
  cut; right edge column 1296 @1920 cut).
- The scissor brackets the ENTIRE landscape slice (sky, terrain, outdoor
  statics, weather). The exit-portal SEAL stamps the full raw aperture at
  true depth and the shell wall ends at the aperture edge, so the cut row
  never receives any color write -> clear color, flickering with eye
  movement as the fractional alignment shifts.
- This violated AD-17's own invariant (over-inclusion is safe,
  UNDER-inclusion is the bug class). No register change: the fix restores
  the row's documented doctrine.

Lead 1 (987313a W=0 clip port regression) REFUTED by the same harness:
the CPU polygon pipeline (ProjectToClip -> ClipToRegion merges ->
ClipPlaneSet planes) is sub-pixel exact against the raw aperture
projection (worst 0.54 px, 0.00 px aligned). For an all-in-front doorway
polygon the port is bit-identical to the old 1e-4 path by construction.
The EyeInsidePortalOpening rescue stays deleted.

Fix: conservative outer bound floor(min)/ceil(max) extracted to
NdcScissorRect.ToPixels (GL-free; containment property proven in the
header comment); BeginDoorwayScissor delegates.

Pins:
- NdcScissorRectTests: center-inside containment across 251 fractional
  alignments x 2 framebuffer sizes + both captured regression cases.
- Issue130DoorwayStripTests: production flood + assembler at the real
  exit door; asserts the scissor never cuts a plane-admitted fragment
  (worstScissorGap 0.00 px post-fix, was 10.8 px capped) and the CPU
  pipeline stays sub-pixel exact (canary 1.2 px).

Suites: App 252+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user visual gate at a cottage doorway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:31:43 +02:00
Erik
0cb97aa594 UN-2 RESOLVED: GetMaxSpeed x4 is byte-verified retail; doc-comment was the misread
The register's UN-2 row recorded a contradiction: the GetMaxSpeed XML doc
claimed the bare run rate was retail-correct (~5.9 m/s catch-up, calling
the xRunAnimSpeed multiply a misread), while the implementation multiplied
by RunAnimSpeed citing ACE. Settled against the binary, not the pseudo-C:

- BN pseudo-C (acclient_2013_pseudo_c.txt:305127) renders get_max_speed as
  void with a bare `this->my_run_rate;` because it DROPS x87 instructions.
- Disassembling the PDB-matched v11.4186 binary at VA 0x00527cb0: all THREE
  return paths end `fld <rate>; fmul dword ptr [0x007C8918]; ret`, and the
  .rdata dword at 0x007C8918 is 4.0f. Sibling get_adjusted_max_speed
  (0x00527d00) carries the same trailing fmul. Verifier committed at
  tools/verify_un2_fmul.py (PE parse + byte decode, rerunnable).
- Retail paths: weenie null -> 1.0 x4; InqRunRate ok -> queried x4;
  InqRunRate failed -> my_run_rate x4. ACE MotionInterp.cs:665-676 matches.

Changes:
- Doc-comment rewritten: the implementation is retail-correct; the catch-up
  speed 2 x get_max_speed ~= 23.5 m/s at run 200 IS retail. The 1-Hz
  remote-blip symptom the old comment attributed to this multiply is
  therefore UNEXPLAINED by it (if it recurs: #41 family, not this).
- Weenie-null path aligned to retail's LITERAL 1.0 default (was MyRunRate).
- Tests re-pinned to the three retail paths (the old NoWeenie test pinned
  the non-retail fallback).
- Register: UN-2 row deleted per the retire rule (6 -> 5 UN rows);
  shortlist renumbered.

This is the 2nd confirmed instance of the BN x87-dropout artifact class
(memory: feedback_bn_decomp_field_names) deciding a register row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:17:50 +02:00
11 changed files with 742 additions and 131 deletions

View file

@ -4477,73 +4477,82 @@ staircase entity's per-frame draw decision.
## #129 — Doors/doorways leak through terrain and houses from over a landblock away
**Status:** OPEN
**Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** MEDIUM (visible at distance during normal outdoor play)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — aperture depth punch at distance (#117 family)
**Component:** render — aperture depth punch at distance (#117 family, AD-18)
**Symptom (user):** "leakage of like doors and doorways through the
terrain and houses over a landblock" — door/doorway-shaped patches
visible THROUGH intervening terrain and nearer buildings when the
source building is roughly a landblock (~192 m) or more away.
**Leads:**
1. **The #117 stencil depth-gate bias at long range (top suspect).**
#117's fix (`478c549`) marks aperture pixels at biased true depth
(LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With
a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of
view depth — the bias can exceed the separation between the aperture
and a hill/house in front of it, marking occluder pixels and punching
them → the occluder shows the interior/background behind. The #108
coverage constraint pulls the bias up; distance pulls it wrong —
re-derive the bias in eye-space (or scale by w) instead of constant
NDC.
2. Per-building look-in floods admitting distant buildings (the #127
churn family) — would gate WHICH buildings punch, not the
through-occluder leak itself.
**Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):**
the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye
depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m
but **~190 m at a landblock**: every hill/house in front of a distant
aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
leak. Exactly AD-18's recorded "Risk if assumption breaks".
**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot +
player/eye position from [snap]/[viewer]); confirm whether the leak
patch matches an aperture polygon of the distant building; then test
the eye-space-bias hypothesis headlessly (the #117 commit has the bias
math).
**Fix (2026-06-12):** cap the bias's EYE-SPACE span —
`biasNdc(d) = min(0.0005, 0.5 m × near / d²)`
(`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader).
Below the ~10 m crossover the constant term wins, bit-identical to the
T5-validated behavior (#108 grass coverage untouched); beyond it the
punch can never reach an occluder more than 0.5 m in front of the
aperture plane. Pins: `Issue129PunchBiasTests` (old form spans >100 m
at a landblock; capped form ≤0.5 m at all distances; close range
unchanged).
**Gate:** the original spot — distant building doors no longer show
through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep
stays gone up close. If a >10 m-range #108-class residue appears, the
cap constant (0.5 m) is the tuning knob — see AD-18.
---
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
**Status:** OPEN
**Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game)
**Filed:** 2026-06-12 (user report, post-#119-close session; "also NOW" —
possibly new since the W=0 clip port `987313a`)
**Component:** render — doorway aperture edge (seal/punch/OutsideView seam)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — doorway-slice scissor box math (AD-17 family)
**Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening.
**Leads (capture first — plausibly a `987313a` regression):**
1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon)
and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView
region through a near doorway is computed slightly differently now.
If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn
shell edge, terrain/outdoor geometry isn't drawn in that strip while
the interior seal/punch still cleared it → background color.
Suspects within the port: `MergeSubPixelVertices` shaving a top
vertex; the exact-w boundary vs the old epsilon shifting the
projected edge; the deleted rescue no longer substituting the full
view for an eye-pressed doorway.
2. The interior SEAL depth vs the shell top edge (the #118-era
machinery) — a 1-px mismatch between the seal polygon and the shell
aperture would show the clear color exactly at an edge.
**Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests`
— 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the
`BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) +
Ceiling(size)`) put the box's far edge at `floor(min)+ceil(maxmin)` —
up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional
alignments. The scissor brackets the ENTIRE landscape slice (sky,
terrain, statics, weather), the seal stamps the full aperture at true
depth, and the shell ends at the aperture edge — so the cut pixel row
never receives color: a background strip along the top edge that comes
and goes as the eye moves (alignment shifts). Captured live by the
harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column
1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion
safe, under-inclusion is the bug class).
**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing
the strip; diff the OutsideView top edge NDC vs the aperture polygon's
projected top edge for that frame (the CornerFloodReplay harness
machinery can replay the frame headlessly once the eye/cell are
captured). If it reproduces at the same doorway with `987313a` reverted
locally, it's the port's edge math; fix the math, never re-add the
rescue.
**Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the
same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion
merges → ClipPlaneSet planes) is sub-pixel exact against the raw
aperture projection (worst 0.54 px; 0.00 px in the aligned case). For
an all-in-front doorway polygon the port is bit-identical to the old
path by construction (the W clip pass only runs when a vertex has
w < 0).
**Fix:** conservative outer bound `floor(min)/ceil(max)` extracted to
`NdcScissorRect.ToPixels` (GL-free, unit-tested); `BeginDoorwayScissor`
delegates. Pins: `NdcScissorRectTests` (containment property + both
captured alignments) + `Issue130DoorwayStripTests` (scissor never cuts
plane-admitted fragments; CPU-pipeline exactness canary ≤1.2 px).
**Gate:** stand inside any cottage, look out the door, sweep the gaze —
no background strip at the top edge at any alignment.
---

View file

@ -79,7 +79,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with invented `PunchMarkDepthBias = 0.0005` NDC; retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders (hills, closer buildings), painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Bias is depth-dependent: an occluder within ~bias in front of a distant aperture gets punched through; door-plane-hugging geometry just beyond it re-occludes the aperture (a **#108**-class regression) | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
@ -167,7 +167,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 5. Unclear (UN) — 6 rows
## 5. Unclear (UN) — 5 rows
These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded
@ -176,7 +176,6 @@ equivalence argument (promote to AD/AP) or a fix.
| # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-2 | `GetMaxSpeed`: XML doc asserts the bare run rate is retail-correct (~5.9 m/s catch-up; the ×RunAnimSpeed multiply "a misread" → ~23.5 m/s), yet the implementation multiplies by RunAnimSpeed citing ACE as retail-verified. The two recorded justifications CONTRADICT — one describes the current code as known-wrong | `src/AcDream.Core/Physics/MotionInterpreter.cs:972` | None coherent — doc and code disagree about which behavior is retail | If the bare-rate reading is right, remote-entity catch-up runs ~4× retail speed — the multi-second 1-Hz blip / racing-remote symptom the doc itself records | `CMotionInterp::get_max_speed` pc:305127; catch-up :353122 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
@ -192,20 +191,19 @@ phase-gated — they carry their trigger in their row and should land
WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **UN-2 — GetMaxSpeed contradiction** — the file argues against its own implementation; if the bare-rate reading is right, remote catch-up runs ~4× retail. Settle with one decomp re-read + a cdb catch-up trace; cheap to resolve, expensive to leave.
3. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
4. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
5. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
6. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
7. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
8. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
9. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
10. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
11. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
12. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
13. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
14. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
15. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),

View file

@ -9954,26 +9954,18 @@ public sealed class GameWindow : IDisposable
// Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in
// framebuffer pixels and enable the scissor test; returns true iff applied (the caller then
// disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode
// NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle
// passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear
// to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window).
// disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
// (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
// (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
// bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel
// off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip.
private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb)
{
if (!apply || _window is null) return false;
var fb = _window.FramebufferSize;
// NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge
// still yields a valid box (same clamp the terrain Scissor path uses).
float nx0 = System.Math.Clamp(ndcAabb.X, -1f, 1f);
float ny0 = System.Math.Clamp(ndcAabb.Y, -1f, 1f);
float nx1 = System.Math.Clamp(ndcAabb.Z, -1f, 1f);
float ny1 = System.Math.Clamp(ndcAabb.W, -1f, 1f);
int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X);
int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y);
int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X);
int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y);
var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
_gl!.Enable(EnableCap.ScissorTest);
_gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph));
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}

View file

@ -0,0 +1,45 @@
// NdcScissorRect.cs
//
// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
// the slice's view region (AD-17 in the divergence register): it must CONTAIN
// every fragment the per-fragment plane clip would keep. Under-inclusion is
// the bug class — the #130 doorway top-edge background strip was this box
// computed as Floor(origin) + Ceiling(size), whose far edge
// floor(min)+ceil(maxmin) lands up to one pixel SHORT of the true max edge
// at unlucky fractional alignments, scissoring away the aperture's top/right
// pixel row for the whole slice (sky, terrain, statics, weather) while the
// seal still stamps it — a strip of clear color no later pass can fill.
//
// Correct outer bound: floor both mins, ceil both maxes, width = difference.
// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
// i ≥ X00.5 ⇒ i ≥ floor(X0) and i ≤ X10.5 ⇒ i < ceil(X1). So
// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
// plane clip repaints or kills the surplus).
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
public static class NdcScissorRect
{
/// <summary>Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a
/// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to
/// the screen so a region extending past an edge still yields a valid box.
/// Width/height are at least 1.</summary>
public static (int X, int Y, int Width, int Height) ToPixels(
Vector4 ndcAabb, int fbWidth, int fbHeight)
{
float nx0 = Math.Clamp(ndcAabb.X, -1f, 1f);
float ny0 = Math.Clamp(ndcAabb.Y, -1f, 1f);
float nx1 = Math.Clamp(ndcAabb.Z, -1f, 1f);
float ny1 = Math.Clamp(ndcAabb.W, -1f, 1f);
int px0 = (int)MathF.Floor((nx0 * 0.5f + 0.5f) * fbWidth);
int py0 = (int)MathF.Floor((ny0 * 0.5f + 0.5f) * fbHeight);
int px1 = (int)MathF.Ceiling((nx1 * 0.5f + 0.5f) * fbWidth);
int py1 = (int)MathF.Ceiling((ny1 * 0.5f + 0.5f) * fbHeight);
return (px0, py0, Math.Max(1, px1 - px0), Math.Max(1, py1 - py0));
}
}

View file

@ -52,7 +52,8 @@ uniform mat4 uViewProjection;
uniform int uPlaneCount;
uniform vec4 uPlanes[8];
uniform int uForceFarZ;
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
out float gl_ClipDistance[8];
void main()
{
@ -62,7 +63,14 @@ void main()
if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0)
clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
{
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
// constant-NDC form alone reached METERS at distance (door-shaped
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
clipPos.z -= biasNdc * clipPos.w;
}
gl_Position = clipPos;
}";
@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private readonly int _locDepthBiasEyeCapN;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not.
/// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary>
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
/// ~190 m at a landblock away: every hill/house in front of a distant
/// aperture passed the mark and got far-Z punched — door-shaped leaks
/// through occluders. Fix: cap the bias's EYE-SPACE span at
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
/// bit-identical to the T5-validated close-range behavior (#108 grass
/// coverage untouched); beyond it the punch can never reach an occluder
/// more than the cap in front of the aperture plane.
/// </summary>
public const float PunchMarkBiasEyeCapMeters = 0.5f;
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
/// d4b5c71). The cap conversion below assumes the production camera near
/// plane; the small f/(fn) factor (~1.00002 at far 5000) is ignored.</summary>
public const float CameraNearPlaneMeters = 0.1f;
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
/// <paramref name="eyeDepthMeters"/>.</summary>
public static float MarkBiasNdc(float eyeDepthMeters) =>
MathF.Min(PunchMarkDepthBias,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
/// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;

View file

@ -935,52 +935,47 @@ public sealed class MotionInterpreter
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
/// <summary>
/// Return the run rate. Mirrors retail
/// <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
/// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0).
/// Mirrors retail <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
///
/// <para>
/// <b>Decomp (named-retail/acclient_2013_pseudo_c.txt:305127):</b>
/// <code>
/// void get_max_speed(this) {
/// weenie_obj = this->weenie_obj;
/// this_1 = nullptr;
/// if (weenie_obj == 0) return;
/// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return;
/// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack
/// }
/// </code>
/// Binary Ninja shows the return type as <c>void</c> because the float
/// return rides the x87 FPU stack rather than EAX. Both branches
/// emit an <c>fld</c> of either <c>this_1</c> (the InqRunRate
/// out-param value) or <c>my_run_rate</c>, leaving the run rate on
/// ST0 as the return value.
/// <b>The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12).</b>
/// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127)
/// renders this function as <c>void</c> with a bare <c>this->my_run_rate;</c>
/// statement because it drops x87 instructions — a known BN artifact class.
/// Disassembling the PDB-matched v11.4186 binary at VA <c>0x00527cb0</c>
/// shows all THREE return paths end with
/// <c>fmul dword ptr [0x007C8918]</c>, and the .rdata dword at
/// <c>0x007C8918</c> is <c>0x40800000</c> = 4.0f (the sibling
/// <c>get_adjusted_max_speed</c> 0x00527d00 carries the same trailing
/// fmul). Re-derive with <c>py tools/verify_un2_fmul.py</c>. The three
/// retail paths: weenie_obj == null → 1.0×4; InqRunRate success →
/// queried×4; InqRunRate failure → my_run_rate×4. ACE's
/// MotionInterp.cs:665-676 ports it identically (RunAnimSpeed = 4.0f).
/// </para>
///
/// <para>
/// <b>Critical:</b> this returns the BARE run rate (typically 1.0 to
/// ~3.0), NOT a velocity in m/s. We previously multiplied by
/// <c>RunAnimSpeed</c> to get a m/s value, reasoning that
/// <c>2 × bare_rate</c> would be too slow a catch-up speed for the
/// caller (<c>InterpolationManager::adjust_offset</c>). That was a
/// misread of the decomp — retail's catch-up IS that slow on purpose.
/// The multi-second 1-Hz blip the user reported when observing retail
/// remotes from acdream traced to body racing at the wrong (overshot)
/// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s
/// for a run-skill-200 char).
/// Consequence: the dead-reckoning catch-up speed
/// (<c>InterpolationManager::adjust_offset</c> 0x00555d30, pc:353122)
/// is <c>2 × get_max_speed()</c> ≈ 23.5 m/s for a run-rate-2.94
/// (run-skill-200) character — that IS retail's value. An earlier
/// doc-comment here claimed the bare rate (~5.9 m/s catch-up) was
/// retail-correct and blamed the ×4 for the multi-second 1-Hz blip on
/// observed retail remotes; that reading trusted the BN x87 dropout
/// and is refuted by the binary. If the blip recurs, its root cause is
/// elsewhere (node-fail handling / progress-quantum abandonment /
/// position-queue feed — the #41 family), NOT this multiply.
/// </para>
/// </summary>
public float GetMaxSpeed()
{
// Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate.
// Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678
// which is verified against retail (the ACE MotionInterp file is a
// line-by-line port). Returns the maximum world-space velocity in m/s
// — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by
// InterpolationManager.AdjustOffset to compute the catch-up speed
// (= 2 × maxSpeed).
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
// Retail 0x00527cb0: weenie null → 1.0; InqRunRate ok → queried;
// InqRunRate failed → my_run_rate. Every path × RunAnimSpeed (4.0,
// .rdata 0x007C8918). Note the weenie-null default is the LITERAL 1.0
// (.rdata 0x007928B0), not my_run_rate.
float rate = 1.0f;
if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate))
rate = MyRunRate;
return RunAnimSpeed * rate;
}

View file

@ -0,0 +1,68 @@
using System;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #129 — doors/doorways leak through terrain and houses from over a landblock
/// away. The punch's mark pass (#117, AD-18) biased the aperture fan toward
/// the viewer by a CONSTANT 0.0005 NDC. NDC depth is non-linear: a constant
/// NDC bias b spans ≈ b·d²·(fn)/(f·n) meters of eye depth at eye distance d
/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant
/// occluders in front of an aperture passed the mark and were far-Z punched:
/// the door-shaped leak. The fix caps the bias's eye-space span
/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant
/// below the ~10 m crossover, never more than the cap beyond it.
/// </summary>
public class Issue129PunchBiasTests
{
private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear)
private const float Far = 5000f;
/// <summary>Eye-depth span (meters) covered by an NDC depth bias b at eye
/// distance d: ndc(d) = f(dn)/((fn)d) ⇒ d(ndc) inverse ⇒
/// span = b·d²·(fn)/(f·n) (exact for small b via the derivative).</summary>
private static float EyeSpanMeters(float biasNdc, float d) =>
biasNdc * d * d * (Far - Near) / (Far * Near);
[Fact]
public void OldConstantBias_SpansMetersAtALandblock_TheLeak()
{
// The refuted form (documentation of WHY the constant was wrong):
// 0.0005 NDC at ~one landblock spans far more eye depth than any
// occluder separation — everything in front got punched.
Assert.True(EyeSpanMeters(0.0005f, 192f) > 100f);
// ...while at close range it was a sane sliver:
Assert.InRange(EyeSpanMeters(0.0005f, 5f), 0.05f, 0.30f);
}
[Fact]
public void CappedBias_MatchesValidatedConstant_AtCloseRange()
{
// Below the crossover the T5-validated constant must win unchanged —
// this preserves the #108 grass coverage bit-for-bit.
foreach (float d in new[] { 0.5f, 1f, 3f, 5f, 8f, 9.9f })
Assert.Equal(0.0005f, PortalDepthMaskRenderer.MarkBiasNdc(d), 6);
}
[Fact]
public void CappedBias_EyeSpanNeverExceedsCap_AtAnyDistance()
{
for (float d = 1f; d <= 400f; d += 1f)
{
float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(d), d);
Assert.True(span <= PortalDepthMaskRenderer.PunchMarkBiasEyeCapMeters * 1.02f,
FormattableString.Invariant($"bias spans {span:F2} m of eye depth at d={d} m"));
}
}
[Fact]
public void CappedBias_At200m_CannotReachOccluders()
{
// The reported #129 distance: occluder separations are tens of
// meters; the punch reach must stay under the 0.5 m cap.
float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(200f), 200f);
Assert.True(span <= 0.51f, FormattableString.Invariant($"span {span:F3} m at 200 m"));
}
}

View file

@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #130 — background-color strip along the TOP outer edge of a doorway when
/// looking out from inside. Mechanism model (2026-06-12 evidence sweep): for
/// an interior root the SEAL stamps the FULL raw dat portal polygon at true
/// depth (PortalDepthMaskRenderer, root-cell slice = full screen), while
/// terrain/sky COLOR is gated per fragment by the OutsideView region — the
/// same dat polygon run through ProjectToClip → ClipToRegion (1-px
/// MergeSubPixelVertices) → ClipPlaneSet.From (0.5° collinear merge) → planes,
/// with a Floor/Ceil pixel scissor (BeginDoorwayScissor) on the slice AABB on
/// top. Every one of those passes can only SHRINK the gate, so any shave shows
/// as a strip of clear color between the gate's top edge and the aperture's
/// rasterized top edge (the shell wall starts above it; the seal z-kills
/// everything beyond; nothing re-covers).
///
/// This harness measures that gap headlessly at the real Holtburg corner
/// building exit door (A9B4 0x0170, the HouseExitWalkReplay door): project the
/// aperture, run the production flood + assembler, then walk sample points
/// just inside the aperture's top edge downward until the gate admits them.
/// Plane-gap and scissor-gap are measured separately (mechanism attribution).
///
/// VERDICT (2026-06-12, 147 eye/gaze combos): the CPU polygon pipeline is
/// sub-pixel exact (worst 0.54 px) — the W=0 clip port 987313a and both merge
/// passes are EXONERATED. The strip was the scissor box: the old
/// Floor(origin)+Ceiling(size) form cut up to 1 px off the TOP/RIGHT edges at
/// unlucky fractional alignments (captured live by this harness: top edge
/// y=0.7938 at 1080p → row 968 cut; right edge x=0.3503 at 1920 → column 1296
/// cut). Fixed by the conservative NdcScissorRect bound; the assertions below
/// pin both properties.
/// </summary>
public class Issue130DoorwayStripTests
{
private readonly ITestOutputHelper _out;
public Issue130DoorwayStripTests(ITestOutputHelper output) => _out = output;
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
// Production projection convention (CornerFloodReplayTests.ViewProjFor):
// FovY 1.2 rad, 1280x720 viewport, near 1, far 5000. The flood clip is
// near-independent so near/far exactness is not load-bearing.
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
{
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
return view * proj;
}
[Fact]
public void Diagnostic_ExitDoorTopEdge_GateVsAperture()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = CornerFloodReplayTests.LoadBuilding(dats);
var root = cells[ExitCellId];
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
// Find the exit portal (OtherCellId == 0xFFFF) and its world polygon.
int exitIdx = -1;
for (int i = 0; i < root.Portals.Count; i++)
{
if (root.Portals[i].OtherCellId == 0xFFFF && i < root.PortalPolygons.Count
&& root.PortalPolygons[i].Length >= 3)
{ exitIdx = i; break; }
}
Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon");
var localPoly = root.PortalPolygons[exitIdx];
var worldPoly = new Vector3[localPoly.Length];
for (int i = 0; i < localPoly.Length; i++)
worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
Vector3 centroid = Vector3.Zero;
foreach (var w in worldPoly) centroid += w;
centroid /= worldPoly.Length;
// Inward direction: the portal plane normal signed toward the cell
// interior (ClipPlanes carries InsideSide from the load).
var plane = root.ClipPlanes[exitIdx];
var worldNormal = Vector3.TransformNormal(plane.Normal, root.WorldTransform);
var cellCenterWorld = Vector3.Transform(
(root.LocalBoundsMin + root.LocalBoundsMax) * 0.5f, root.WorldTransform);
if (Vector3.Dot(worldNormal, cellCenterWorld - centroid) < 0)
worldNormal = -worldNormal;
worldNormal = Vector3.Normalize(worldNormal);
_out.WriteLine(FormattableString.Invariant(
$"exit portal idx={exitIdx} verts={localPoly.Length} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) inward=({worldNormal.X:F2},{worldNormal.Y:F2},{worldNormal.Z:F2})"));
for (int i = 0; i < worldPoly.Length; i++)
_out.WriteLine(FormattableString.Invariant(
$" poly[{i}] world=({worldPoly[i].X:F3},{worldPoly[i].Y:F3},{worldPoly[i].Z:F3})"));
float worstPlaneGapPx = 0f, worstScissorGapPx = 0f;
string worstDesc = "(none)";
// Eye sweep: back off the doorway along the inward normal at several
// distances/heights/lateral offsets; gaze at the centroid plus raised /
// lowered targets (NDC alignment of the top edge varies with gaze).
var lateral = Vector3.Normalize(Vector3.Cross(worldNormal, Vector3.UnitZ));
float[] dists = { 0.6f, 1.0f, 1.6f, 2.4f, 3.5f };
float[] heights = { 0.9f, 1.4f, 1.7f };
float[] laterals = { -0.8f, 0f, 0.8f };
float[] gazeRaise = { -0.4f, 0f, 0.4f, 0.9f };
int evaluated = 0;
foreach (float d in dists)
foreach (float h in heights)
foreach (float lat in laterals)
foreach (float gz in gazeRaise)
{
var eye = centroid + worldNormal * d + lateral * lat;
eye.Z = centroid.Z - 1.0f + h; // door centroid sits mid-opening; bias to floor-ish
var look = centroid + new Vector3(0, 0, gz);
var viewProj = ViewProjFor(eye, look);
// Aperture truth: the seal's footprint = the raw polygon's projection.
var clip = new Vector4[worldPoly.Length];
float minW = float.MaxValue;
for (int i = 0; i < worldPoly.Length; i++)
{
clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj);
minW = MathF.Min(minW, clip[i].W);
}
if (minW <= 0.05f) continue; // eye in/behind the door plane — out of #130's scenario
var aperture = new Vector2[clip.Length];
for (int i = 0; i < clip.Length; i++)
aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W);
var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
if (asm.OutsideViewSlices.Length == 0)
{
_out.WriteLine(FormattableString.Invariant(
$"d={d} h={h} lat={lat} gz={gz}: NO outside slice (outPolys={pv.OutsideView.Polygons.Count})"));
continue;
}
evaluated++;
(float planeGapPx, float scissorGapPx, float atX) =
MeasureTopEdgeGap(aperture, asm.OutsideViewSlices, 1920, 1080);
if (planeGapPx > worstPlaneGapPx || scissorGapPx > worstScissorGapPx)
{
worstDesc = FormattableString.Invariant(
$"d={d} h={h} lat={lat} gz={gz} minW={minW:F2} atX={atX:F3} slices={asm.OutsideViewSlices.Length} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)} apVerts={aperture.Length}");
worstPlaneGapPx = MathF.Max(worstPlaneGapPx, planeGapPx);
worstScissorGapPx = MathF.Max(worstScissorGapPx, scissorGapPx);
}
if (planeGapPx > 0.55f || scissorGapPx > 0.55f)
{
_out.WriteLine(FormattableString.Invariant(
$"GAP d={d} h={h} lat={lat} gz={gz}: planeGap={planeGapPx:F2}px scissorGap={scissorGapPx:F2}px atX={atX:F3} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)}"));
float apTop = TopBoundaryY(aperture, atX);
foreach (var slice in asm.OutsideViewSlices)
_out.WriteLine(FormattableString.Invariant(
$" slice slot={slice.Slot} planes={slice.Planes.Length} aabb=({slice.NdcAabb.X:F4},{slice.NdcAabb.Y:F4},{slice.NdcAabb.Z:F4},{slice.NdcAabb.W:F4}) apTopAtX={apTop:F4}"));
foreach (var poly in pv.OutsideView.Polygons)
{
var sb = new System.Text.StringBuilder(" outPoly:");
foreach (var v in poly.Vertices)
sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(sb.ToString());
}
}
}
_out.WriteLine(FormattableString.Invariant(
$"evaluated={evaluated} worstPlaneGapPx={worstPlaneGapPx:F2} worstScissorGapPx={worstScissorGapPx:F2} @ {worstDesc}"));
Assert.True(evaluated > 100, $"sweep degenerated: only {evaluated} eye/gaze combos evaluated");
// PIN 1 (#130): the scissor box never cuts a fragment the plane gate
// admits — conservative containment (AD-17's over-include doctrine).
// One probe step is ~0.11 px; anything beyond it is a real cut row.
Assert.True(worstScissorGapPx <= 0.15f, FormattableString.Invariant(
$"scissor under-covers the plane-admitted region by {worstScissorGapPx:F2}px @ {worstDesc}"));
// PIN 2 (canary): the CPU polygon pipeline (ProjectToClip → ClipToRegion
// merges → ClipPlaneSet planes) stays sub-pixel exact against the raw
// aperture projection. Observed 0.54 px worst (2026-06-12); the
// production vertex-merge floor is ~1 px — beyond 1.2 px means a new
// under-inclusion shaver entered the pipeline.
Assert.True(worstPlaneGapPx <= 1.2f, FormattableString.Invariant(
$"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}"));
}
private static string DescribePolys(CellView view)
{
var parts = new List<string>();
foreach (var p in view.Polygons) parts.Add(p.Vertices.Length.ToString());
return $"[{string.Join(",", parts)}]";
}
/// <summary>
/// For sample x positions across the aperture's projected top edge, find the
/// aperture boundary's top y, then walk downward until the gate admits the
/// point. Returns the worst gaps in 1080p pixels (plane gate and modeled
/// scissor gate measured independently), and the x of the worst plane gap.
/// </summary>
private static (float planeGapPx, float scissorGapPx, float atX) MeasureTopEdgeGap(
Vector2[] aperture, ClipViewSlice[] slices, int fbW, int fbH,
ITestOutputHelper? debug = null)
{
const float Inset = 1e-4f; // dodge exact-boundary ambiguity
const float StepY = 0.0002f; // ~0.1 px at 1080p
const float CapY = 0.02f; // stop searching beyond ~10 px
float minX = float.MaxValue, maxX = float.MinValue;
foreach (var v in aperture) { minX = MathF.Min(minX, v.X); maxX = MathF.Max(maxX, v.X); }
float span = maxX - minX;
if (span <= 0.01f) return (0, 0, 0);
float worstPlane = 0, worstScissor = 0, atX = 0;
const int Samples = 160;
for (int s = 0; s <= Samples; s++)
{
float x = minX + span * (0.01f + 0.98f * s / Samples);
if (MathF.Abs(x) > 0.98f) continue; // off screen — no pixel exists there
float topY = TopBoundaryY(aperture, x);
if (float.IsNaN(topY) || MathF.Abs(topY) > 0.98f) continue; // off screen / no boundary
var p = new Vector2(x, topY - Inset);
float planeGap = GapBelow(p, q => AnySliceAdmitsPlanes(slices, q), StepY, CapY);
// The scissor question is "does the box cut pixels the PLANES would
// draw" — measure it from the planes-admitted top, not the aperture
// top (at slanted corners the aperture top can sit legitimately
// outside the gate polygon's column).
var pPlanes = new Vector2(p.X, p.Y - planeGap - Inset);
float scissorGap = GapBelow(pPlanes, q => AnySliceAdmitsScissor(slices, q, fbW, fbH), StepY, CapY);
if (debug is not null && scissorGap > 0.005f)
debug.WriteLine(FormattableString.Invariant(
$" sample x={x:F4} apTop={topY:F4} planeGap={planeGap * fbH / 2f:F2}px pPlanes=({pPlanes.X:F4},{pPlanes.Y:F4}) scissorGap={scissorGap * fbH / 2f:F2}px"));
if (planeGap > worstPlane) { worstPlane = planeGap; atX = x; }
worstScissor = MathF.Max(worstScissor, scissorGap);
}
// NDC y → pixels at the given framebuffer height.
return (worstPlane * fbH / 2f, worstScissor * fbH / 2f, atX);
}
private static float GapBelow(Vector2 start, Func<Vector2, bool> admitted, float step, float cap)
{
if (admitted(start)) return 0f;
for (float dy = step; dy <= cap; dy += step)
{
if (admitted(new Vector2(start.X, start.Y - dy)))
return dy;
}
return cap;
}
// Production semantics: each OutsideView polygon is one slice; the union of
// slices is drawn. A slice with planes gates per fragment via
// gl_ClipDistance (dot((nx,ny,0,d),(x,y,z,1)) >= 0 for an NDC point);
// a planeless slice (scissor fallback) admits its whole NDC AABB.
private static bool AnySliceAdmitsPlanes(ClipViewSlice[] slices, Vector2 p)
{
foreach (var slice in slices)
{
if (slice.Planes.Length == 0)
{
if (p.X >= slice.NdcAabb.X && p.Y >= slice.NdcAabb.Y
&& p.X <= slice.NdcAabb.Z && p.Y <= slice.NdcAabb.W)
return true;
continue;
}
bool inside = true;
foreach (var pl in slice.Planes)
{
if (pl.X * p.X + pl.Y * p.Y + pl.W < 0f) { inside = false; break; }
}
if (inside) return true;
}
return false;
}
// Production scissor (BeginDoorwayScissor → NdcScissorRect.ToPixels): a
// point is admitted when its pixel falls inside some slice's scissor box.
private static bool AnySliceAdmitsScissor(ClipViewSlice[] slices, Vector2 p, int fbW, int fbH)
{
int pixX = (int)MathF.Floor((p.X * 0.5f + 0.5f) * fbW);
int pixY = (int)MathF.Floor((p.Y * 0.5f + 0.5f) * fbH);
foreach (var slice in slices)
{
var box = NdcScissorRect.ToPixels(slice.NdcAabb, fbW, fbH);
if (pixX >= box.X && pixX < box.X + box.Width
&& pixY >= box.Y && pixY < box.Y + box.Height)
return true;
}
return false;
}
/// <summary>Highest boundary y of the polygon at vertical line x (NaN when
/// the line misses the polygon).</summary>
private static float TopBoundaryY(Vector2[] poly, float x)
{
float best = float.NaN;
for (int i = 0; i < poly.Length; i++)
{
var a = poly[i];
var b = poly[(i + 1) % poly.Length];
if (MathF.Abs(a.X - b.X) < 1e-9f)
{
if (MathF.Abs(a.X - x) < 1e-6f)
{
float hi = MathF.Max(a.Y, b.Y);
if (float.IsNaN(best) || hi > best) best = hi;
}
continue;
}
float t = (x - a.X) / (b.X - a.X);
if (t < 0f || t > 1f) continue;
float y = a.Y + t * (b.Y - a.Y);
if (float.IsNaN(best) || y > best) best = y;
}
return best;
}
}

View file

@ -0,0 +1,80 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #130: the doorway-slice scissor must be a CONSERVATIVE outer bound of its
/// NDC AABB (AD-17: over-inclusion safe, under-inclusion is the bug class).
/// The old Floor(origin)+Ceiling(size) form put the far edge at
/// floor(min)+ceil(maxmin), up to one pixel short of the true max edge —
/// the doorway top-edge background strip.
/// </summary>
public class NdcScissorRectTests
{
/// <summary>Containment property: every pixel whose CENTER lies inside the
/// NDC box is inside the scissor box, across a dense grid of fractional
/// alignments at two framebuffer sizes.</summary>
[Theory]
[InlineData(1920, 1080)]
[InlineData(2560, 1440)]
public void EveryCenterInsidePixel_IsInsideTheBox(int fbW, int fbH)
{
for (int i = 0; i < 251; i++)
{
// Sweep fractional alignments of all four edges.
float f = i / 251f;
float minX = -0.83f + f * 0.0031f;
float minY = -0.71f + f * 0.0047f;
float maxX = 0.339f + f * 0.0043f;
float maxY = 0.7938f + f * 0.0029f;
var box = NdcScissorRect.ToPixels(new Vector4(minX, minY, maxX, maxY), fbW, fbH);
// Pixel-space extremes of center-inside pixels.
float x0 = (minX * 0.5f + 0.5f) * fbW, x1 = (maxX * 0.5f + 0.5f) * fbW;
float y0 = (minY * 0.5f + 0.5f) * fbH, y1 = (maxY * 0.5f + 0.5f) * fbH;
int loX = (int)MathF.Ceiling(x0 - 0.5f), hiX = (int)MathF.Floor(x1 - 0.5f);
int loY = (int)MathF.Ceiling(y0 - 0.5f), hiY = (int)MathF.Floor(y1 - 0.5f);
Assert.True(box.X <= loX, $"left cut: box.X={box.X} > loX={loX} (minX={minX})");
Assert.True(box.Y <= loY, $"bottom cut: box.Y={box.Y} > loY={loY} (minY={minY})");
Assert.True(box.X + box.Width > hiX, $"right cut: box ends {box.X + box.Width} <= hiX={hiX} (maxX={maxX})");
Assert.True(box.Y + box.Height > hiY, $"top cut: box ends {box.Y + box.Height} <= hiY={hiY} (maxY={maxY})");
// Over-inclusion stays bounded (≤1 px per edge).
Assert.True(box.X >= loX - 1 && box.Y >= loY - 1);
Assert.True(box.X + box.Width <= hiX + 2 && box.Y + box.Height <= hiY + 2);
}
}
[Fact]
public void CapturedRegression_TopEdgeRow968_At1080p()
{
// Issue130DoorwayStripTests live capture: aperture top y=0.7938 →
// pixel row 968 (center 968.5 < 968.65). The old formula ended the box
// at row 967 — the visible strip.
var box = NdcScissorRect.ToPixels(new Vector4(-0.339f, -0.743f, 0.339f, 0.7938f), 1920, 1080);
Assert.True(box.Y + box.Height > 968, $"top row 968 cut: box ends at {box.Y + box.Height}");
}
[Fact]
public void CapturedRegression_RightColumn1296_At1920()
{
// Issue130DoorwayStripTests live capture: gate right edge x=0.3507 →
// pixel column 1296 admitted by the plane gate; the old formula ended
// the box at column 1295.
var box = NdcScissorRect.ToPixels(new Vector4(-0.2845f, -1.0f, 0.3507f, 0.2630f), 1920, 1080);
Assert.True(box.X + box.Width > 1296, $"right column 1296 cut: box ends at {box.X + box.Width}");
}
[Fact]
public void DegenerateAndOffscreenBoxes_StayValid()
{
// Past-the-edge regions clamp to the screen and keep min 1 px size.
var box = NdcScissorRect.ToPixels(new Vector4(0.999f, 0.999f, 1.5f, 1.5f), 1920, 1080);
Assert.True(box.Width >= 1 && box.Height >= 1);
var inverted = NdcScissorRect.ToPixels(new Vector4(1f, 1f, -1f, -1f), 1920, 1080);
Assert.True(inverted.Width >= 1 && inverted.Height >= 1);
}
}

View file

@ -845,15 +845,14 @@ public sealed class MotionInterpreterTests
[InlineData(MotionCommand.RunForward)]
public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command)
{
// GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it deliberately
// returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand (see GetMaxSpeed's
// doc comment: the bare run rate × RunAnimSpeed, ACE MotionInterp.cs:670-678, retail-verified
// — the slow catch-up is intentional, it fixed the 1-Hz remote-blip). It does NOT branch
// per-command. These previously asserted a REMOVED command-branching design (WalkForward →
// WalkAnimSpeed, WalkBackward → ×0.65, Idle → 0); that contract no longer exists, so they are
// consolidated here to PIN the no-branch contract across commands (Phase W green-tests triage).
var interp = MakeInterp();
interp.MyRunRate = 1.75f;
// GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it
// returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand
// (retail 0x00527cb0 never reads interpreted_state; UN-2 byte verification
// 2026-06-12, tools/verify_un2_fmul.py). These previously asserted a REMOVED
// command-branching design (WalkForward → WalkAnimSpeed, WalkBackward →
// ×0.65, Idle → 0); they PIN the no-branch contract across commands.
var weenie = new FakeWeenie { RunRate = 1.75f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = command;
float speed = interp.GetMaxSpeed();
@ -862,17 +861,33 @@ public sealed class MotionInterpreterTests
}
[Fact]
public void GetMaxSpeed_RunForward_NoWeenie_FallsBackToMyRunRate()
public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed()
{
// WeenieObj is null (MakeInterp with no weenie argument); MyRunRate
// is set explicitly. GetMaxSpeed must use MyRunRate as the run-rate
// source when InqRunRate is unavailable.
// Retail 0x00527cb0 weenie_obj == null path: fld 1.0 (.rdata 0x007928B0),
// fmul 4.0 (.rdata 0x007C8918) — the LITERAL 1.0, NOT my_run_rate (UN-2
// byte verification 2026-06-12). MyRunRate is set to a different value to
// prove it is not consulted on this path.
var interp = MakeInterp();
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.0f, speed, precision: 4);
}
[Fact]
public void GetMaxSpeed_InqRunRateFails_FallsBackToMyRunRate()
{
// Retail 0x00527cb0 InqRunRate-failure path: fld [esi+0x7c] (my_run_rate),
// fmul 4.0. The InqRunRate out-value is discarded on failure.
var weenie = new FakeWeenie { RunRate = 9.9f, InqRunRateResult = false };
var interp = MakeInterp(weenie: weenie);
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4);
}
}

40
tools/verify_un2_fmul.py Normal file
View file

@ -0,0 +1,40 @@
# UN-2 verification: prove/disprove that retail CMotionInterp::get_max_speed
# (VA 0x00527cb0) multiplies by the 4.0f constant at VA 0x007C8918 on its
# return paths (the fmul the BN pseudo-C drops). Throwaway apparatus.
import struct
p = r"C:\Turbine\Asheron's Call\acclient.exe"
data = open(p, 'rb').read()
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
nsec = struct.unpack_from('<H', data, pe_off + 6)[0]
opt_size = struct.unpack_from('<H', data, pe_off + 20)[0]
sec0 = pe_off + 24 + opt_size
imgbase = struct.unpack_from('<I', data, pe_off + 24 + 28)[0]
def va2off(va):
rva = va - imgbase
for i in range(nsec):
o = sec0 + i * 40
name = data[o:o + 8].rstrip(b'\x00').decode()
vsz, vaddr, rsz, roff = struct.unpack_from('<IIII', data, o + 8)
if vaddr <= rva < vaddr + max(vsz, rsz):
return roff + (rva - vaddr), name
return None, None
print('imgbase', hex(imgbase))
off, sec = va2off(0x00527CB0)
print('get_max_speed VA 0x527cb0 -> file', hex(off), 'sec', sec)
code = data[off:off + 0x50]
print('bytes:', code.hex())
FMUL = bytes.fromhex('d80d18897c00') # fmul dword ptr [0x007C8918]
print('fmul [0x7C8918] count in get_max_speed:', code.count(FMUL))
off2, sec2 = va2off(0x007C8918)
print('dword @0x7C8918 sec', sec2, '=', struct.unpack_from('<f', data, off2)[0])
off3, sec3 = va2off(0x007928B0)
print('dword @0x7928B0 sec', sec3, '=', struct.unpack_from('<f', data, off3)[0])
off4, _ = va2off(0x00527D00)
code4 = data[off4:off4 + 0x70]
print('get_adjusted_max_speed fmul count:', code4.count(FMUL))