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 ## #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) **Severity:** MEDIUM (visible at distance during normal outdoor play)
**Filed:** 2026-06-12 (user report, post-#119-close session) **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 **Symptom (user):** "leakage of like doors and doorways through the
terrain and houses over a landblock" — door/doorway-shaped patches terrain and houses over a landblock" — door/doorway-shaped patches
visible THROUGH intervening terrain and nearer buildings when the visible THROUGH intervening terrain and nearer buildings when the
source building is roughly a landblock (~192 m) or more away. source building is roughly a landblock (~192 m) or more away.
**Leads:** **Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):**
1. **The #117 stencil depth-gate bias at long range (top suspect).** the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
#117's fix (`478c549`) marks aperture pixels at biased true depth non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye
(LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m
a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of but **~190 m at a landblock**: every hill/house in front of a distant
view depth — the bias can exceed the separation between the aperture aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
and a hill/house in front of it, marking occluder pixels and punching leak. Exactly AD-18's recorded "Risk if assumption breaks".
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.
**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot + **Fix (2026-06-12):** cap the bias's EYE-SPACE span —
player/eye position from [snap]/[viewer]); confirm whether the leak `biasNdc(d) = min(0.0005, 0.5 m × near / d²)`
patch matches an aperture polygon of the distant building; then test (`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader).
the eye-space-bias hypothesis headlessly (the #117 commit has the bias Below the ~10 m crossover the constant term wins, bit-identical to the
math). 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 ## #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) **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" — **Filed:** 2026-06-12 (user report, post-#119-close session)
possibly new since the W=0 clip port `987313a`) **Component:** render — doorway-slice scissor box math (AD-17 family)
**Component:** render — doorway aperture edge (seal/punch/OutsideView seam)
**Symptom (user):** standing inside looking out through a doorway, a **Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening. of the TOP of the doorway opening.
**Leads (capture first — plausibly a `987313a` regression):** **Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests`
1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon) — 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the
and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView `BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) +
region through a near doorway is computed slightly differently now. Ceiling(size)`) put the box's far edge at `floor(min)+ceil(maxmin)` —
If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional
shell edge, terrain/outdoor geometry isn't drawn in that strip while alignments. The scissor brackets the ENTIRE landscape slice (sky,
the interior seal/punch still cleared it → background color. terrain, statics, weather), the seal stamps the full aperture at true
Suspects within the port: `MergeSubPixelVertices` shaving a top depth, and the shell ends at the aperture edge — so the cut pixel row
vertex; the exact-w boundary vs the old epsilon shifting the never receives color: a background strip along the top edge that comes
projected edge; the deleted rescue no longer substituting the full and goes as the eye moves (alignment shifts). Captured live by the
view for an eye-pressed doorway. harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column
2. The interior SEAL depth vs the shell top edge (the #118-era 1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion
machinery) — a 1-px mismatch between the seal polygon and the shell safe, under-inclusion is the bug class).
aperture would show the clear color exactly at an edge.
**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing **Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the
the strip; diff the OutsideView top edge NDC vs the aperture polygon's same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion
projected top edge for that frame (the CornerFloodReplay harness merges → ClipPlaneSet planes) is sub-pixel exact against the raw
machinery can replay the frame headlessly once the eye/cell are aperture projection (worst 0.54 px; 0.00 px in the aligned case). For
captured). If it reproduces at the same doorway with `987313a` reverted an all-in-front doorway polygon the port is bit-identical to the old
locally, it's the port's edge math; fix the math, never re-add the path by construction (the W clip pass only runs when a vertex has
rescue. 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-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-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-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-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-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 | | 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. These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded 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 | | # | 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-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-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-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) | | 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. 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. 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. 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-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. **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-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. **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-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-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. **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-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. **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-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. **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. **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.
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.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** **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), 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 // 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 // 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 // disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
// NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle // (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
// passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear // (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
// to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window). // 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) private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb)
{ {
if (!apply || _window is null) return false; if (!apply || _window is null) return false;
var fb = _window.FramebufferSize; var fb = _window.FramebufferSize;
// NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
// 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);
_gl!.Enable(EnableCap.ScissorTest); _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; 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 int uPlaneCount;
uniform vec4 uPlanes[8]; uniform vec4 uPlanes[8];
uniform int uForceFarZ; 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]; out float gl_ClipDistance[8];
void main() void main()
{ {
@ -62,7 +63,14 @@ void main()
if (uForceFarZ == 1) if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0) 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; 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 _locPlanes;
private readonly int _locForceFarZ; private readonly int _locForceFarZ;
private readonly int _locDepthBias; private readonly int _locDepthBias;
private readonly int _locDepthBiasEyeCapN;
private const int MaxFanVerts = 32; private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3]; 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"); _locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ"); _locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias"); _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray(); _vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer(); _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 /// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must /// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not. /// 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> /// </summary>
private const float PunchMarkDepthBias = 0.0005f; 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> /// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the /// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects /// 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.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0); _gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias); _gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only; // ── 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) ───────────────────────────── // ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
/// <summary> /// <summary>
/// Return the run rate. Mirrors retail /// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0).
/// <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>. /// Mirrors retail <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
/// ///
/// <para> /// <para>
/// <b>Decomp (named-retail/acclient_2013_pseudo_c.txt:305127):</b> /// <b>The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12).</b>
/// <code> /// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127)
/// void get_max_speed(this) { /// renders this function as <c>void</c> with a bare <c>this->my_run_rate;</c>
/// weenie_obj = this->weenie_obj; /// statement because it drops x87 instructions — a known BN artifact class.
/// this_1 = nullptr; /// Disassembling the PDB-matched v11.4186 binary at VA <c>0x00527cb0</c>
/// if (weenie_obj == 0) return; /// shows all THREE return paths end with
/// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return; /// <c>fmul dword ptr [0x007C8918]</c>, and the .rdata dword at
/// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack /// <c>0x007C8918</c> is <c>0x40800000</c> = 4.0f (the sibling
/// } /// <c>get_adjusted_max_speed</c> 0x00527d00 carries the same trailing
/// </code> /// fmul). Re-derive with <c>py tools/verify_un2_fmul.py</c>. The three
/// Binary Ninja shows the return type as <c>void</c> because the float /// retail paths: weenie_obj == null → 1.0×4; InqRunRate success →
/// return rides the x87 FPU stack rather than EAX. Both branches /// queried×4; InqRunRate failure → my_run_rate×4. ACE's
/// emit an <c>fld</c> of either <c>this_1</c> (the InqRunRate /// MotionInterp.cs:665-676 ports it identically (RunAnimSpeed = 4.0f).
/// out-param value) or <c>my_run_rate</c>, leaving the run rate on
/// ST0 as the return value.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Critical:</b> this returns the BARE run rate (typically 1.0 to /// Consequence: the dead-reckoning catch-up speed
/// ~3.0), NOT a velocity in m/s. We previously multiplied by /// (<c>InterpolationManager::adjust_offset</c> 0x00555d30, pc:353122)
/// <c>RunAnimSpeed</c> to get a m/s value, reasoning that /// is <c>2 × get_max_speed()</c> ≈ 23.5 m/s for a run-rate-2.94
/// <c>2 × bare_rate</c> would be too slow a catch-up speed for the /// (run-skill-200) character — that IS retail's value. An earlier
/// caller (<c>InterpolationManager::adjust_offset</c>). That was a /// doc-comment here claimed the bare rate (~5.9 m/s catch-up) was
/// misread of the decomp — retail's catch-up IS that slow on purpose. /// retail-correct and blamed the ×4 for the multi-second 1-Hz blip on
/// The multi-second 1-Hz blip the user reported when observing retail /// observed retail remotes; that reading trusted the BN x87 dropout
/// remotes from acdream traced to body racing at the wrong (overshot) /// and is refuted by the binary. If the blip recurs, its root cause is
/// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s /// elsewhere (node-fail handling / progress-quantum abandonment /
/// for a run-skill-200 char). /// position-queue feed — the #41 family), NOT this multiply.
/// </para> /// </para>
/// </summary> /// </summary>
public float GetMaxSpeed() public float GetMaxSpeed()
{ {
// Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate. // Retail 0x00527cb0: weenie null → 1.0; InqRunRate ok → queried;
// Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678 // InqRunRate failed → my_run_rate. Every path × RunAnimSpeed (4.0,
// which is verified against retail (the ACE MotionInterp file is a // .rdata 0x007C8918). Note the weenie-null default is the LITERAL 1.0
// line-by-line port). Returns the maximum world-space velocity in m/s // (.rdata 0x007928B0), not my_run_rate.
// — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by float rate = 1.0f;
// InterpolationManager.AdjustOffset to compute the catch-up speed if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate))
// (= 2 × maxSpeed). rate = MyRunRate;
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
return RunAnimSpeed * rate; 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)] [InlineData(MotionCommand.RunForward)]
public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command) public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command)
{ {
// GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it deliberately // GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it
// returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand (see GetMaxSpeed's // returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand
// doc comment: the bare run rate × RunAnimSpeed, ACE MotionInterp.cs:670-678, retail-verified // (retail 0x00527cb0 never reads interpreted_state; UN-2 byte verification
// — the slow catch-up is intentional, it fixed the 1-Hz remote-blip). It does NOT branch // 2026-06-12, tools/verify_un2_fmul.py). These previously asserted a REMOVED
// per-command. These previously asserted a REMOVED command-branching design (WalkForward → // command-branching design (WalkForward → WalkAnimSpeed, WalkBackward →
// WalkAnimSpeed, WalkBackward → ×0.65, Idle → 0); that contract no longer exists, so they are // ×0.65, Idle → 0); they PIN the no-branch contract across commands.
// consolidated here to PIN the no-branch contract across commands (Phase W green-tests triage). var weenie = new FakeWeenie { RunRate = 1.75f };
var interp = MakeInterp(); var interp = MakeInterp(weenie: weenie);
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = command; interp.InterpretedState.ForwardCommand = command;
float speed = interp.GetMaxSpeed(); float speed = interp.GetMaxSpeed();
@ -862,17 +861,33 @@ public sealed class MotionInterpreterTests
} }
[Fact] [Fact]
public void GetMaxSpeed_RunForward_NoWeenie_FallsBackToMyRunRate() public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed()
{ {
// WeenieObj is null (MakeInterp with no weenie argument); MyRunRate // Retail 0x00527cb0 weenie_obj == null path: fld 1.0 (.rdata 0x007928B0),
// is set explicitly. GetMaxSpeed must use MyRunRate as the run-rate // fmul 4.0 (.rdata 0x007C8918) — the LITERAL 1.0, NOT my_run_rate (UN-2
// source when InqRunRate is unavailable. // byte verification 2026-06-12). MyRunRate is set to a different value to
// prove it is not consulted on this path.
var interp = MakeInterp(); var interp = MakeInterp();
interp.MyRunRate = 1.75f; interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed(); 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); 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))