refactor(rendering): delete superseded visibility probes
Delete the callerless portal-BFS research graph and spent renderer probe families while retaining the production RetailFrameWalk path, terrain diagnostics, membership invariant, and walk transcript. Mutation evidence (all restored): 1. Restored PortalVisibilityBuilder type -> AppAssembly_ContainsNoSupersededPortalGraphTypes first failed Assert.Empty with AcDream.App.Rendering.PortalVisibilityBuilder. 2. Restored ACDREAM_PROBE_FACILITY_STAIRS -> ProductionSource_ContainsNoDeletedRendererProbe_AndRetainsWalkTranscriptProof first failed Assert.Empty on RenderingDiagnostics.cs. 3. Added a second RetailFrameWalk field -> WalkFrameOwners_AreUnique first failed Assert.Single with _frameWalk and _mutatedSecondFrameWalk. 4. Added OrderBy to OrderedStream -> OrderedWalkStream_HasNoCrossStreamReorder first failed Assert.DoesNotContain on OrderBy(. 5. Added IDatReaderWriter parameter -> FrameTimeWalkOwners_HaveNoRawDatDependency first failed Assert.Empty on RetailFrameWalk.MutatedRawDatParameter.
This commit is contained in:
parent
b77989c323
commit
bf53e2ad6e
82 changed files with 451 additions and 10108 deletions
|
|
@ -1102,8 +1102,10 @@ rooting and portal recursion. Its root is the collided camera/viewer cell or
|
|||
retail's synthetic outdoor cell; the player's current cell separately owns
|
||||
sunlight and indoor lighting. A null root exists only for login/debug/streaming-
|
||||
gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are
|
||||
composition/pass facades, and `PortalVisibilityBuilder` has no production
|
||||
caller. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render
|
||||
composition/pass facades. The superseded `PortalVisibilityBuilder`,
|
||||
`IndoorDrawPlan`, and `ViewconeCuller` research path was deleted in Campaign
|
||||
OVERHAUL S5-c5; production has one visibility graph and one walk owner.
|
||||
**Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render
|
||||
and physics cross-cell membership one canonical owner:**
|
||||
`ShadowObjectRegistry` computes retail's exact CELLARRAY
|
||||
(`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per
|
||||
|
|
@ -1123,23 +1125,21 @@ cross-cell membership only. Full detail:
|
|||
`docs/architecture/worldbuilder-inventory.md`'s "Retail static render-shadow
|
||||
cell membership" / "Consumer cutover to a borrowed per-cell view" entries.
|
||||
|
||||
**Current draw discipline and known non-retail order.** The walk records an
|
||||
**Current draw discipline and bounded residuals.** The walk records an
|
||||
ordered event stream for landscape cells, building portals, EnvCell shells,
|
||||
statics, dynamics, particles, depth boundaries, and alpha barriers, then
|
||||
`WalkFrameDriver` replays it through the Vulkan leaves. Private portal/
|
||||
paperdoll viewports and retained UI execute after the world stream, and
|
||||
projectiles remain ordinary live-entity draws. Three current details are
|
||||
explicit Campaign OVERHAUL divergences, not accepted retail contracts:
|
||||
|
||||
- terrain is staged before all per-cell building/object turns instead of
|
||||
retail's `DrawLandCell` then `DrawSortCell` interleave for each land cell;
|
||||
- depth clear/seal events are emitted for zero-outside-view roots and do not
|
||||
consume retail's persistent previous-call `uint16 portalsDrawnCount` gate;
|
||||
- ordinary object and particle parts now receive retail's stable per-cell
|
||||
authored-CYpt order before entering the two FIFO mesh lists, which drain
|
||||
CLIP before ALPHA at exact barriers. The retained differences are AP-241
|
||||
(opaque/row-5 GPU channels), AP-242 (equal cross-source tie ordinal), and
|
||||
AP-243 (retail's >50 m shared-cell-key optimization).
|
||||
projectiles remain ordinary live-entity draws. Campaign OVERHAUL S3/S4 landed
|
||||
retail's per-landcell terrain turn followed by that cell's sort-cell turn,
|
||||
outside-view/latch-gated clear and seal events, and the two FIFO lists (bounded
|
||||
CLIP and ALPHA) drained at their exact barriers. The remaining explicit deviations
|
||||
are AP-238 (EnvCell token granularity), AP-239 (collapsed raw surface-mask
|
||||
origin), AP-240 (ordinary Gfx/building pure-Clip immediate A2C placement),
|
||||
AP-241 (opaque object and row-5 particle GPU channels), AP-242 (equal-key
|
||||
cross-source tie ordinal), and AP-243 (retail's >50 m shared-cell-key
|
||||
optimization). They are bounded residuals, not a second visibility or ordering
|
||||
model.
|
||||
|
||||
The exact target and evidence are recorded in
|
||||
`docs/research/2026-09-01-overhaul/oh1-retail-world-contract.md`; OH5, OH6, and
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -230,10 +230,9 @@ matching retail's own rule that an object not yet in a cell is not drawn.
|
|||
Every distinct entity id this happens for in one frame is counted once in
|
||||
`WalkProductionWorldData.UnregisteredRenderMembershipCount` (renamed from
|
||||
`UnregisteredStaticRenderFallbackCount`, which under the pre-chunk-5 model
|
||||
also covered the separate dynamic-record fallback) and reported by one
|
||||
print-only `[walk-membership]` line — gated on
|
||||
`RenderingDiagnostics.ProbeFacilityStairsEnabled`, the walk family's
|
||||
existing diagnostic flag — at the start of the next frame when nonzero.
|
||||
also covered the separate dynamic-record fallback). The count remains a cheap
|
||||
per-frame invariant available to tests and diagnostics consumers; Campaign
|
||||
OVERHAUL S5-c5 deleted its spent print-only Facility probe and next-frame log.
|
||||
A primitive-only Setup may reuse its already-authored cylsphere collision
|
||||
cell set; BSP-bearing and pure-visual statics use the all-visual-part box
|
||||
walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
|
||||
|
|
|
|||
|
|
@ -249,13 +249,6 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
|
|||
| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class |
|
||||
| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 |
|
||||
| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` |
|
||||
| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) |
|
||||
| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) |
|
||||
| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` |
|
||||
| `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` |
|
||||
| `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family |
|
||||
| `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` |
|
||||
|
|
@ -264,7 +257,6 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
|
|||
| `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2–120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` |
|
||||
| `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) |
|
||||
| `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` |
|
||||
| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` |
|
||||
| `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) |
|
||||
| `ACDREAM_DUMP_CELLS` | `=<comma list of hex cell ids>` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage) for the physics replay harness; roundtrip-tested. Not investigation-scoped. | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | off/unset | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) |
|
||||
| `ACDREAM_DUMP_CELLS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` |
|
||||
|
|
@ -313,25 +305,17 @@ issue is closed, the strip was missed; delete both.
|
|||
| `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` |
|
||||
| `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` |
|
||||
| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` |
|
||||
| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-scis]` lines: outside-slice clip routing + region-SSBO bytes, actual GL/RHI scissor state (S3 review fix round 1, F5: the third producer, `[clip-route-disp]` — WbDrawDispatcher's per-entity clip-slot routing histogram — is deleted with the routing state it reported on) | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` |
|
||||
| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` |
|
||||
| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B |
|
||||
| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` |
|
||||
| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` |
|
||||
| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` |
|
||||
| `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` |
|
||||
| `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` |
|
||||
| `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` |
|
||||
| `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` |
|
||||
| `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` |
|
||||
| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` |
|
||||
| `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` |
|
||||
| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` |
|
||||
| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=<comma-separated hex GUID list>` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) |
|
||||
| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` |
|
||||
| `ACDREAM_PROBE_FACILITY_STAIRS` | #177 Facility Hub stair probe (committed by the `b3b7d922` investigation checkpoint) | `=1` | traces the classifier admission, ordered-stream append, ring upload, and final indirect draw of the authored stair GfxObj `0x010000DE` and the local-player setup parts in cells `0x8A02015E/015F/01C1`, plus the walk root signature for those cells; Campaign OVERHAUL S2 chunk 5 added a `[walk-membership]` line, printed once at the start of a frame only when `WalkProductionWorldData.UnregisteredRenderMembershipCount` was nonzero the PRIOR frame (an entity the registry flooded into a cell but the presentation scene could not resolve yet that frame) | print-only; never influences admission, order, or depth state; scheduled for deletion in Campaign OVERHAUL S5 | `RenderingDiagnostics.ProbeFacilityStairsEnabled` (read sites in `RetailPViewRenderer`, `WalkPView`, `WalkProductionWorldData`, `WalkStaticStreamPopulator`, `WbDrawDispatcher.*`) |
|
||||
| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` |
|
||||
| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` |
|
||||
| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` |
|
||||
| `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` |
|
||||
|
|
@ -358,7 +342,7 @@ from the "must still exist" check.
|
|||
| `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative |
|
||||
| `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none |
|
||||
| `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none |
|
||||
| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` |
|
||||
| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | none |
|
||||
| `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none |
|
||||
| `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
| `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none |
|
||||
|
|
|
|||
|
|
@ -358,15 +358,8 @@ internal sealed class FrameRootCompositionPhase
|
|||
{
|
||||
// Campaign V slice V6j: the world scene is composed on BOTH arms.
|
||||
// Every renderer below now exists on Vulkan too; what forks is one
|
||||
// pass surface, one state restorer, one GL-state reader, and the
|
||||
// three renderers that stay raw GL until their own slices — sky,
|
||||
// particles and the portal depth mask, which the executors already
|
||||
// accept as absent.
|
||||
WorldRenderDiagnostics worldRenderDiagnostics =
|
||||
host.WorldRenderDiagnostics
|
||||
?? new WorldRenderDiagnostics(
|
||||
NullRenderGlStateReader.Instance,
|
||||
d.RenderDiagnosticLog);
|
||||
new(d.RenderDiagnosticLog);
|
||||
IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance;
|
||||
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
|
||||
IWorldPassSurface worldPassSurface = new RhiWorldPassSurface(
|
||||
|
|
@ -445,10 +438,8 @@ internal sealed class FrameRootCompositionPhase
|
|||
live.ParticleRenderer,
|
||||
live.PortalDepthMask,
|
||||
d.RetailAlphaQueue,
|
||||
worldRenderDiagnostics,
|
||||
terrainDrawDiagnostics);
|
||||
var worldSceneDiagnostics = new WorldSceneDiagnosticsController(
|
||||
worldRenderDiagnostics,
|
||||
new RuntimeWorldScenePViewDiagnosticSource(
|
||||
d.PlayerController,
|
||||
d.PhysicsEngine,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ internal sealed record HostInputCameraResult(
|
|||
IRenderFrameSlotSource FrameSlots,
|
||||
IGpuDevice GpuDevice,
|
||||
GpuDeviceFrameLifetime GpuFrameLifetime,
|
||||
WorldRenderDiagnostics? WorldRenderDiagnostics,
|
||||
SilkKeyboardSource? KeyboardSource,
|
||||
SilkMouseSource? MouseSource,
|
||||
IMouseLookCursor? MouseLookCursor,
|
||||
|
|
@ -86,11 +85,6 @@ internal interface IHostInputCameraCompositionFactory
|
|||
GpuFrameFlightController? frameFlights,
|
||||
IGpuDevice device);
|
||||
|
||||
/// <summary>The raw-GL state tripwire, or null on a backend that has no GL state.</summary>
|
||||
WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
|
||||
GameWindowGraphics graphics,
|
||||
IRenderFrameDiagnosticLog log);
|
||||
|
||||
SilkKeyboardSource CreateKeyboardSource(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence);
|
||||
|
|
@ -250,11 +244,6 @@ internal sealed class HostInputCameraCompositionPhase :
|
|||
var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
|
||||
_publication.PublishGpuFrameLifetime(gpuFrameLifetime);
|
||||
|
||||
WorldRenderDiagnostics? diagnostics =
|
||||
_factory.CreateWorldRenderDiagnostics(
|
||||
graphics,
|
||||
_dependencies.RenderDiagnosticLog);
|
||||
|
||||
IKeyboard? firstKeyboard = input.Keyboards.FirstOrDefault();
|
||||
IMouse? firstMouse = input.Mice.FirstOrDefault();
|
||||
SilkKeyboardSource? keyboard = null;
|
||||
|
|
@ -356,7 +345,6 @@ internal sealed class HostInputCameraCompositionPhase :
|
|||
frameSlots,
|
||||
gpuDevice,
|
||||
gpuFrameLifetime,
|
||||
diagnostics,
|
||||
keyboard,
|
||||
mouse,
|
||||
cursor,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ namespace AcDream.App.Composition;
|
|||
///
|
||||
/// <para><b>What is absent, and why.</b> There is no GL fence ring: the RHI
|
||||
/// device owns its own frames-in-flight through a timeline semaphore, so
|
||||
/// retirement and slot indexing come from the device instead. There is no
|
||||
/// <see cref="WorldRenderDiagnostics"/>: it was a raw-GL state tripwire, and
|
||||
/// Vulkan has no global state for it to watch. Both nulls are read by the
|
||||
/// phases that would otherwise consume them.</para>
|
||||
/// retirement and slot indexing come from the device instead.</para>
|
||||
/// </summary>
|
||||
internal sealed class VulkanHostInputCameraCompositionFactory
|
||||
: IHostInputCameraCompositionFactory
|
||||
|
|
@ -57,10 +54,6 @@ internal sealed class VulkanHostInputCameraCompositionFactory
|
|||
IGpuDevice device) =>
|
||||
new VulkanRenderFrameSlotSource(RequireContext(graphics).Device);
|
||||
|
||||
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
|
||||
GameWindowGraphics graphics,
|
||||
IRenderFrameDiagnosticLog log) => null;
|
||||
|
||||
public SilkKeyboardSource CreateKeyboardSource(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
|
|
|
|||
|
|
@ -89,8 +89,7 @@ public sealed class LoadedCell
|
|||
/// CEnvCell.stab_list (acclient.h ~30925), the stable set of cells potentially
|
||||
/// visible from this cell, precomputed by the AC content tools. Refreshed only at
|
||||
/// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878).
|
||||
/// PortalVisibilityBuilder grounds set membership in it so a brittle per-frame
|
||||
/// portal-side test can't drop a potentially-visible cell from the visible set.
|
||||
/// The frame walk uses it to ground authored potentially-visible membership.
|
||||
/// Empty when the dat carried no stab list (degenerate / old cell).
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> VisibleCells = System.Array.Empty<uint>();
|
||||
|
|
@ -105,9 +104,8 @@ public sealed class LoadedCell
|
|||
|
||||
/// <summary>
|
||||
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
|
||||
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a flood-graph cell whose
|
||||
/// shell is the landscape. <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView
|
||||
/// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
|
||||
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a walk root whose
|
||||
/// shell is the landscape. The frame assembler seeds its outside view full-screen.
|
||||
/// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
|
||||
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
|
||||
/// </summary>
|
||||
|
|
@ -168,10 +166,8 @@ public struct PortalClipPlane
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked
|
||||
/// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained
|
||||
/// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that
|
||||
/// still prints <see cref="LastCameraCellResolution"/> (always None post-Stage 3).
|
||||
/// Historical result describing which branch of the former camera-cell resolver ran.
|
||||
/// The retained physics-membership route always reports <see cref="None"/>.
|
||||
/// </summary>
|
||||
public enum CameraCellResolution
|
||||
{
|
||||
|
|
@ -219,7 +215,7 @@ public sealed class CellVisibility
|
|||
/// <summary>
|
||||
/// Stage 3 (2026-06-02): always <see cref="CameraCellResolution.None"/> — the FindCameraCell
|
||||
/// AABB grace-frame resolver was deleted; the physics membership answer is the sole root.
|
||||
/// Retained for the [flap-cam] probe log line in GameWindow.cs.
|
||||
/// Retained as a stable diagnostic result for existing frame records.
|
||||
/// </summary>
|
||||
public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// ClipFrameAssembler.cs
|
||||
//
|
||||
// Retail view assembly policy. The production frame walk supplies its
|
||||
// outside_view directly; the legacy PortalVisibilityBuilder overload remains
|
||||
// only for isolated research/replay tests. Each visible polygon is packed as
|
||||
// an individual GPU clip slot.
|
||||
// outside_view directly. Each visible polygon is packed as an individual GPU
|
||||
// clip slot.
|
||||
//
|
||||
// outside_view landscape slices
|
||||
// reverse cell_draw_list exit masks
|
||||
|
|
@ -19,34 +18,10 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// S3 chunk 4 fix round 2 (L2): retired from the walk's own clip assembly —
|
||||
/// <see cref="ClipFrameAssembly"/> no longer tracks a terrain clip mode at
|
||||
/// all, because the walk draws the sky, terrain and weather unclipped, one
|
||||
/// call each, matching retail (<c>LScape::draw</c> never installs a view
|
||||
/// before any of the three). The type stays only because
|
||||
/// <see cref="WorldSceneRenderer"/>'s separate flat-world safety path still
|
||||
/// uses it for its own unrelated "did the flat terrain draw this frame"
|
||||
/// diagnostic flag (<c>Planes</c> = drew, <c>Skip</c> = the PView walk ran
|
||||
/// instead). S3 landing hygiene (H4) deleted the <c>Scissor</c> member: no
|
||||
/// producer ever wrote it (<see cref="WorldSceneRenderer"/> only ever
|
||||
/// assigns <c>Planes</c> or <c>Skip</c>), because there is no GPU scissor
|
||||
/// consumer left anywhere in this contract, walk or flat.
|
||||
/// </summary>
|
||||
public enum TerrainClipMode
|
||||
{
|
||||
/// <summary>The flat-world path drew terrain this frame.</summary>
|
||||
Planes,
|
||||
|
||||
/// <summary>The PView walk ran instead of the flat-world path this frame;
|
||||
/// flat terrain was not drawn.</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is
|
||||
/// retained for diagnostics (<see cref="WorldRenderDiagnostics"/>) only — no
|
||||
/// GPU pass reads it; there is no scissor consumer left in the walk.
|
||||
/// retained as part of the exact outside-view reconstruction proof — no GPU
|
||||
/// pass reads it; there is no scissor consumer left in the walk.
|
||||
///
|
||||
/// <para>S3 review fix round 1 (F1): <paramref name="NothingVisible"/>
|
||||
/// distinguishes the "this walk view's own polygon collapsed" case (retail
|
||||
|
|
@ -95,7 +70,7 @@ public sealed class ClipFrameAssembly
|
|||
public Dictionary<uint, int> PerCellPlaneCounts { get; } = new();
|
||||
public int ScissorFallbacks { get; internal set; }
|
||||
|
||||
// The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the
|
||||
// The assembly is frame-scoped. An owner may pass it back to reuse the
|
||||
// dictionaries, per-cell arrays, and construction list after the prior frame is consumed.
|
||||
// Exact-length pools keep the public array API honest: Length is always the live slice count.
|
||||
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
|
||||
|
|
@ -279,8 +254,7 @@ public sealed class ClipFrameAssembly
|
|||
public static class ClipFrameAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts one production walk assembly without constructing a parallel
|
||||
/// PortalVisibilityFrame. Outdoor roots begin with retail's full-screen
|
||||
/// Starts the one production walk assembly. Outdoor roots begin with retail's full-screen
|
||||
/// default view; interior roots begin empty and are populated from
|
||||
/// <see cref="Walk.RetailFrameWalk.InteriorOutsideView"/> after Collect.
|
||||
/// </summary>
|
||||
|
|
@ -321,109 +295,6 @@ public static class ClipFrameAssembler
|
|||
return assembly;
|
||||
}
|
||||
|
||||
public static ClipFrameAssembly Assemble(
|
||||
ClipFrame frame,
|
||||
PortalVisibilityFrame pvFrame,
|
||||
ClipFrameAssembly? reuseAssembly = null)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(frame);
|
||||
System.ArgumentNullException.ThrowIfNull(pvFrame);
|
||||
|
||||
frame.Reset();
|
||||
ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly();
|
||||
assembly.Reset(frame);
|
||||
|
||||
Dictionary<uint, int> cellIdToSlot = assembly.CellIdToSlot;
|
||||
Dictionary<uint, int[]> cellIdToViewSlots = assembly.CellIdToViewSlots;
|
||||
Dictionary<uint, ClipViewSlice[]> cellIdToViewSlices = assembly.CellIdToViewSlices;
|
||||
Dictionary<uint, int> perCellPlaneCounts = assembly.PerCellPlaneCounts;
|
||||
int scissorFallbacks = 0;
|
||||
|
||||
foreach (uint cellId in pvFrame.OrderedVisibleCells)
|
||||
{
|
||||
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
|
||||
continue;
|
||||
|
||||
List<ClipViewSlice> slices = assembly.SliceScratch;
|
||||
slices.Clear();
|
||||
int maxPlaneCount = 0;
|
||||
|
||||
foreach (var poly in view.Polygons)
|
||||
{
|
||||
var cps = ClipPlaneSet.From(poly);
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
|
||||
int slot;
|
||||
Vector4[] planes;
|
||||
if (cps.Count > 0)
|
||||
{
|
||||
planes = cps.PlaneArray;
|
||||
slot = frame.AppendSlot(planes);
|
||||
if (cps.Count > maxPlaneCount)
|
||||
maxPlaneCount = cps.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
planes = System.Array.Empty<Vector4>();
|
||||
slot = 0;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||
}
|
||||
|
||||
if (slices.Count == 0)
|
||||
continue;
|
||||
|
||||
ClipViewSlice[] sliceArray = assembly.CopySlices(slices);
|
||||
cellIdToViewSlices[cellId] = sliceArray;
|
||||
cellIdToViewSlots[cellId] = assembly.CopySlots(sliceArray);
|
||||
cellIdToSlot[cellId] = sliceArray[0].Slot;
|
||||
perCellPlaneCounts[cellId] = maxPlaneCount;
|
||||
}
|
||||
|
||||
List<ClipViewSlice> outsideSlicesList = assembly.SliceScratch;
|
||||
outsideSlicesList.Clear();
|
||||
int outsideMaxPlaneCount = 0;
|
||||
bool outsideHasScissorFallback = false;
|
||||
|
||||
foreach (var poly in pvFrame.OutsideView.Polygons)
|
||||
{
|
||||
AppendOutsideSlice(
|
||||
frame,
|
||||
poly,
|
||||
outsideSlicesList,
|
||||
ref outsideMaxPlaneCount,
|
||||
ref outsideHasScissorFallback,
|
||||
ref scissorFallbacks);
|
||||
}
|
||||
|
||||
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
|
||||
bool outdoorVisible = outsideViewSlices.Length > 0;
|
||||
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
|
||||
|
||||
Vector4 outsideViewNdcAabb = outdoorVisible
|
||||
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
|
||||
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
|
||||
: Vector4.Zero;
|
||||
|
||||
assembly.SetOutsideViewSlices(outsideViewSlices);
|
||||
assembly.OutdoorSlot = outdoorSlot;
|
||||
assembly.OutdoorVisible = outdoorVisible;
|
||||
assembly.HasOutsideView = outdoorVisible;
|
||||
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
|
||||
// S3 chunk 4 fix round 2 (L2): equivalent to the deleted
|
||||
// terrainMode == TerrainClipMode.Planes gate without needing
|
||||
// TerrainClipMode at all — a scissor-fallback slice can only exist
|
||||
// when outdoorVisible is already true (both branches above append a
|
||||
// slice), so this reduces to the same three cases (no slices -> 0;
|
||||
// any scissor fallback -> 0; all-planes -> outsideMaxPlaneCount).
|
||||
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
|
||||
assembly.ScissorFallbacks = scissorFallbacks;
|
||||
return assembly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FW4 slice 1 — the interior root's outside-view cutover.
|
||||
/// Replaces the assembly's outside-view block (slices, terrain mode,
|
||||
|
|
@ -433,13 +304,8 @@ public static class ClipFrameAssembler
|
|||
/// exactly ONE visibility structure per frame: <c>LScape::draw</c>'s
|
||||
/// terrain clip, the punch fans' <c>building_view</c> planes, and the
|
||||
/// landscape turn's view count all read the views the walk itself
|
||||
/// installed. Feeding these from the old <c>PortalVisibilityBuilder</c>
|
||||
/// assembly let the two systems desynchronize on camera-transition
|
||||
/// boundary frames — terrain splashed through stale/fat old-apparatus
|
||||
/// exit views over interior pixels the walk's flood never repainted
|
||||
/// (the FW3 visual-gate stairwell/grass flash, probe-pinned
|
||||
/// 2026-08-30), and punch fans indexed the old slice array with walk
|
||||
/// view indices.
|
||||
/// installed. This keeps terrain, punch fans, and landscape turns on
|
||||
/// the same frame-owned walk views during camera transitions.
|
||||
///
|
||||
/// The walk stores view vertices as PIXEL screen points
|
||||
/// (<c>copy_view</c>'s post-divide viewport coordinates, origin
|
||||
|
|
@ -463,8 +329,7 @@ public static class ClipFrameAssembler
|
|||
/// <see cref="ClipFrameAssembly.OutsideViewSlices"/> by that index. The
|
||||
/// shared <see cref="AppendOutsideSlice"/> helper SKIPS a view whose
|
||||
/// polygon collapses (<see cref="ClipPlaneSet.IsNothingVisible"/>) —
|
||||
/// correct for <see cref="Assemble"/>'s "empty regions are omitted
|
||||
/// entirely" legacy policy, but wrong here: skipping would make the
|
||||
/// unsuitable here because skipping would make the
|
||||
/// slice array SHORTER than the view list, so every later view's fan
|
||||
/// would read the WRONG view's planes (off by however many views
|
||||
/// collapsed before it). A collapsed view instead gets its own slice
|
||||
|
|
@ -564,22 +429,14 @@ public static class ClipFrameAssembler
|
|||
assembly.OutdoorVisible = outdoorVisible;
|
||||
assembly.HasOutsideView = outdoorVisible;
|
||||
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
|
||||
// S3 chunk 4 fix round 2 (L2): see Assemble's matching comment — the
|
||||
// same reduction applies here.
|
||||
// Any scissor fallback makes the aggregate plane count zero.
|
||||
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
|
||||
assembly.ScissorFallbacks = scissorFallbacks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S3 chunk 4 fix round 2 (L4): the ONE place a single outside_view
|
||||
/// <see cref="ViewPolygon"/> becomes an appended clip slot plus its
|
||||
/// <see cref="ClipViewSlice"/> — both <see cref="Assemble"/>'s
|
||||
/// outside_view loop and <see cref="ReassembleOutsideViewFromWalk"/>'s
|
||||
/// outside_view loop call this instead of each carrying its own copy of
|
||||
/// the plane/scissor-fallback bookkeeping (a prior round's three-lens
|
||||
/// review found the duplication let a CPU/GPU equivalence pin exercise
|
||||
/// one copy while production ran the other — see <c>ClipFrameLayoutTests</c>'
|
||||
/// punch-fan pin). A polygon entirely outside every plane
|
||||
/// Converts one outside_view <see cref="ViewPolygon"/> into an appended
|
||||
/// clip slot and <see cref="ClipViewSlice"/>. A polygon entirely outside every plane
|
||||
/// (<see cref="ClipPlaneSet.IsNothingVisible"/>) appends nothing and
|
||||
/// returns false; the caller's own loop simply moves to the next
|
||||
/// polygon either way, so the return value only matters to a caller that
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// gl_ClipDistance, or a signal that the region OVER-includes (draws
|
||||
// unclipped) because it cannot be expressed as one convex plane set.
|
||||
//
|
||||
// This is the bridge between PortalVisibilityBuilder's 2D NDC view polygons and
|
||||
// This is the bridge between the frame walk's 2D NDC view polygons and
|
||||
// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e).
|
||||
// Pure System.Numerics math; NO GL. The shader consumes each plane as
|
||||
// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep)
|
||||
|
|
@ -186,8 +186,7 @@ public readonly struct ClipPlaneSet
|
|||
new(Array.Empty<Vector4>(), isPlaneOverflow: true, isNothingVisible: false);
|
||||
|
||||
/// <summary>
|
||||
/// Return the polygon wound CCW with collinear vertices removed. The PortalVisibilityBuilder
|
||||
/// already EnsureCcw's its output, but From() is a public entry point that must be robust to
|
||||
/// Return the polygon wound CCW with collinear vertices removed. From() must be robust to
|
||||
/// either winding (e.g. a hand-built CellView), so we normalize here too.
|
||||
/// </summary>
|
||||
private static int NormalizeAndMerge(ReadOnlySpan<Vector2> input, Span<Vector2> points)
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
// IndoorDrawPlan.cs
|
||||
//
|
||||
// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840):
|
||||
// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a
|
||||
// non-empty view is included — there is NO "drawable" filter. Dropping cells without a
|
||||
// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed).
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices);
|
||||
|
||||
public static class IndoorDrawPlan
|
||||
{
|
||||
/// <summary>Reverse OrderedVisibleCells (far→near), each visible cell with its view
|
||||
/// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped
|
||||
/// (they are not actually visible); no other cell is ever dropped.</summary>
|
||||
public static List<CellDrawEntry> ShellPass(PortalVisibilityFrame frame)
|
||||
{
|
||||
var result = new List<CellDrawEntry>(frame.OrderedVisibleCells.Count);
|
||||
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
uint cellId = frame.OrderedVisibleCells[i];
|
||||
if (!frame.CellViews.TryGetValue(cellId, out var view) || view.IsEmpty)
|
||||
continue;
|
||||
result.Add(new CellDrawEntry(cellId, view.Polygons));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,15 @@ namespace AcDream.App.Rendering;
|
|||
/// Factory for the OUTDOOR render root — the cell the render roots at when the camera eye is outdoors.
|
||||
/// Retail roots every in-world frame at <c>viewer_cell</c> (SmartBox::RenderNormalMode →
|
||||
/// DrawInside(viewer_cell), decomp:92635); when outdoors that is a <c>CLandCell</c>. acdream models it
|
||||
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> (so
|
||||
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw
|
||||
/// as the root's shell) and <see cref="LoadedCell.SeenOutside"/>.
|
||||
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> and
|
||||
/// <see cref="LoadedCell.SeenOutside"/>. <see cref="ClipFrameAssembler.BeginWalkFrame"/> seeds the
|
||||
/// full-screen outside view for this root.
|
||||
///
|
||||
/// <para>R-A2 (2026-06-08): the node no longer carries reverse portals into nearby buildings. Retail
|
||||
/// does NOT flood buildings from the land root — buildings flood SEPARATELY, per-building, during the
|
||||
/// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827).
|
||||
/// acdream issues those via <see cref="PortalVisibilityBuilder.ConstructViewBuilding"/> per nearby
|
||||
/// building inside <see cref="RetailPViewRenderer.DrawInside"/>. The pre-R-A2 design flooded all
|
||||
/// acdream issues those through the retained frame walk per nearby building inside
|
||||
/// <see cref="RetailPViewRenderer.DrawInside"/>. The pre-R-A2 design flooded all
|
||||
/// buildings from one root through reverse portals, coupling their interior membership to a single
|
||||
/// root-level portal-side test that oscillated as the chase eye grazed a doorway — the indoor flap.</para>
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -71,24 +71,6 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
|
|||
|
||||
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
|
||||
|
||||
// [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the
|
||||
// builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the
|
||||
// player feet cell); ok = the sweep found a valid position (find_valid_position != 0).
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
{
|
||||
var cp = _physics.DataCache?.GetCellStruct(startCell);
|
||||
string bsp = cp?.FlatPhysicsBsp is null
|
||||
? "noflatbsp"
|
||||
: (cp.FlatPhysicsBsp.RootIndex < 0 ? "noroot" : "ok");
|
||||
float desiredBack = Vector3.Distance(pivot, desiredEye);
|
||||
float eyeBack = Vector3.Distance(pivot, eye);
|
||||
System.Console.WriteLine(
|
||||
$"[flap-sweep] cell=0x{cellId:X8} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
|
||||
$"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " +
|
||||
$"in=({desiredEye.X:F6},{desiredEye.Y:F6},{desiredEye.Z:F6}) out=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) " +
|
||||
$"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}");
|
||||
}
|
||||
|
||||
// success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871).
|
||||
// Graph-tracked, no AABB/grace.
|
||||
if (r.Ok) return new CameraSweepResult(eye, r.CellId);
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ public static class PortalProjection
|
|||
}
|
||||
|
||||
/// <summary>Project a cell-local polygon to NDC, preserving the projected winding of
|
||||
/// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible
|
||||
/// the input (NOT normalized to CCW). The frame-walk caller is responsible
|
||||
/// for feeding camera-facing portal polygons (via the portal-side test) so the result is
|
||||
/// CCW for the CCW-only <see cref="ScreenPolygonClip"/>. Returns fewer than 3 verts when
|
||||
/// the polygon is entirely behind the camera / degenerate.</summary>
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@ public sealed class CellView
|
|||
{
|
||||
if (p.IsEmpty) return false;
|
||||
|
||||
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
|
||||
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
|
||||
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). A caller may
|
||||
// re-queue a cell every time its CellView grows, so the flood only terminates when Add
|
||||
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
|
||||
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
|
||||
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -62,7 +62,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
|||
private readonly ParticleRenderer? _particleRenderer;
|
||||
private readonly PortalDepthMaskRenderer? _portalDepthMask;
|
||||
private readonly RetailAlphaQueue _alpha;
|
||||
private readonly WorldRenderDiagnostics _diagnostics;
|
||||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
||||
private readonly EnvCellAlphaDrawSource _envCellClipAlphaSource;
|
||||
|
|
@ -85,7 +84,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
|||
ParticleRenderer? particleRenderer,
|
||||
PortalDepthMaskRenderer? portalDepthMask,
|
||||
RetailAlphaQueue alpha,
|
||||
WorldRenderDiagnostics diagnostics,
|
||||
TerrainDrawDiagnosticsController terrainDiagnostics)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
|
|
@ -100,7 +98,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
|||
_particleRenderer = particleRenderer;
|
||||
_portalDepthMask = portalDepthMask;
|
||||
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_terrainDiagnostics = terrainDiagnostics
|
||||
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
|
||||
_envCellClipAlphaSource = new EnvCellAlphaDrawSource(
|
||||
|
|
@ -663,20 +660,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
|||
clipSlot: 0);
|
||||
}
|
||||
|
||||
public void EmitDiagnostics(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewFrameResult result) =>
|
||||
_diagnostics.EmitRetailPViewDiagnostics(
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled,
|
||||
RenderingDiagnostics.ProbeFlapEnabled,
|
||||
result,
|
||||
frame.RootCell,
|
||||
frame.ViewerCellId,
|
||||
frame.PlayerCellId,
|
||||
frame.CameraWorldPosition,
|
||||
frame.PlayerViewPosition,
|
||||
frame.CameraCellResolution);
|
||||
|
||||
/// <summary>Retail <c>D3DPolyRender::DrawPortalPolyInternal</c>
|
||||
/// @0x0059BC90. Main interior roots stamp true depth (seal); outdoor and
|
||||
/// look-in apertures stamp far depth (punch). The renderer owns that
|
||||
|
|
@ -737,14 +720,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
|||
cell.WorldTransform);
|
||||
}
|
||||
|
||||
_diagnostics.EmitSeamMask(
|
||||
RenderingDiagnostics.ProbeSeamDrawEnabled,
|
||||
RenderingDiagnostics.SeamDrawTargetCells,
|
||||
cellId,
|
||||
index,
|
||||
forceFarZ,
|
||||
world[..count]);
|
||||
|
||||
// S4-c1 fix round 1 F2: retail increments portalsDrawnCount
|
||||
// (0x59BD70-0x59BD74) BEFORE polyClipFinish runs (0x59BDB0) —
|
||||
// the counter records accepted ATTEMPTS, not successful GPU
|
||||
|
|
|
|||
|
|
@ -32,12 +32,6 @@ internal sealed class RetailPViewRenderer
|
|||
// cleared in finally.
|
||||
private Action? _walkPreClearDynamics;
|
||||
|
||||
// Output-only Facility Hub staircase probe. This counter is consulted
|
||||
// only while ACDREAM_PROBE_FACILITY_STAIRS=1 and never affects the walk.
|
||||
private ulong _probeFacilityStairFrame;
|
||||
private string? _probeFacilityStairRootSignature;
|
||||
private string? _probeCathedralStairRootSignature;
|
||||
|
||||
// FW6 allocation closeout: the walk's large event/view/route scratch,
|
||||
// frame context, and one-cell leaf collections are renderer-lifetime
|
||||
// owners. Only their frame-local bindings change. Before this cutover all
|
||||
|
|
@ -108,11 +102,8 @@ internal sealed class RetailPViewRenderer
|
|||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(passes);
|
||||
passes.BeginFrame();
|
||||
// Campaign FW4: there is one production renderer and one visibility
|
||||
// owner. The fake/legacy executor path was useful during the cutover,
|
||||
// but retaining it here kept the retired PortalVisibilityBuilder and
|
||||
// look-in seed machinery alive. Fail loudly if composition ever tries
|
||||
// to reintroduce that split.
|
||||
// There is one production renderer and one frame-walk owner. Fail
|
||||
// loudly if composition attempts to split that path.
|
||||
RetailPViewPassExecutor walkExecutor = passes as RetailPViewPassExecutor
|
||||
?? throw new InvalidOperationException(
|
||||
"The retail frame walk requires RetailPViewPassExecutor.");
|
||||
|
|
@ -294,128 +285,13 @@ internal sealed class RetailPViewRenderer
|
|||
viewportHeight);
|
||||
}
|
||||
|
||||
// The walk's visited set replaces PortalVisibilityFrame's old
|
||||
// OrderedVisibleCells side-channel for every production consumer.
|
||||
// The walk's visited set feeds every production consumer.
|
||||
_drawableCellsScratch.Clear();
|
||||
_drawableCellsScratch.UnionWith(walkDriver.VisitedCells);
|
||||
walkDriver.CopyVisibleCellsTo(_visibleCellsScratch);
|
||||
_visibleLandscapeCellsScratch.Clear();
|
||||
_visibleLandscapeCellsScratch.UnionWith(walkDriver.VisitedLandscapeCellIds);
|
||||
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
{
|
||||
static int TargetMembership(IReadOnlyCollection<uint> cells)
|
||||
{
|
||||
const uint a = 0x8A02015Eu;
|
||||
const uint b = 0x8A02015Fu;
|
||||
const uint c = 0x8A0201C1u;
|
||||
int mask = 0;
|
||||
foreach (uint cell in cells)
|
||||
{
|
||||
if (cell == a) mask |= 1;
|
||||
if (cell == b) mask |= 2;
|
||||
if (cell == c) mask |= 4;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
_probeFacilityStairFrame++;
|
||||
int floodMask = TargetMembership(walkDriver.InteriorFloodCells);
|
||||
int visitedMask = TargetMembership(walkDriver.VisitedCells);
|
||||
int turnMask = TargetMembership(walkDriver.LookInCells);
|
||||
string signature = $"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:"
|
||||
+ $"{ctx.RootCell.CellId:X8}:{floodMask}:{visitedMask}:{turnMask}:"
|
||||
+ $"{walkDriver.InteriorFloodCells.Count}:{walkDriver.VisitedCells.Count}:"
|
||||
+ $"{walkDriver.LookInCellTurns.Count}";
|
||||
bool changed = !string.Equals(
|
||||
_probeFacilityStairRootSignature,
|
||||
signature,
|
||||
StringComparison.Ordinal);
|
||||
_probeFacilityStairRootSignature = signature;
|
||||
if (changed)
|
||||
{
|
||||
static string DescribeMask(int mask) =>
|
||||
$"15e={((mask & 1) != 0 ? 1 : 0)},"
|
||||
+ $"15f={((mask & 2) != 0 ? 1 : 0)},"
|
||||
+ $"1c1={((mask & 4) != 0 ? 1 : 0)}";
|
||||
Console.WriteLine(
|
||||
$"[facility-root] f={_probeFacilityStairFrame} changed={(changed ? 1 : 0)} "
|
||||
+ $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} "
|
||||
+ $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} "
|
||||
+ $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) "
|
||||
+ $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) "
|
||||
+ $"rootFlood={walkDriver.InteriorFloodCells.Count}"
|
||||
+ $"[{DescribeMask(floodMask)}] "
|
||||
+ $"visited={walkDriver.VisitedCells.Count}"
|
||||
+ $"[{DescribeMask(visitedMask)}] "
|
||||
+ $"turns={walkDriver.LookInCellTurns.Count}"
|
||||
+ $"[{DescribeMask(turnMask)}]");
|
||||
}
|
||||
|
||||
static int CathedralMembership(IReadOnlyCollection<uint> cells)
|
||||
{
|
||||
const uint oldBuilding = 0xF4180107u;
|
||||
const uint stairParent = 0xF4180112u;
|
||||
int mask = 0;
|
||||
foreach (uint cell in cells)
|
||||
{
|
||||
if (cell == oldBuilding) mask |= 1;
|
||||
if (cell == stairParent) mask |= 2;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
static uint BuildingAnchor(Walk.WalkBuilding building)
|
||||
{
|
||||
foreach (Walk.WalkBldPortal portal in building.Portals)
|
||||
{
|
||||
if (portal.OtherCellId != 0xFFFFFFFFu)
|
||||
return portal.OtherCellId;
|
||||
}
|
||||
return 0u;
|
||||
}
|
||||
|
||||
int cathedralFlood = CathedralMembership(walkDriver.InteriorFloodCells);
|
||||
int cathedralVisited = CathedralMembership(walkDriver.VisitedCells);
|
||||
int cathedralLookIn = CathedralMembership(walkDriver.LookInCells);
|
||||
bool bucket107 = _walkWorldData.StaticBucketContains(
|
||||
0xF4180107u,
|
||||
0x020009A2u);
|
||||
bool bucket112 = _walkWorldData.StaticBucketContains(
|
||||
0xF4180112u,
|
||||
0x020009A2u);
|
||||
string buildingAnchors = string.Join(",", walkDriver.VisitedBuildings
|
||||
.ConvertAll(building => $"0x{BuildingAnchor(building):X8}"));
|
||||
string cathedralSignature =
|
||||
$"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:{ctx.RootCell.CellId:X8}:"
|
||||
+ $"{cathedralFlood}:{cathedralVisited}:{cathedralLookIn}:"
|
||||
+ $"{(bucket107 ? 1 : 0)}:{(bucket112 ? 1 : 0)}:{buildingAnchors}";
|
||||
bool cathedralChanged = !string.Equals(
|
||||
_probeCathedralStairRootSignature,
|
||||
cathedralSignature,
|
||||
StringComparison.Ordinal);
|
||||
_probeCathedralStairRootSignature = cathedralSignature;
|
||||
if (cathedralChanged
|
||||
&& ((ctx.ViewerCellId & 0xFFFF0000u) == 0xF4180000u
|
||||
|| (ctx.PlayerCellId & 0xFFFF0000u) == 0xF4180000u))
|
||||
{
|
||||
static string DescribeCathedralMask(int mask) =>
|
||||
$"107={((mask & 1) != 0 ? 1 : 0)},"
|
||||
+ $"112={((mask & 2) != 0 ? 1 : 0)}";
|
||||
Console.WriteLine(
|
||||
$"[cathedral-stair] f={_probeFacilityStairFrame} "
|
||||
+ $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} "
|
||||
+ $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} "
|
||||
+ $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) "
|
||||
+ $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) "
|
||||
+ $"rootFlood=[{DescribeCathedralMask(cathedralFlood)}] "
|
||||
+ $"visited=[{DescribeCathedralMask(cathedralVisited)}] "
|
||||
+ $"lookIn=[{DescribeCathedralMask(cathedralLookIn)}] "
|
||||
+ $"buckets=[107={(bucket107 ? 1 : 0)},112={(bucket112 ? 1 : 0)}] "
|
||||
+ $"buildings=[{buildingAnchors}]");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// FW4 slice 1: the ONE clip-region publication, after any walk
|
||||
|
|
@ -448,8 +324,6 @@ internal sealed class RetailPViewRenderer
|
|||
counts,
|
||||
sourceCounts,
|
||||
diagnosticPartition: null);
|
||||
passes.EmitDiagnostics(ctx, result);
|
||||
|
||||
// The one collected walk owns terrain, cell shells, buildings,
|
||||
// statics, dynamics, particles, punches, and alpha barriers at
|
||||
// their retail turns. Interior-root landscape services run at the
|
||||
|
|
|
|||
|
|
@ -1,207 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// T3 (BR-5): the port of retail's <c>Render::viewconeCheck</c> (Ghidra
|
||||
/// 0x0054c250) — meshes (characters, statics, emitters) are CULLED per portal
|
||||
/// view by a bounding-sphere test against the view's edge planes, never
|
||||
/// clipped. Retail stores each view vertex with its 3D eye-edge plane
|
||||
/// (<c>view_vertex { Vec2D pt; Plane plane }</c>, acclient.h:32483) and tests
|
||||
/// the object's drawing sphere against the installed view's plane set;
|
||||
/// OUTSIDE → skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310,
|
||||
/// and the DrawCells per-cell object epilogue, Ghidra 0x005a4840).
|
||||
///
|
||||
/// <para>Our views are clip-space half-planes (≤8 per slice,
|
||||
/// <see cref="ClipPlaneSet"/> output: (nx,ny,0,d) satisfied when
|
||||
/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space —
|
||||
/// the view_vertex.plane analog, a plane through the EYE and the view edge —
|
||||
/// is one matrix fold: with row-vector convention (System.Numerics),
|
||||
/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P)
|
||||
/// is the world-space homogeneous half-plane. Sphere-vs-half-plane keeps the
|
||||
/// sphere when L.xyz·c + L.w ≥ −r·|L.xyz| (not entirely outside).</para>
|
||||
///
|
||||
/// <para>A sphere is visible through a SLICE when it is not entirely outside
|
||||
/// any of the slice's planes (convex region); visible for a CELL when any of
|
||||
/// the cell's slices passes. A slice with zero planes is pass-all (the
|
||||
/// NoClipSlice / full-screen outdoor case). A cell with no views culls — in
|
||||
/// retail an object whose cell is not in the draw list is simply never
|
||||
/// reached.</para>
|
||||
/// </summary>
|
||||
public sealed class ViewconeCuller
|
||||
{
|
||||
private const int MaxRetainedCellPlaneSets = 512;
|
||||
private const int MaxRetainedPlanesPerCell = 256;
|
||||
private const int MaxRetainedSlicesPerCell = 64;
|
||||
|
||||
private readonly Dictionary<uint, PlaneSet> _cellPlanes = new();
|
||||
private readonly Stack<PlaneSet> _planeSetPool = new();
|
||||
private PlaneSet _outsidePlanes = new();
|
||||
|
||||
private readonly record struct SliceRange(int Start, int Count);
|
||||
private readonly record struct LiftedPlane(Vector4 Equation, float NormalLength);
|
||||
|
||||
/// <summary>
|
||||
/// Contiguous per-cell plane storage. Reusing two Lists per visible cell
|
||||
/// avoids rebuilding a jagged array graph on every render frame while
|
||||
/// retaining the exact slice boundaries used by retail's any-view test.
|
||||
/// </summary>
|
||||
private sealed class PlaneSet
|
||||
{
|
||||
public List<LiftedPlane> Planes { get; } = new();
|
||||
public List<SliceRange> Slices { get; } = new();
|
||||
|
||||
public bool IsRetainable =>
|
||||
Planes.Capacity <= MaxRetainedPlanesPerCell
|
||||
&& Slices.Capacity <= MaxRetainedSlicesPerCell;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Planes.Clear();
|
||||
Slices.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the outside view is a full-screen pass-all (the
|
||||
/// synthetic outdoor root) — every outside-test passes.</summary>
|
||||
public bool OutsideIsFullScreen { get; private set; }
|
||||
|
||||
public static ViewconeCuller Build(
|
||||
ClipFrameAssembly assembly,
|
||||
in Matrix4x4 viewProjection,
|
||||
ViewconeCuller? reuse = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
var culler = reuse ?? new ViewconeCuller();
|
||||
culler.Reset();
|
||||
|
||||
foreach (var (cellId, slices) in assembly.CellIdToViewSlices)
|
||||
{
|
||||
PlaneSet lifted = culler.RentPlaneSet();
|
||||
for (int s = 0; s < slices.Length; s++)
|
||||
AppendLiftedSlice(lifted, slices[s].Planes, viewProjection);
|
||||
culler._cellPlanes[cellId] = lifted;
|
||||
}
|
||||
|
||||
var outside = assembly.OutsideViewSlices;
|
||||
bool fullScreen = false;
|
||||
for (int s = 0; s < outside.Length; s++)
|
||||
{
|
||||
AppendLiftedSlice(culler._outsidePlanes, outside[s].Planes, viewProjection);
|
||||
if (outside[s].Planes.Length == 0)
|
||||
fullScreen = true;
|
||||
}
|
||||
culler.OutsideIsFullScreen = fullScreen;
|
||||
return culler;
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
foreach (PlaneSet set in _cellPlanes.Values)
|
||||
{
|
||||
set.Reset();
|
||||
if (set.IsRetainable && _planeSetPool.Count < MaxRetainedCellPlaneSets)
|
||||
_planeSetPool.Push(set);
|
||||
}
|
||||
_cellPlanes.Clear();
|
||||
if (_outsidePlanes.IsRetainable)
|
||||
_outsidePlanes.Reset();
|
||||
else
|
||||
_outsidePlanes = new PlaneSet();
|
||||
OutsideIsFullScreen = false;
|
||||
}
|
||||
|
||||
private PlaneSet RentPlaneSet()
|
||||
{
|
||||
PlaneSet result = _planeSetPool.Count != 0
|
||||
? _planeSetPool.Pop()
|
||||
: new PlaneSet();
|
||||
result.Reset();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AppendLiftedSlice(
|
||||
PlaneSet destination,
|
||||
Vector4[] clipPlanes,
|
||||
in Matrix4x4 m)
|
||||
{
|
||||
int start = destination.Planes.Count;
|
||||
for (int i = 0; i < clipPlanes.Length; i++)
|
||||
{
|
||||
var p = clipPlanes[i];
|
||||
var equation = new Vector4(
|
||||
m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W,
|
||||
m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W,
|
||||
m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W,
|
||||
m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W);
|
||||
float normalLength = MathF.Sqrt(
|
||||
equation.X * equation.X
|
||||
+ equation.Y * equation.Y
|
||||
+ equation.Z * equation.Z);
|
||||
destination.Planes.Add(new LiftedPlane(equation, normalLength));
|
||||
}
|
||||
destination.Slices.Add(new SliceRange(start, clipPlanes.Length));
|
||||
}
|
||||
|
||||
private static bool SphereInsidePlanes(
|
||||
PlaneSet set,
|
||||
SliceRange slice,
|
||||
in Vector3 center,
|
||||
float radius)
|
||||
{
|
||||
int end = slice.Start + slice.Count;
|
||||
for (int i = slice.Start; i < end; i++)
|
||||
{
|
||||
LiftedPlane plane = set.Planes[i];
|
||||
Vector4 l = plane.Equation;
|
||||
float nLen = plane.NormalLength;
|
||||
if (nLen < 1e-12f)
|
||||
continue; // degenerate plane — no constraint
|
||||
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
|
||||
if (dist < -radius * nLen)
|
||||
return false; // entirely outside this edge plane
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Sphere-vs-the-cell's-views: visible when any slice passes.
|
||||
/// A cell with no views culls (not in the draw list ⇒ never reached in
|
||||
/// retail). A zero-plane slice is pass-all.</summary>
|
||||
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
|
||||
{
|
||||
if (!_cellPlanes.TryGetValue(cellId, out PlaneSet? set))
|
||||
return false;
|
||||
for (int s = 0; s < set.Slices.Count; s++)
|
||||
if (SphereInsidePlanes(set, set.Slices[s], center, radius))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Sphere-vs-the-outside-views (objects in outdoor space seen
|
||||
/// from an interior root through doorways; pass-all under the outdoor
|
||||
/// root's full-screen outside view).</summary>
|
||||
public bool SphereVisibleOutside(in Vector3 center, float radius)
|
||||
{
|
||||
if (OutsideIsFullScreen)
|
||||
return true;
|
||||
for (int s = 0; s < _outsidePlanes.Slices.Count; s++)
|
||||
if (SphereInsidePlanes(_outsidePlanes, _outsidePlanes.Slices[s], center, radius))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Sphere vs ONE outside slice (the landscape pass draws per
|
||||
/// slice; its statics pre-filter tests against exactly that slice).</summary>
|
||||
public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius)
|
||||
{
|
||||
if ((uint)sliceIndex >= (uint)_outsidePlanes.Slices.Count)
|
||||
return false;
|
||||
return SphereInsidePlanes(
|
||||
_outsidePlanes,
|
||||
_outsidePlanes.Slices[sliceIndex],
|
||||
center,
|
||||
radius);
|
||||
}
|
||||
}
|
||||
|
|
@ -107,9 +107,8 @@ public sealed class RetailFrameWalk
|
|||
/// exit-view polygons THIS walk's own <c>ConstructView</c> installed
|
||||
/// during the most recent <see cref="DrawInside"/>. The production
|
||||
/// renderer materializes the frame's terrain/sky/punch clip slices from
|
||||
/// these views (retail has exactly one visibility structure; the old
|
||||
/// PortalVisibilityBuilder assembly no longer feeds the walk path's
|
||||
/// outside views). Valid after <see cref="WalkFrame"/> returns for an
|
||||
/// these views (retail has exactly one visibility structure). Valid after
|
||||
/// <see cref="WalkFrame"/> returns for an
|
||||
/// interior root, until the next interior root's view push resets it.</summary>
|
||||
internal WalkPortalView InteriorOutsideView => _interiorPView.OutsideView;
|
||||
|
||||
|
|
|
|||
|
|
@ -186,11 +186,6 @@ public sealed class WalkPView
|
|||
ref WalkCellPortal portal = ref cell.Portals[j];
|
||||
if (!flags.Seen || flags.InView)
|
||||
{
|
||||
EmitFacilityPortalProbe(
|
||||
cell, j, viewIndex: -1, top, portal, flags, ctx,
|
||||
projectedCount: -1, clippedCount: -1,
|
||||
neighborViewsBefore: -1, neighborViewsAfter: -1,
|
||||
appended: false, result: "flag-gate");
|
||||
continue;
|
||||
}
|
||||
if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu)
|
||||
|
|
@ -198,11 +193,6 @@ public sealed class WalkPView
|
|||
cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId);
|
||||
if (cell.CachedNeighbors[j] is null)
|
||||
{
|
||||
EmitFacilityPortalProbe(
|
||||
cell, j, viewIndex: -1, top, portal, flags, ctx,
|
||||
projectedCount: -1, clippedCount: -1,
|
||||
neighborViewsBefore: -1, neighborViewsAfter: -1,
|
||||
appended: false, result: "neighbor-missing");
|
||||
continue; // not loaded: silently dead
|
||||
}
|
||||
}
|
||||
|
|
@ -223,15 +213,8 @@ public sealed class WalkPView
|
|||
cell, portal.PortalSide,
|
||||
cell.PortalPolygons[portal.PolygonIndex],
|
||||
doClip: true, ctx, _clipScratch);
|
||||
int projectedCount =
|
||||
cell.PortalPolygons[portal.PolygonIndex].Vertices.Length;
|
||||
if (n == 0)
|
||||
{
|
||||
EmitFacilityPortalProbe(
|
||||
cell, j, i, top, portal, flags, ctx,
|
||||
projectedCount, clippedCount: 0,
|
||||
neighborViewsBefore: -1, neighborViewsAfter: -1,
|
||||
appended: false, result: "clip-empty");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -257,123 +240,19 @@ public sealed class WalkPView
|
|||
SetView(top, i); // restore after the far-frame excursion
|
||||
if (n == 0)
|
||||
{
|
||||
EmitFacilityPortalProbe(
|
||||
cell, j, i, top, portal, flags, ctx,
|
||||
projectedCount, clippedCount: 0,
|
||||
neighborViewsBefore: neighbor.TopView.ViewCount,
|
||||
neighborViewsAfter: neighbor.TopView.ViewCount,
|
||||
appended: false, result: "far-clip-empty");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
int before = neighbor.TopView.ViewCount;
|
||||
bool appended = false;
|
||||
if (neighbor.NumView != 0)
|
||||
appended = WalkCopyView.Append(
|
||||
WalkCopyView.Append(
|
||||
neighbor.TopView, _clipScratch.AsSpan(0, n),
|
||||
ctx.Rays, ctx.WorldViewpoint);
|
||||
EmitFacilityPortalProbe(
|
||||
cell, j, i, top, portal, flags, ctx,
|
||||
projectedCount, n, before, neighbor.TopView.ViewCount,
|
||||
appended,
|
||||
neighbor.NumView == 0 ? "neighbor-not-pushed"
|
||||
: appended ? "view-appended" : "append-rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Output-only Facility Hub discriminator for the residual #177 stair
|
||||
/// disappearance. It traces only portals entering cell 0x8A02015F so a
|
||||
/// side-to-away camera turn can distinguish flag admission, homogeneous
|
||||
/// portal clipping, and copy_view append without changing the walk.
|
||||
/// </summary>
|
||||
private void EmitFacilityPortalProbe(
|
||||
WalkCell cell,
|
||||
int portalIndex,
|
||||
int viewIndex,
|
||||
WalkPortalView top,
|
||||
WalkCellPortal portal,
|
||||
WalkPortalFlags flags,
|
||||
IWalkFrameContext ctx,
|
||||
int projectedCount,
|
||||
int clippedCount,
|
||||
int neighborViewsBefore,
|
||||
int neighborViewsAfter,
|
||||
bool appended,
|
||||
string result)
|
||||
{
|
||||
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
|
||||
|| portal.OtherCellId != 0x8A02015Fu
|
||||
|| (cell.CellId & 0xFFFF0000u) != 0x8A020000u)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string activeBounds = "none";
|
||||
if ((uint)viewIndex < (uint)top.ViewCount)
|
||||
{
|
||||
WalkViewPoly active = top.View.Polys[viewIndex];
|
||||
activeBounds = $"({active.XMin:F2},{active.YMin:F2})-"
|
||||
+ $"({active.XMax:F2},{active.YMax:F2})";
|
||||
}
|
||||
|
||||
string projectedBounds = DescribeScreenBounds(
|
||||
_projectScratch.AsSpan(0, Math.Max(projectedCount, 0)));
|
||||
string clippedBounds = DescribeScreenBounds(
|
||||
_clipScratch.AsSpan(0, Math.Max(clippedCount, 0)));
|
||||
|
||||
Console.WriteLine(
|
||||
$"[facility-portal] frame={_masterTimestamp} "
|
||||
+ $"from=0x{cell.CellId:X8} portal={portalIndex} "
|
||||
+ $"to=0x{portal.OtherCellId:X8} back={portal.OtherPortalId} "
|
||||
+ $"view={viewIndex}/{top.ViewCount} startFlags="
|
||||
+ $"seen:{(flags.Seen ? 1 : 0)},in:{(flags.InView ? 1 : 0)} "
|
||||
+ $"side={portal.PortalSide} exact={(portal.ExactMatch ? 1 : 0)} "
|
||||
+ $"eye=({ctx.WorldViewpoint.X:F6},{ctx.WorldViewpoint.Y:F6},"
|
||||
+ $"{ctx.WorldViewpoint.Z:F6}) active={activeBounds} "
|
||||
+ $"projected={projectedCount}:{projectedBounds} "
|
||||
+ $"clipped={clippedCount}:{clippedBounds} "
|
||||
+ $"neighborViews={neighborViewsBefore}->{neighborViewsAfter} "
|
||||
+ $"append={(appended ? 1 : 0)} result={result}");
|
||||
}
|
||||
|
||||
private static string DescribeScreenBounds(ReadOnlySpan<WalkScreenPoint> points)
|
||||
{
|
||||
if (points.IsEmpty)
|
||||
return "none";
|
||||
|
||||
float xmin = float.PositiveInfinity;
|
||||
float xmax = float.NegativeInfinity;
|
||||
float ymin = float.PositiveInfinity;
|
||||
float ymax = float.NegativeInfinity;
|
||||
float wmin = float.PositiveInfinity;
|
||||
float wmax = float.NegativeInfinity;
|
||||
int divided = 0;
|
||||
for (int index = 0; index < points.Length; index++)
|
||||
{
|
||||
ref readonly WalkScreenPoint point = ref points[index];
|
||||
wmin = MathF.Min(wmin, point.W);
|
||||
wmax = MathF.Max(wmax, point.W);
|
||||
if (point.W < WalkScreenClip.MinW)
|
||||
continue;
|
||||
float x = point.X / point.W;
|
||||
float y = point.Y / point.W;
|
||||
xmin = MathF.Min(xmin, x);
|
||||
xmax = MathF.Max(xmax, x);
|
||||
ymin = MathF.Min(ymin, y);
|
||||
ymax = MathF.Max(ymax, y);
|
||||
divided++;
|
||||
}
|
||||
|
||||
return divided == 0
|
||||
? $"behind(w={wmin:F4}..{wmax:F4})"
|
||||
: $"({xmin:F2},{ymin:F2})-({xmax:F2},{ymax:F2});"
|
||||
+ $"w={wmin:F4}..{wmax:F4};front={divided}";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// OtherPortalClip @0x005a5400 — the double clip for non-exact_match
|
||||
// portals: snapshot the near-clipped poly as a temp view, then re-clip
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
|
||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
|
||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellObjectsCache = new();
|
||||
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new();
|
||||
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
|
||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
|
||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
|
||||
|
|
@ -162,21 +161,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
int renderCenterLbX,
|
||||
int renderCenterLbY)
|
||||
{
|
||||
// Campaign OVERHAUL S2 chunk 5: report the PRIOR frame's count
|
||||
// before resetting it — the count is only final once that frame's
|
||||
// walk (a sequence of on-demand Get* calls this class has no other
|
||||
// "frame is done" hook for) has finished, so this is necessarily a
|
||||
// one-frame-delayed report, matching every other per-second/per-
|
||||
// change probe in this file family (print-only; never influences
|
||||
// admission).
|
||||
if (UnregisteredRenderMembershipCount > 0
|
||||
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[walk-membership] unregistered={UnregisteredRenderMembershipCount} "
|
||||
+ $"tupleLandblock=0x{_tupleLandblockId:X8}");
|
||||
}
|
||||
|
||||
_scene = scene;
|
||||
_tupleLandblockId = tupleLandblockId;
|
||||
_cellCache.Clear();
|
||||
|
|
@ -217,41 +201,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Facility Hub / cathedral discriminator: does the given cell's
|
||||
/// borrowed retail render membership contain a record with this exact
|
||||
/// authored SourceId? Reimplemented directly over the registry's
|
||||
/// per-cell entries (chunk 5) rather than reading a swept bucket
|
||||
/// dictionary — <see cref="RetailPViewRenderer"/>'s
|
||||
/// <c>ProbeFacilityStairsEnabled</c> block is the only caller.</summary>
|
||||
internal bool StaticBucketContains(uint cellId, uint sourceId)
|
||||
{
|
||||
IReadOnlyList<RetailPartEntry> entries =
|
||||
_shadows.GetRetailPartEntriesInCell(cellId);
|
||||
uint previousEntityId = 0;
|
||||
bool havePrevious = false;
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
uint entityId = entries[i].EntityId;
|
||||
if (havePrevious && entityId == previousEntityId)
|
||||
continue;
|
||||
previousEntityId = entityId;
|
||||
havePrevious = true;
|
||||
|
||||
if (_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record)
|
||||
&& record.Source.SourceId == sourceId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public WalkFrameStaticRecords GetCellStatics(uint cellId)
|
||||
{
|
||||
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||||
return cached;
|
||||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
|
||||
EmitFacilityShadowProbe(records.Records, cellId, "static");
|
||||
_cellCache[cellId] = records;
|
||||
return records;
|
||||
}
|
||||
|
|
@ -261,7 +215,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||||
return cached;
|
||||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
|
||||
EmitFacilityShadowProbe(records.Records, cellId, "combined");
|
||||
_cellObjectsCache[cellId] = records;
|
||||
return records;
|
||||
}
|
||||
|
|
@ -271,7 +224,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||||
return cached;
|
||||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
|
||||
EmitFacilityShadowProbe(records.Records, cellId, "dynamic");
|
||||
_cellDynamicCache[cellId] = records;
|
||||
return records;
|
||||
}
|
||||
|
|
@ -391,68 +343,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
|||
projectionClass is RenderProjectionClass.LiveDynamicRoot
|
||||
or RenderProjectionClass.EquippedChild;
|
||||
|
||||
/// <summary>
|
||||
/// Facility Hub discriminator for retail's cross-cell render-shadow path.
|
||||
/// The scene query is currently keyed by authored parent cell, while retail
|
||||
/// also appends each object's parts to every cell in its physics CELLARRAY
|
||||
/// through CPhysicsObj::add_shadows_to_cells / CPartArray::AddPartsShadow.
|
||||
/// This probe prints the authoritative physics owner set without changing
|
||||
/// admission, so a correction is made only if the live staircase/player
|
||||
/// registration proves that path is populated.
|
||||
/// </summary>
|
||||
private void EmitFacilityShadowProbe(
|
||||
ReadOnlySpan<RenderProjectionRecord> records,
|
||||
uint queriedCellId,
|
||||
string route)
|
||||
{
|
||||
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
return;
|
||||
|
||||
for (int recordIndex = 0; recordIndex < records.Length; recordIndex++)
|
||||
{
|
||||
ref readonly RenderProjectionRecord record = ref records[recordIndex];
|
||||
bool targetPlayer = record.EntityPayload.CasterIdentity
|
||||
== RenderCasterIdentityKind.LocalPlayer;
|
||||
bool targetStair = record.Source.SourceId == 0x02000623u;
|
||||
bool targetCathedralStair =
|
||||
record.Source.SourceId == 0x020009A2u;
|
||||
|
||||
if (!targetStair && !targetCathedralStair && !targetPlayer)
|
||||
continue;
|
||||
|
||||
IReadOnlyList<uint> ownerCells =
|
||||
_shadows.GetOwnerCells(record.Source.LocalEntityId);
|
||||
var cells = new StringBuilder(ownerCells.Count * 11 + 2);
|
||||
cells.Append('[');
|
||||
for (int cellIndex = 0; cellIndex < ownerCells.Count; cellIndex++)
|
||||
{
|
||||
if (cellIndex != 0)
|
||||
cells.Append(',');
|
||||
cells.Append("0x").Append(ownerCells[cellIndex].ToString("X8"));
|
||||
}
|
||||
cells.Append(']');
|
||||
|
||||
string key = $"{route}:{queriedCellId:X8}:"
|
||||
+ $"{record.Source.LocalEntityId:X8}";
|
||||
string signature = $"{record.Source.ParentCellId:X8}:{cells}";
|
||||
if (_facilityShadowProbeSignatures.TryGetValue(key, out string? prior)
|
||||
&& string.Equals(prior, signature, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
_facilityShadowProbeSignatures[key] = signature;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[facility-shadow] route={route} "
|
||||
+ $"kind={(targetPlayer ? "player" : targetCathedralStair ? "cathedral-stair" : "stair")} "
|
||||
+ $"guid=0x{record.Source.ServerGuid:X8} "
|
||||
+ $"local=0x{record.Source.LocalEntityId:X8} "
|
||||
+ $"query=0x{queriedCellId:X8} "
|
||||
+ $"parent=0x{record.Source.ParentCellId:X8} "
|
||||
+ $"owners={cells}");
|
||||
}
|
||||
}
|
||||
|
||||
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
|
||||
{
|
||||
uint anchor = BuildingShellBucketCellId(building);
|
||||
|
|
|
|||
|
|
@ -224,8 +224,7 @@ internal sealed class WalkStaticStreamPopulator
|
|||
liveDynamic,
|
||||
views,
|
||||
viewRouteIndex,
|
||||
cellId,
|
||||
diagnosticViewProjection: viewProjection);
|
||||
cellId);
|
||||
|
||||
for (int batchIndex = 0; batchIndex < _batchScratch.Count; batchIndex++)
|
||||
{
|
||||
|
|
@ -257,17 +256,10 @@ internal sealed class WalkStaticStreamPopulator
|
|||
WbDrawDispatcher.WalkClassifiedBatch batch = item.Batch;
|
||||
if (batch.IsOpaque)
|
||||
{
|
||||
int commandIndex = stream.Count;
|
||||
stream.Append(new OrderedDrawCommand(
|
||||
batch.Key, batch.Transform, item.Stage, cellId, batch.ClipSlot,
|
||||
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
||||
batch.SelectionLighting, batch.DetailCategory));
|
||||
_dispatcher.ProbeFacilityStairCommandAppended(
|
||||
commandIndex,
|
||||
item.LocalEntityId,
|
||||
cellId,
|
||||
item.Stage,
|
||||
in batch);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -318,7 +310,6 @@ internal sealed class WalkStaticStreamPopulator
|
|||
lookInViews,
|
||||
lookInRouteIndex,
|
||||
cellId,
|
||||
diagnosticViewProjection: viewProjection,
|
||||
buildingSelection: buildingSelection,
|
||||
buildingPartTransform: buildingPartTransform);
|
||||
|
||||
|
|
@ -327,17 +318,10 @@ internal sealed class WalkStaticStreamPopulator
|
|||
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i];
|
||||
if (batch.IsOpaque)
|
||||
{
|
||||
int commandIndex = stream.Count;
|
||||
stream.Append(new OrderedDrawCommand(
|
||||
batch.Key, batch.Transform, stage, cellId, batch.ClipSlot,
|
||||
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
||||
batch.SelectionLighting, batch.DetailCategory));
|
||||
_dispatcher.ProbeFacilityStairCommandAppended(
|
||||
commandIndex,
|
||||
record.Source.LocalEntityId,
|
||||
cellId,
|
||||
stage,
|
||||
in batch);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -220,12 +220,6 @@ public sealed unsafe partial class EnvCellRenderer
|
|||
Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
|
||||
if (renderPass == WbRenderPass.Opaque
|
||||
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||||
{
|
||||
EmitSeamDrawProbe(_renderDrawCalls, allInstances, _seamProbeFilter);
|
||||
}
|
||||
|
||||
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int globalLightUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -203,21 +203,6 @@ public sealed partial class EnvCellRenderer :
|
|||
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
|
||||
private LastFrameStats _lastFrameStats;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28).
|
||||
/// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water).
|
||||
/// A divergence between expected and actual values would indicate a pool-
|
||||
/// management regression — exactly the bug class the 2026-05-28 audit caught.
|
||||
/// </summary>
|
||||
public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics()
|
||||
{
|
||||
int poolTotal;
|
||||
lock (_listPool) poolTotal = _listPool.Count;
|
||||
int hwm;
|
||||
lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex;
|
||||
return (poolTotal, hwm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor
|
||||
// Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step
|
||||
|
|
@ -1004,12 +989,6 @@ public sealed partial class EnvCellRenderer :
|
|||
}
|
||||
}
|
||||
|
||||
// #176 seam-draw probe: stash this call's filter so the opaque-pass
|
||||
// emitter inside RenderModernMDIInternal can report flood membership
|
||||
// per target cell (null on the unfiltered/outdoor path).
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||||
_seamProbeFilter = filter;
|
||||
|
||||
// WB EnvCellRenderManager.cs:470-483:
|
||||
if (allInstances.Count > 0)
|
||||
{
|
||||
|
|
@ -1421,104 +1400,6 @@ public sealed partial class EnvCellRenderer :
|
|||
ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount, materialState));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus.
|
||||
// The in-engine replacement for the RenderDoc pixel-history the pipeline
|
||||
// can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern
|
||||
// startup gate throws). Per opaque pass: for each target cell — flood
|
||||
// membership, every shell instance (count + translation, F3 z shows the
|
||||
// +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the
|
||||
// cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 +
|
||||
// intensity; raw indices shuffle when the pool rebuilds). Plus the
|
||||
// snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are
|
||||
// ~1–2). Change-deduped block with a 2 s heartbeat: a purple identity
|
||||
// flipping with flood membership = the snapshot-scope mechanism; two
|
||||
// coincident instances = the z-fight. See RenderingDiagnostics.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private HashSet<uint>? _seamProbeFilter;
|
||||
private string? _seamSig;
|
||||
private long _seamLastEmitMs;
|
||||
|
||||
private void EmitSeamDrawProbe(
|
||||
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||||
List<InstanceData> allInstances,
|
||||
HashSet<uint>? filter)
|
||||
{
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var snap = _pointSnapshot;
|
||||
var sb = new System.Text.StringBuilder(640);
|
||||
|
||||
var sorted = new List<uint>(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells);
|
||||
sorted.Sort();
|
||||
foreach (uint cell in sorted)
|
||||
{
|
||||
sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8"));
|
||||
sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N'));
|
||||
|
||||
int totalInst = 0;
|
||||
foreach (var dc in drawCalls)
|
||||
{
|
||||
int n = 0;
|
||||
InstanceData first = default;
|
||||
for (int i = dc.offset; i < dc.offset + dc.count; i++)
|
||||
{
|
||||
if (allInstances[i].CellId != cell) continue;
|
||||
if (n == 0) first = allInstances[i];
|
||||
n++;
|
||||
}
|
||||
if (n == 0) continue;
|
||||
totalInst += n;
|
||||
var t = first.Transform.Translation;
|
||||
sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})",
|
||||
dc.gfxObjId, n, t.X, t.Y, t.Z);
|
||||
}
|
||||
if (totalInst == 0) sb.Append(" inst=0");
|
||||
|
||||
// The 8-light set this cell's instances carry (fresh: the per-pass
|
||||
// cache was cleared at the top of RenderModernMDIInternal).
|
||||
int[] set = GetCellLightSet(cell);
|
||||
sb.Append(" L=[");
|
||||
bool any = false;
|
||||
for (int k = 0; k < set.Length; k++)
|
||||
{
|
||||
int idx = set[k];
|
||||
if (idx < 0) continue;
|
||||
if (any) sb.Append(',');
|
||||
if (snap is not null && idx < snap.Count)
|
||||
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||||
else
|
||||
sb.Append('?').Append(idx);
|
||||
any = true;
|
||||
}
|
||||
sb.Append(']');
|
||||
}
|
||||
|
||||
sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=[");
|
||||
if (snap is not null)
|
||||
{
|
||||
bool anyHot = false;
|
||||
for (int i = 0; i < snap.Count; i++)
|
||||
{
|
||||
var ls = snap[i];
|
||||
if (ls.Intensity < 50f) continue;
|
||||
if (anyHot) sb.Append(',');
|
||||
sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})",
|
||||
ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z);
|
||||
anyHot = true;
|
||||
}
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
string sig = sb.ToString();
|
||||
long now = System.Environment.TickCount64;
|
||||
bool changed = sig != _seamSig;
|
||||
if (!changed && (now - _seamLastEmitMs) < 2000) return;
|
||||
_seamSig = sig;
|
||||
_seamLastEmitMs = now;
|
||||
System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List pool (GetPooledList)
|
||||
// Copied from WB ObjectRenderManagerBase (pattern).
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
using System.Text;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.Core.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Temporary issue-177 live probe. The classifier-side facility trace proves
|
||||
/// whether the stair GfxObj survives the retail drawing-sphere test; this
|
||||
/// companion trace follows that same exact object through the ordered stream,
|
||||
/// ring upload, and final indirect draw call. It is inert unless
|
||||
/// <c>ACDREAM_PROBE_FACILITY_STAIRS=1</c>.
|
||||
/// </summary>
|
||||
public sealed partial class WbDrawDispatcher
|
||||
{
|
||||
private const uint FacilityStairSubmissionLocalId = 0x4F41806Cu;
|
||||
|
||||
private readonly List<FacilityStairSubmissionCommand> _facilityStairSubmissionCommands = new(4);
|
||||
private readonly HashSet<int> _facilityStairSubmittedCommands = new();
|
||||
private int _facilityStairSubmissionFrame = -1;
|
||||
private bool _facilityStairPrepareSeen;
|
||||
private bool _facilityStairUploadCompleted;
|
||||
private int _facilityStairPreparedCount;
|
||||
private int _facilityStairSubmissionRangeFirst = -1;
|
||||
private int _facilityStairSubmissionRangeCount;
|
||||
private int _facilityStairSubmissionRunFirst = -1;
|
||||
private int _facilityStairSubmissionRunCount;
|
||||
private readonly record struct FacilityStairSubmissionCommand(
|
||||
int CommandIndex,
|
||||
uint CellId,
|
||||
WalkDrawStage Stage,
|
||||
System.Numerics.Vector3 Translation,
|
||||
GroupKey Key);
|
||||
|
||||
internal void BeginFacilityStairSubmissionProbeFrame()
|
||||
{
|
||||
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
return;
|
||||
|
||||
_facilityStairSubmissionCommands.Clear();
|
||||
_facilityStairSubmittedCommands.Clear();
|
||||
_facilityStairPrepareSeen = false;
|
||||
_facilityStairUploadCompleted = false;
|
||||
_facilityStairPreparedCount = 0;
|
||||
_facilityStairSubmissionRangeFirst = -1;
|
||||
_facilityStairSubmissionRangeCount = 0;
|
||||
_facilityStairSubmissionRunFirst = -1;
|
||||
_facilityStairSubmissionRunCount = 0;
|
||||
_facilityStairSubmissionFrame = WalkPView.MasterTimestampForDiagnostics;
|
||||
}
|
||||
|
||||
internal void ProbeFacilityStairCommandAppended(
|
||||
int commandIndex,
|
||||
uint localEntityId,
|
||||
uint cellId,
|
||||
WalkDrawStage stage,
|
||||
in WalkClassifiedBatch batch)
|
||||
{
|
||||
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled
|
||||
|| localEntityId != FacilityStairSubmissionLocalId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_facilityStairSubmissionCommands.Add(new FacilityStairSubmissionCommand(
|
||||
commandIndex,
|
||||
cellId,
|
||||
stage,
|
||||
batch.Transform.Translation,
|
||||
batch.Key));
|
||||
}
|
||||
|
||||
private void ProbeFacilityStairPrepareStarted(int streamCount)
|
||||
{
|
||||
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
return;
|
||||
|
||||
int frame = WalkPView.MasterTimestampForDiagnostics;
|
||||
_facilityStairPrepareSeen = true;
|
||||
_facilityStairPreparedCount = streamCount;
|
||||
|
||||
EmitFacilityStairSubmissionProbe("prepare");
|
||||
}
|
||||
|
||||
private void ProbeFacilityStairUploadCompleted(int preparedCount)
|
||||
{
|
||||
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
return;
|
||||
|
||||
_facilityStairUploadCompleted = true;
|
||||
_facilityStairPreparedCount = preparedCount;
|
||||
EmitFacilityStairSubmissionProbe("upload");
|
||||
}
|
||||
|
||||
private void ProbeFacilityStairRunSubmitted(
|
||||
int rangeFirst,
|
||||
int rangeCount,
|
||||
int runFirst,
|
||||
int runCount)
|
||||
{
|
||||
if (!RenderingDiagnostics.ProbeFacilityStairsEnabled
|
||||
|| _facilityStairSubmissionCommands.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int runEnd = runFirst + runCount;
|
||||
int submittedBefore = _facilityStairSubmittedCommands.Count;
|
||||
for (int i = 0; i < _facilityStairSubmissionCommands.Count; i++)
|
||||
{
|
||||
int commandIndex = _facilityStairSubmissionCommands[i].CommandIndex;
|
||||
if (commandIndex < runFirst || commandIndex >= runEnd)
|
||||
continue;
|
||||
|
||||
_facilityStairSubmittedCommands.Add(commandIndex);
|
||||
_facilityStairSubmissionRangeFirst = rangeFirst;
|
||||
_facilityStairSubmissionRangeCount = rangeCount;
|
||||
_facilityStairSubmissionRunFirst = runFirst;
|
||||
_facilityStairSubmissionRunCount = runCount;
|
||||
}
|
||||
|
||||
if (_facilityStairSubmittedCommands.Count != submittedBefore)
|
||||
EmitFacilityStairSubmissionProbe("submit");
|
||||
}
|
||||
|
||||
private void EmitFacilityStairSubmissionProbe(string phase)
|
||||
{
|
||||
if (_facilityStairSubmissionFrame < 0
|
||||
|| _facilityStairSubmissionCommands.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var commandDetails = new StringBuilder();
|
||||
for (int i = 0; i < _facilityStairSubmissionCommands.Count; i++)
|
||||
{
|
||||
FacilityStairSubmissionCommand command = _facilityStairSubmissionCommands[i];
|
||||
if (i != 0)
|
||||
commandDetails.Append(';');
|
||||
commandDetails.Append(command.CommandIndex)
|
||||
.Append(':').Append(command.Stage)
|
||||
.Append(":0x").Append(command.CellId.ToString("X8"))
|
||||
.Append(":(")
|
||||
.Append(command.Translation.X.ToString("F4"))
|
||||
.Append(',')
|
||||
.Append(command.Translation.Y.ToString("F4"))
|
||||
.Append(',')
|
||||
.Append(command.Translation.Z.ToString("F4"))
|
||||
.Append(')')
|
||||
.Append(':').Append(command.Key.CullMode)
|
||||
.Append(':').Append(command.Key.FirstIndex)
|
||||
.Append('+').Append(command.Key.IndexCount)
|
||||
.Append(':').Append(command.Key.BaseVertex);
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"[facility-stair-path] phase={phase} frame={_facilityStairSubmissionFrame} "
|
||||
+ $"local=0x{FacilityStairSubmissionLocalId:X8} "
|
||||
+ $"commands={_facilityStairSubmissionCommands.Count} "
|
||||
+ $"detail={commandDetails} "
|
||||
+ $"prepare={(_facilityStairPrepareSeen ? 1 : 0)} "
|
||||
+ $"preparedCount={_facilityStairPreparedCount} "
|
||||
+ $"uploaded={(_facilityStairUploadCompleted ? 1 : 0)} "
|
||||
+ $"submitted={_facilityStairSubmittedCommands.Count}/"
|
||||
+ $"{_facilityStairSubmissionCommands.Count} "
|
||||
+ $"range={_facilityStairSubmissionRangeFirst}+"
|
||||
+ $"{_facilityStairSubmissionRangeCount} "
|
||||
+ $"run={_facilityStairSubmissionRunFirst}+"
|
||||
+ $"{_facilityStairSubmissionRunCount}");
|
||||
}
|
||||
}
|
||||
|
|
@ -350,7 +350,6 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
_orderedStream = stream;
|
||||
_orderedFrame = frame;
|
||||
_orderedPreparedCount = 0;
|
||||
ProbeFacilityStairPrepareStarted(stream.Count);
|
||||
|
||||
// Fail loud before any GPU work: a PortalPunch command has no
|
||||
// submission path.
|
||||
|
|
@ -433,7 +432,6 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
checked((uint)(count * DrawCommandStride)));
|
||||
|
||||
_orderedPreparedCount = count;
|
||||
ProbeFacilityStairUploadCompleted(count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -587,11 +585,6 @@ public sealed unsafe partial class WbDrawDispatcher
|
|||
DrawIndirectRangeRhi(
|
||||
encoder, ref pushConstants, commandBuffer, commandBase,
|
||||
run.FirstCommand, run.CommandCount, _orderedDrawCullModes);
|
||||
ProbeFacilityStairRunSubmitted(
|
||||
firstCommand,
|
||||
commandCount,
|
||||
run.FirstCommand,
|
||||
run.CommandCount);
|
||||
}
|
||||
|
||||
ClearDetailPushConstants(ref pushConstants);
|
||||
|
|
|
|||
|
|
@ -55,20 +55,6 @@ public sealed partial class WbDrawDispatcher
|
|||
{
|
||||
private readonly HashSet<WalkDrawnPartKey> _walkDrawnParts = new();
|
||||
private bool _walkPartFrameActive;
|
||||
private readonly Dictionary<string, string> _facilityStairProbeSignatures = new();
|
||||
private Dictionary<uint, FacilityStairProbeSnapshot> _facilityStairsCurrent = new();
|
||||
private Dictionary<uint, FacilityStairProbeSnapshot> _facilityStairsPrevious = new();
|
||||
private int _facilityStairsCurrentFrame = -1;
|
||||
private int _facilityStairsPreviousFrame = -1;
|
||||
|
||||
private readonly record struct FacilityStairProbeSnapshot(
|
||||
uint CellId,
|
||||
int RouteIndex,
|
||||
bool Admitted,
|
||||
bool ScreenIntersect,
|
||||
string ScreenBounds,
|
||||
Vector3 WorldCenter);
|
||||
|
||||
private readonly record struct WalkDrawnPartKey(
|
||||
RenderProjectionId ProjectionId,
|
||||
int PartIndex);
|
||||
|
|
@ -93,7 +79,6 @@ public sealed partial class WbDrawDispatcher
|
|||
|
||||
_missRequested.Clear();
|
||||
_walkDrawnParts.Clear();
|
||||
BeginFacilityStairSubmissionProbeFrame();
|
||||
_walkPartFrameActive = true;
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +260,6 @@ public sealed partial class WbDrawDispatcher
|
|||
IWalkLookInViewSource? lookInViews = null,
|
||||
int lookInRouteIndex = -1,
|
||||
uint lookInCellId = 0,
|
||||
Matrix4x4 diagnosticViewProjection = default,
|
||||
WalkBuildingSelection? buildingSelection = null,
|
||||
Matrix4x4 buildingPartTransform = default)
|
||||
{
|
||||
|
|
@ -369,17 +353,6 @@ public sealed partial class WbDrawDispatcher
|
|||
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||
if (partData is null)
|
||||
{
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
|
||||
&& entity.LocalEntityId == 0x4F41806Cu
|
||||
&& gfxObjId == 0x01001FE8u)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[facility-part] frame={WalkPView.MasterTimestampForDiagnostics} "
|
||||
+ $"local=0x{entity.LocalEntityId:X8} "
|
||||
+ $"cell=0x{lookInCellId:X8} gfx=0x{gfxObjId:X8} "
|
||||
+ $"part={setupPartIndex} route={lookInRouteIndex} "
|
||||
+ "decision=render-data-miss");
|
||||
}
|
||||
if (_missRequested.Add(gfxObjId))
|
||||
_meshAdapter.EnsureLoaded(gfxObjId);
|
||||
continue;
|
||||
|
|
@ -393,12 +366,6 @@ public sealed partial class WbDrawDispatcher
|
|||
: 1f;
|
||||
if (opacity <= 0f)
|
||||
{
|
||||
EmitFacilityStairPartProbe(
|
||||
in projection, in entity, lookInCellId,
|
||||
lookInRouteIndex, (uint)gfxObjId, setupPartIndex,
|
||||
partData, default, diagnosticViewProjection,
|
||||
default, 0f, hasSphere: false,
|
||||
opacity, "opacity-zero", lookInViews);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -411,21 +378,11 @@ public sealed partial class WbDrawDispatcher
|
|||
lookInRouteIndex,
|
||||
partData,
|
||||
model,
|
||||
out Vector3 sphereCenter,
|
||||
out float sphereRadius,
|
||||
out bool hasSphere);
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
bool firstAdmission = visible
|
||||
&& TryStampWalkPart(in projection, selectionPartIndex);
|
||||
EmitFacilityStairPartProbe(
|
||||
in projection, in entity, lookInCellId,
|
||||
lookInRouteIndex, (uint)gfxObjId, setupPartIndex,
|
||||
partData, model, diagnosticViewProjection,
|
||||
sphereCenter, sphereRadius, hasSphere,
|
||||
opacity,
|
||||
!visible
|
||||
? "sphere-reject"
|
||||
: firstAdmission ? "admit" : "already-drawn",
|
||||
lookInViews);
|
||||
if (!firstAdmission)
|
||||
continue;
|
||||
EmitClassifiedBatches(
|
||||
|
|
@ -448,12 +405,6 @@ public sealed partial class WbDrawDispatcher
|
|||
: 1f;
|
||||
if (opacity <= 0f)
|
||||
{
|
||||
EmitFacilityStairPartProbe(
|
||||
in projection, in entity, lookInCellId,
|
||||
lookInRouteIndex, (uint)meshRef.GfxObjId, partIndex,
|
||||
renderData, default, diagnosticViewProjection,
|
||||
default, 0f, hasSphere: false,
|
||||
opacity, "opacity-zero", lookInViews);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -463,21 +414,11 @@ public sealed partial class WbDrawDispatcher
|
|||
lookInRouteIndex,
|
||||
renderData,
|
||||
model,
|
||||
out Vector3 sphereCenter,
|
||||
out float sphereRadius,
|
||||
out bool hasSphere);
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
bool firstAdmission = visible
|
||||
&& TryStampWalkPart(in projection, partIndex);
|
||||
EmitFacilityStairPartProbe(
|
||||
in projection, in entity, lookInCellId,
|
||||
lookInRouteIndex, (uint)meshRef.GfxObjId, partIndex,
|
||||
renderData, model, diagnosticViewProjection,
|
||||
sphereCenter, sphereRadius, hasSphere,
|
||||
opacity,
|
||||
!visible
|
||||
? "sphere-reject"
|
||||
: firstAdmission ? "admit" : "already-drawn",
|
||||
lookInViews);
|
||||
if (!firstAdmission)
|
||||
continue;
|
||||
EmitClassifiedBatches(
|
||||
|
|
@ -585,213 +526,6 @@ public sealed partial class WbDrawDispatcher
|
|||
return lookInViews.SphereVisibleInLookInTurn(routeIndex, in sphereCenter, sphereRadius);
|
||||
}
|
||||
|
||||
private void EmitFacilityStairPartProbe(
|
||||
in RenderProjectionRecord projection,
|
||||
in RenderInstanceCandidate entity,
|
||||
uint cellId,
|
||||
int routeIndex,
|
||||
uint gfxObjId,
|
||||
int partIndex,
|
||||
ObjectRenderData renderData,
|
||||
Matrix4x4 localToWorld,
|
||||
Matrix4x4 viewProjection,
|
||||
Vector3 sphereCenter,
|
||||
float sphereRadius,
|
||||
bool hasSphere,
|
||||
float opacity,
|
||||
string decision,
|
||||
IWalkLookInViewSource? lookInViews)
|
||||
{
|
||||
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||||
return;
|
||||
|
||||
bool targetStairCell = cellId is 0x8A02015Eu or 0x8A02015Fu;
|
||||
bool targetPlayerCell = targetStairCell || cellId == 0x8A0201C1u;
|
||||
bool targetStair = targetStairCell && gfxObjId == 0x010000DEu;
|
||||
bool targetPlayer = targetPlayerCell
|
||||
&& projection.EntityPayload.CasterIdentity
|
||||
== RenderCasterIdentityKind.LocalPlayer;
|
||||
// Static render projections carry the local render identity here;
|
||||
// SourceId is not guaranteed to retain the Setup DID after the
|
||||
// landblock projection has been materialized.
|
||||
// Cathedral floating stair Setup 0x020009A2 is seven independent
|
||||
// visual parts (0x01001FE8 + six 0x01001FE6 slabs). Tracing only the
|
||||
// first GfxObj hid the actual camera-angle failure on the six visible
|
||||
// slabs, so keep the probe keyed to the complete PartArray owner.
|
||||
bool targetCathedralRamp = entity.LocalEntityId == 0x4F41806Cu;
|
||||
if (!targetStair && !targetPlayer && !targetCathedralRamp)
|
||||
return;
|
||||
|
||||
int frame = WalkPView.MasterTimestampForDiagnostics;
|
||||
string screenBounds = "unavailable";
|
||||
bool screenIntersect = false;
|
||||
if (targetStair || targetCathedralRamp)
|
||||
{
|
||||
AdvanceFacilityStairProbeFrame(frame);
|
||||
screenIntersect = TryProjectFacilityStairBounds(
|
||||
renderData,
|
||||
localToWorld,
|
||||
viewProjection,
|
||||
out screenBounds);
|
||||
var snapshot = new FacilityStairProbeSnapshot(
|
||||
cellId,
|
||||
routeIndex,
|
||||
string.Equals(decision, "admit", StringComparison.Ordinal),
|
||||
screenIntersect,
|
||||
screenBounds,
|
||||
hasSphere ? sphereCenter : localToWorld.Translation);
|
||||
if (!_facilityStairsCurrent.TryGetValue(
|
||||
entity.LocalEntityId,
|
||||
out FacilityStairProbeSnapshot existing)
|
||||
|| (!existing.Admitted && snapshot.Admitted))
|
||||
{
|
||||
_facilityStairsCurrent[entity.LocalEntityId] = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
string key = $"{entity.ServerGuid:X8}:{entity.LocalEntityId:X8}:"
|
||||
+ $"{cellId:X8}:{gfxObjId:X8}:{partIndex}";
|
||||
// A route index is allocated afresh every frame. Including it in the
|
||||
// signature made a stationary cathedral trace print at frame rate and
|
||||
// obscure the useful admit/reject transition. Cell + part are already
|
||||
// in the key; emit again only when that route's decision changes.
|
||||
string signature = targetCathedralRamp
|
||||
? $"{decision}:{(hasSphere ? 1 : 0)}:"
|
||||
+ $"{(opacity <= 0f ? 0 : opacity < 1f ? 1 : 2)}"
|
||||
: $"{routeIndex}:{decision}:{(hasSphere ? 1 : 0)}:"
|
||||
+ $"{(opacity <= 0f ? 0 : opacity < 1f ? 1 : 2)}";
|
||||
if (_facilityStairProbeSignatures.TryGetValue(key, out string? prior)
|
||||
&& string.Equals(prior, signature, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_facilityStairProbeSignatures[key] = signature;
|
||||
|
||||
string authored = renderData.SelectionSphere is { } sourceSphere
|
||||
? $" authored=({sourceSphere.Origin.X:F4},{sourceSphere.Origin.Y:F4},"
|
||||
+ $"{sourceSphere.Origin.Z:F4};r={sourceSphere.Radius:F4})"
|
||||
: " authored=none";
|
||||
string world = hasSphere
|
||||
? $" world=({sphereCenter.X:F4},{sphereCenter.Y:F4},"
|
||||
+ $"{sphereCenter.Z:F4};r={sphereRadius:F4})"
|
||||
: " world=none";
|
||||
string cone = targetCathedralRamp && hasSphere && lookInViews is not null
|
||||
? " cone={" + lookInViews.DescribeLookInTurn(
|
||||
routeIndex, in sphereCenter, sphereRadius) + "}"
|
||||
: string.Empty;
|
||||
Console.WriteLine(
|
||||
$"[facility-part] frame={frame} "
|
||||
+ $"kind={projection.EntityPayload.CasterIdentity} "
|
||||
+ $"guid=0x{entity.ServerGuid:X8} local=0x{entity.LocalEntityId:X8} "
|
||||
+ $"cell=0x{cellId:X8} parent=0x{entity.ParentCellId:X8} "
|
||||
+ $"gfx=0x{gfxObjId:X8} part={partIndex} route={routeIndex} "
|
||||
+ $"opacity={opacity:F4} decision={decision} "
|
||||
+ $"screen={(screenIntersect ? 1 : 0)} ndc={screenBounds}"
|
||||
+ authored + world + cone);
|
||||
}
|
||||
|
||||
private void AdvanceFacilityStairProbeFrame(int frame)
|
||||
{
|
||||
if (frame == _facilityStairsCurrentFrame)
|
||||
return;
|
||||
|
||||
if (_facilityStairsCurrentFrame >= 0)
|
||||
{
|
||||
if (_facilityStairsPreviousFrame >= 0)
|
||||
{
|
||||
foreach ((uint localId, FacilityStairProbeSnapshot previous)
|
||||
in _facilityStairsPrevious)
|
||||
{
|
||||
if (!_facilityStairsCurrent.TryGetValue(localId, out _))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[facility-stair-drop] frame={_facilityStairsCurrentFrame} "
|
||||
+ $"previousFrame={_facilityStairsPreviousFrame} "
|
||||
+ $"local=0x{localId:X8} cell=0x{previous.CellId:X8} "
|
||||
+ $"route={previous.RouteIndex} admitted={(previous.Admitted ? 1 : 0)} "
|
||||
+ $"screen={(previous.ScreenIntersect ? 1 : 0)} "
|
||||
+ $"lastNdc={previous.ScreenBounds} "
|
||||
+ $"world=({previous.WorldCenter.X:F4},{previous.WorldCenter.Y:F4},"
|
||||
+ $"{previous.WorldCenter.Z:F4}) reason=cell-not-walked");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((uint localId, FacilityStairProbeSnapshot current)
|
||||
in _facilityStairsCurrent)
|
||||
{
|
||||
if (_facilityStairsPrevious.TryGetValue(
|
||||
localId,
|
||||
out FacilityStairProbeSnapshot previous)
|
||||
&& current.Admitted != previous.Admitted)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[facility-stair-decision] frame={_facilityStairsCurrentFrame} "
|
||||
+ $"local=0x{localId:X8} cell=0x{current.CellId:X8} "
|
||||
+ $"admitted={(previous.Admitted ? 1 : 0)}->"
|
||||
+ $"{(current.Admitted ? 1 : 0)} "
|
||||
+ $"screen={(current.ScreenIntersect ? 1 : 0)} "
|
||||
+ $"ndc={current.ScreenBounds}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<uint, FacilityStairProbeSnapshot> swap =
|
||||
_facilityStairsPrevious;
|
||||
_facilityStairsPrevious = _facilityStairsCurrent;
|
||||
_facilityStairsCurrent = swap;
|
||||
_facilityStairsCurrent.Clear();
|
||||
_facilityStairsPreviousFrame = _facilityStairsCurrentFrame;
|
||||
}
|
||||
|
||||
_facilityStairsCurrentFrame = frame;
|
||||
}
|
||||
|
||||
private static bool TryProjectFacilityStairBounds(
|
||||
ObjectRenderData renderData,
|
||||
Matrix4x4 localToWorld,
|
||||
Matrix4x4 viewProjection,
|
||||
out string bounds)
|
||||
{
|
||||
Vector3 min = renderData.BoundingBox.Min;
|
||||
Vector3 max = renderData.BoundingBox.Max;
|
||||
float minX = float.PositiveInfinity;
|
||||
float minY = float.PositiveInfinity;
|
||||
float maxX = float.NegativeInfinity;
|
||||
float maxY = float.NegativeInfinity;
|
||||
int projected = 0;
|
||||
|
||||
for (int corner = 0; corner < 8; corner++)
|
||||
{
|
||||
var local = new Vector3(
|
||||
(corner & 1) == 0 ? min.X : max.X,
|
||||
(corner & 2) == 0 ? min.Y : max.Y,
|
||||
(corner & 4) == 0 ? min.Z : max.Z);
|
||||
Vector3 world = Vector3.Transform(local, localToWorld);
|
||||
Vector4 clip = Vector4.Transform(new Vector4(world, 1f), viewProjection);
|
||||
if (!float.IsFinite(clip.W) || clip.W <= 0.0001f)
|
||||
continue;
|
||||
|
||||
float x = clip.X / clip.W;
|
||||
float y = clip.Y / clip.W;
|
||||
if (!float.IsFinite(x) || !float.IsFinite(y))
|
||||
continue;
|
||||
minX = MathF.Min(minX, x);
|
||||
minY = MathF.Min(minY, y);
|
||||
maxX = MathF.Max(maxX, x);
|
||||
maxY = MathF.Max(maxY, y);
|
||||
projected++;
|
||||
}
|
||||
|
||||
if (projected == 0)
|
||||
{
|
||||
bounds = "behind";
|
||||
return false;
|
||||
}
|
||||
|
||||
bounds = $"({minX:F3},{minY:F3})-({maxX:F3},{maxY:F3});corners={projected}";
|
||||
return maxX >= -1f && minX <= 1f && maxY >= -1f && minY <= 1f;
|
||||
}
|
||||
|
||||
internal static bool LookInDrawingSphereVisible(
|
||||
IWalkLookInViewSource lookInViews,
|
||||
int routeIndex,
|
||||
|
|
|
|||
|
|
@ -750,31 +750,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
private bool _disposing;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Per-cell-entity last-log frame number for rate-limiting the
|
||||
/// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
|
||||
/// probes. Defaults to 30 frames at 30Hz = 1 sec.
|
||||
/// </summary>
|
||||
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
|
||||
private int _indoorProbeFrameCounter;
|
||||
private const int IndoorProbeRateLimitFrames = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
|
||||
/// frames per cellId. Caller must already have checked that an indoor
|
||||
/// probe flag is enabled.
|
||||
/// </summary>
|
||||
private bool ShouldEmitIndoorProbe(ulong cellId)
|
||||
{
|
||||
if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
|
||||
|| _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
|
||||
{
|
||||
_lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
|
||||
private int _entitiesSeen;
|
||||
private int _entitiesDrawn;
|
||||
|
|
@ -947,16 +922,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
|
||||
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
|
||||
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
|
||||
///
|
||||
/// <para>
|
||||
/// When <paramref name="indoorProbeState"/> is non-null the method emits
|
||||
/// <c>[indoor-cull]</c> lines for cell entities rejected by the
|
||||
/// visibleCellIds or frustum filters, and <c>[indoor-walk]</c> lines for
|
||||
/// cell entities that pass all filters. Rate-limited by
|
||||
/// <see cref="IndoorProbeState"/>. Pass <see langword="null"/> (the default)
|
||||
/// to disable all probe emission — used by the test-friendly
|
||||
/// <see cref="WalkEntities"/> overload.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void WalkEntitiesInto(
|
||||
IEnumerable<LandblockEntry> landblockEntries,
|
||||
|
|
@ -966,7 +931,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
HashSet<uint>? animatedEntityIds,
|
||||
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
||||
ref WalkResult result,
|
||||
IndoorProbeState? indoorProbeState = null,
|
||||
EntitySet set = EntitySet.All)
|
||||
{
|
||||
scratch.Clear();
|
||||
|
|
@ -1015,13 +979,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
|
||||
// is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
|
||||
// result reused for all probe checks below.
|
||||
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
|
||||
bool isCellEntity = indoorProbeState is not null
|
||||
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
|
||||
|
||||
bool shellScoped = IsShellScopedSet(set)
|
||||
&& entity.IsBuildingShell
|
||||
&& visibleCellIds is not null;
|
||||
|
|
@ -1029,14 +986,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
if (!cellInVis)
|
||||
{
|
||||
if (shellScoped) result.BuildingShellAnchorReject++;
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
|
||||
$"reason=visibleCellIds-miss " +
|
||||
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (shellScoped) result.BuildingShellAnchorPass++;
|
||||
|
|
@ -1054,31 +1003,9 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
|
||||
if (!aabbVisible)
|
||||
{
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
|
||||
$"reason=frustum " +
|
||||
$"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
|
||||
$"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Passed all filters — emit walk probe.
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
|
||||
$"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
|
||||
$"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
|
||||
$"meshRef0=0x{cellProbeId:X8} " +
|
||||
$"meshRefCount={entity.MeshRefs.Count} " +
|
||||
$"landblockVisible=true aabbVisible=true cellInVis=true");
|
||||
}
|
||||
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
scratch.Add((entity, i, entry.LandblockId));
|
||||
|
|
@ -1196,23 +1123,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
// that populates _walkScratch (a per-dispatcher field reused across frames)
|
||||
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
|
||||
//
|
||||
// Pass an IndoorProbeState when any indoor probe is active so the static
|
||||
// WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk]
|
||||
// lines without needing access to instance fields. Null = probes off.
|
||||
IndoorProbeState? probeState = null;
|
||||
if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled)
|
||||
{
|
||||
// _currentFrame is snapped at construction time. Construct
|
||||
// once per Draw() call only — a second construction within
|
||||
// the same frame would stamp the dictionary with the
|
||||
// (already-advanced) counter value, suppressing the second
|
||||
// pass's emissions for IndoorProbeRateLimitFrames frames.
|
||||
// Today Draw() is called exactly once per frame; if a
|
||||
// future refactor adds a shadow / reflection / second pass,
|
||||
// this assumption needs revisiting.
|
||||
probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter);
|
||||
}
|
||||
|
||||
var walkResult = default(WalkResult);
|
||||
WalkEntitiesInto(
|
||||
ToEntries(landblockEntries),
|
||||
|
|
@ -1222,7 +1132,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
animatedEntityIds,
|
||||
_walkScratch,
|
||||
ref walkResult,
|
||||
probeState,
|
||||
set);
|
||||
_currentRenderSceneObserver?.ObserveDispatcherDraw(
|
||||
set,
|
||||
|
|
@ -1358,14 +1267,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
? new Vector2(lighting.Luminosity, lighting.Diffuse)
|
||||
: new Vector2(0f, 1f);
|
||||
|
||||
// #176 seam-draw probe: any entity parented to a target cell reports
|
||||
// its position + light set (a floor-coincident static/plate would be
|
||||
// the z-fight's second draw; the player entity is the positive
|
||||
// control). Before the culled-continue, like the dump above.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
|
||||
&& entity.ParentCell is { } seamPc
|
||||
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
|
||||
MaybeEmitSeamEnt(entity);
|
||||
}
|
||||
prevTupleEntityId = entity.Id;
|
||||
|
||||
|
|
@ -1468,41 +1369,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
|
||||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||
|
||||
// [indoor-lookup] probe — emit once per cell entity per sec.
|
||||
// Fires BEFORE the null-renderData early-continue so a miss still
|
||||
// emits hit=false, distinguishing H2 (empty batches) from H6
|
||||
// (dispatcher fails to traverse Setup).
|
||||
ulong lookupCellId = (ulong)gfxObjId;
|
||||
if (RenderingDiagnostics.IsEnvCellId(lookupCellId)
|
||||
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
|
||||
// Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull]
|
||||
// (which key on the same gfxObjId). Without this, IndoorAll=1 would
|
||||
// silence the lookup probe whenever the walk probe fired first.
|
||||
&& ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL))
|
||||
{
|
||||
bool hit = renderData is not null;
|
||||
bool isSetup = hit && renderData!.IsSetup;
|
||||
int partCount = isSetup ? renderData!.SetupParts.Count : 0;
|
||||
|
||||
int partsHit = 0, partsMiss = 0;
|
||||
if (isSetup)
|
||||
{
|
||||
foreach (var (partId, _) in renderData!.SetupParts)
|
||||
{
|
||||
if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
|
||||
else partsMiss++;
|
||||
}
|
||||
}
|
||||
|
||||
bool hasEnvCellGeom = isSetup
|
||||
&& renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
|
||||
|
||||
Console.WriteLine(
|
||||
$"[indoor-lookup] cellId=0x{lookupCellId:X8} " +
|
||||
$"hit={hit} isSetup={isSetup} partCount={partCount} " +
|
||||
$"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
|
||||
}
|
||||
|
||||
if (renderData is null)
|
||||
{
|
||||
// Tier 1 cache (#53): mesh data is still async-decoding via
|
||||
|
|
@ -1595,23 +1461,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
var model = ComposePartWorldMatrix(
|
||||
entityWorld, meshRef.PartTransform, partTransform);
|
||||
|
||||
// [indoor-xform] probe — only for the cell's synthetic
|
||||
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
|
||||
// cellGeomId convention). One line per part per sec.
|
||||
// Disambiguates hypothesis H5 (transform double-apply —
|
||||
// composedT lands at 2 × cellOrigin).
|
||||
if ((partGfxObjId & 0x1_0000_0000UL) != 0
|
||||
&& RenderingDiagnostics.ProbeIndoorXformEnabled
|
||||
&& ShouldEmitIndoorProbe(partGfxObjId))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
|
||||
$"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
|
||||
$"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
|
||||
$"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
|
||||
$"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
|
||||
}
|
||||
|
||||
var restPose = partTransform * meshRef.PartTransform;
|
||||
|
||||
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
|
||||
|
|
@ -1718,11 +1567,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
populateEntityId, populateLandblockId, _cache,
|
||||
_populateScratch, _populateSelectionScratch);
|
||||
|
||||
// S3 review fix round 1 (F5): the §4 flap [clip-route-disp] probe
|
||||
// (2026-06-10, throwaway) is deleted with the routing state it
|
||||
// reported on — it could only ever print "cullEnt=0" now that
|
||||
// ResolveSlotForFrame never culls anything.
|
||||
|
||||
ExecuteClassifiedGroups(
|
||||
vp,
|
||||
camPos,
|
||||
|
|
@ -1777,7 +1621,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
out Vector3 cameraWorldPosition)
|
||||
{
|
||||
_selectionLighting?.TickLighting();
|
||||
_indoorProbeFrameCounter++;
|
||||
viewProjection = camera.View * camera.Projection;
|
||||
_missRequested.Clear();
|
||||
|
||||
|
|
@ -3085,44 +2928,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. One
|
||||
// [seam-ent] line per target-cell entity, re-emitted on state change: world
|
||||
// position (F3 z — entities do NOT get the +0.02 shell lift), cull/slot,
|
||||
// and the SelectForObject light set resolved to identities (owner-cell
|
||||
// low16 + intensity). Sig dict is bounded by the handful of entities that
|
||||
// ever live in the target cells.
|
||||
private readonly Dictionary<ulong, string> _seamEntSigs = new();
|
||||
|
||||
private void MaybeEmitSeamEnt(in RenderInstanceCandidate entity)
|
||||
{
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var snap = _pointSnapshot;
|
||||
var sb = new System.Text.StringBuilder(200);
|
||||
sb.AppendFormat(ci,
|
||||
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
|
||||
entity.ServerGuid, entity.ParentCellId,
|
||||
entity.Position.X, entity.Position.Y, entity.Position.Z,
|
||||
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
|
||||
bool any = false;
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
{
|
||||
int idx = _currentEntityLightSet[k];
|
||||
if (idx < 0) continue;
|
||||
if (any) sb.Append(',');
|
||||
if (snap is not null && idx < snap.Count)
|
||||
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||||
else
|
||||
sb.Append('?').Append(idx);
|
||||
any = true;
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
string sig = sb.ToString();
|
||||
if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return;
|
||||
_seamEntSigs[entity.Id] = sig;
|
||||
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
|
||||
}
|
||||
|
||||
private void ComputeEntityLightSet(
|
||||
in RenderInstanceCandidate entity)
|
||||
{
|
||||
|
|
@ -3632,41 +3437,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around an instance's rate-limit dictionary + frame
|
||||
/// counter, passed into the static <see cref="WalkEntitiesInto"/>
|
||||
/// overload so it can emit rate-limited probe lines without access
|
||||
/// to instance fields. Null = probes disabled (test-friendly overload).
|
||||
/// </summary>
|
||||
internal sealed class IndoorProbeState
|
||||
{
|
||||
private readonly Dictionary<ulong, int> _lastFrame;
|
||||
private readonly int _currentFrame;
|
||||
private const int RateLimit = IndoorProbeRateLimitFrames;
|
||||
|
||||
internal IndoorProbeState(Dictionary<ulong, int> lastFrame, int currentFrame)
|
||||
{
|
||||
_lastFrame = lastFrame;
|
||||
_currentFrame = currentFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
|
||||
/// frames per <paramref name="cellId"/>. Side-effect: stamps the frame
|
||||
/// number into the dictionary on success.
|
||||
/// </summary>
|
||||
internal bool ShouldEmit(ulong cellId)
|
||||
{
|
||||
if (!_lastFrame.TryGetValue(cellId, out int last)
|
||||
|| _currentFrame - last >= RateLimit)
|
||||
{
|
||||
_lastFrame[cellId] = _currentFrame;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InstanceGroup
|
||||
{
|
||||
// Nonzero only while this exact object is registered in _groups.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ internal interface IRenderFrameGlState
|
|||
{
|
||||
void RestoreFrameDefaults();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6j: the small graphics surface the two world pass executors
|
||||
/// touch directly, expressed once so their ordering logic — which is retail's,
|
||||
|
|
@ -186,25 +185,3 @@ internal sealed class NullRenderFrameGlState : IRenderFrameGlState
|
|||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6j: the GL state reader on a backend with no GL state.
|
||||
///
|
||||
/// <para><c>WorldRenderDiagnostics</c> reads live GL state for explicitly enabled
|
||||
/// probes only. Every snapshot below is the truthful answer for a Vulkan frame —
|
||||
/// there is no ambient capability state to sample — which keeps every other
|
||||
/// diagnostic the class emits (render signature, PView input, out-stage routing,
|
||||
/// phantom objects) working unchanged on both backends.</para>
|
||||
/// </summary>
|
||||
internal sealed class NullRenderGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public static NullRenderGlStateReader Instance { get; } = new();
|
||||
|
||||
private NullRenderGlStateReader()
|
||||
{
|
||||
}
|
||||
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,22 @@
|
|||
using System.Numerics;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal readonly record struct IntRenderRectangle(int X, int Y, int Width, int Height);
|
||||
|
||||
internal readonly record struct RenderGlStateSnapshot(
|
||||
bool DepthTest,
|
||||
bool DepthWrite,
|
||||
int DepthFunction,
|
||||
bool Blend,
|
||||
int BlendSource,
|
||||
int BlendDestination,
|
||||
bool CullFace,
|
||||
int CullMode,
|
||||
int FrontFace,
|
||||
bool Scissor,
|
||||
IntRenderRectangle ScissorBox,
|
||||
IntRenderRectangle Viewport,
|
||||
int DrawFramebuffer,
|
||||
bool AlphaToCoverage,
|
||||
bool Stencil,
|
||||
int ClipBits,
|
||||
int Error);
|
||||
|
||||
internal readonly record struct RenderGlScissorSnapshot(
|
||||
bool Enabled,
|
||||
IntRenderRectangle Box);
|
||||
|
||||
internal readonly record struct TerrainRenderDiagnosticFacts(
|
||||
int VisibleSlots,
|
||||
int Draws,
|
||||
int LoadedSlots,
|
||||
int CapacitySlots);
|
||||
|
||||
internal interface IRenderGlStateReader
|
||||
{
|
||||
RenderGlStateSnapshot CaptureState();
|
||||
|
||||
RenderGlScissorSnapshot CaptureScissor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns print-on-change world-render probes and their reusable scratch. Inputs
|
||||
/// are borrowed for one call; the owner retains only copied signatures and IDs.
|
||||
/// </summary>
|
||||
/// <summary>Owns permanent terrain timing and diagnostic publication.</summary>
|
||||
internal sealed class WorldRenderDiagnostics
|
||||
{
|
||||
private readonly IRenderGlStateReader _gl;
|
||||
private readonly IRenderFrameDiagnosticLog _log;
|
||||
private readonly Stopwatch _terrainStopwatch = new();
|
||||
private readonly RollingTimingSampleWindow _terrainSamples = new(256);
|
||||
private string? _lastRenderSignature;
|
||||
private int _renderSignatureFrame;
|
||||
private int _renderSignatureStableFrames;
|
||||
private string? _lastGlStateSignature;
|
||||
private long _glStateFrame;
|
||||
private long _glStateStableFrames;
|
||||
private string? _lastPostWorldGlStateSignature;
|
||||
private long _postWorldGlStateFrame;
|
||||
private long _postWorldGlStateStableFrames;
|
||||
private string? _lastScissorSignature;
|
||||
private long _scissorSequence;
|
||||
private string? _lastClipRouteSignature;
|
||||
private long _clipRouteSequence;
|
||||
private readonly List<uint> _clipRouteCellKeys = [];
|
||||
|
||||
public WorldRenderDiagnostics(
|
||||
IRenderGlStateReader gl,
|
||||
IRenderFrameDiagnosticLog log)
|
||||
public WorldRenderDiagnostics(IRenderFrameDiagnosticLog log)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
}
|
||||
|
||||
|
|
@ -86,12 +29,6 @@ internal sealed class WorldRenderDiagnostics
|
|||
(long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
|
||||
}
|
||||
|
||||
/// <summary>S3 chunk 3 fix round 1 (F3): the walk path's per-frame
|
||||
/// analogue of <see cref="EndTerrainDraw"/> — pushes ONE precomputed
|
||||
/// elapsed-time sample (the sum of every land-cell batch's own
|
||||
/// Stopwatch.GetTimestamp() delta this frame, converted by the caller)
|
||||
/// instead of stopping this owner's own stopwatch, since the walk path
|
||||
/// times many small batches per frame rather than one bracketed call.</summary>
|
||||
public void PushTerrainSample(long elapsedHundredthsMicroseconds) =>
|
||||
_terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds);
|
||||
|
||||
|
|
@ -107,442 +44,4 @@ internal sealed class WorldRenderDiagnostics
|
|||
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
|
||||
+ $"capacity={facts.CapacitySlots}");
|
||||
}
|
||||
|
||||
public void EmitGlStateTripwireIfChanged(bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
_glStateFrame++;
|
||||
string signature = FormatGlState(_gl.CaptureState());
|
||||
if (signature == _lastGlStateSignature)
|
||||
{
|
||||
_glStateStableFrames++;
|
||||
return;
|
||||
}
|
||||
|
||||
_log.WriteLine(
|
||||
$"[gl-state] frame={_glStateFrame} stable={_glStateStableFrames} {signature}");
|
||||
_lastGlStateSignature = signature;
|
||||
_glStateStableFrames = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second sample of the same snapshot, taken at the END of the normal-world
|
||||
/// phase instead of at the frame clear.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="EmitGlStateTripwireIfChanged"/> samples immediately after the
|
||||
/// clear phase has run <c>RestoreFrameDefaults</c>, so it can only observe
|
||||
/// state that survives from one frame into the next. State that a world pass
|
||||
/// establishes and something in private presentation puts back before the
|
||||
/// next clear is invisible to it — including the draw framebuffer, which no
|
||||
/// frame-global restore touches. Sampling here as well brackets the world
|
||||
/// phase, so a binding that the world's geometry drew into but the retained
|
||||
/// UI did not shows up as a difference between the two lines rather than as
|
||||
/// no line at all.
|
||||
/// </remarks>
|
||||
public void EmitPostWorldGlStateIfChanged(bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
_postWorldGlStateFrame++;
|
||||
string signature = FormatGlState(_gl.CaptureState());
|
||||
if (signature == _lastPostWorldGlStateSignature)
|
||||
{
|
||||
_postWorldGlStateStableFrames++;
|
||||
return;
|
||||
}
|
||||
|
||||
_log.WriteLine(
|
||||
$"[gl-state-postworld] frame={_postWorldGlStateFrame} "
|
||||
+ $"stable={_postWorldGlStateStableFrames} {signature}");
|
||||
_lastPostWorldGlStateSignature = signature;
|
||||
_postWorldGlStateStableFrames = 0;
|
||||
}
|
||||
|
||||
public void EmitClipRouteScissorProbe(
|
||||
bool enabled,
|
||||
bool applied,
|
||||
Vector4 ndcAabb)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
RenderGlScissorSnapshot snapshot = _gl.CaptureScissor();
|
||||
string signature = FormattableString.Invariant(
|
||||
$"applied={(applied ? 1 : 0)} scis={(snapshot.Enabled ? 1 : 0)} box=({snapshot.Box.X},{snapshot.Box.Y},{snapshot.Box.Width},{snapshot.Box.Height}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})");
|
||||
_scissorSequence++;
|
||||
if (signature == _lastScissorSignature)
|
||||
return;
|
||||
|
||||
_lastScissorSignature = signature;
|
||||
_log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}");
|
||||
}
|
||||
|
||||
public void EmitClipRouteProbe(
|
||||
bool enabled,
|
||||
ClipFrame clipFrame,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
ClipViewSlice slice,
|
||||
int sliceIndex)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
var text = new StringBuilder(256);
|
||||
text.Append(FormattableString.Invariant(
|
||||
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
|
||||
text.Append(FormattableString.Invariant(
|
||||
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
|
||||
text.Append(FormattableString.Invariant($" planes={slice.Planes.Length}["));
|
||||
for (int i = 0; i < slice.Planes.Length; i++)
|
||||
{
|
||||
Vector4 plane = slice.Planes[i];
|
||||
if (i > 0)
|
||||
text.Append(' ');
|
||||
text.Append(FormattableString.Invariant(
|
||||
$"({plane.X:F3},{plane.Y:F3},{plane.Z:F3},{plane.W:F3})"));
|
||||
}
|
||||
|
||||
text.Append("] cells={");
|
||||
_clipRouteCellKeys.Clear();
|
||||
foreach (uint key in clipAssembly.CellIdToSlot.Keys)
|
||||
_clipRouteCellKeys.Add(key);
|
||||
_clipRouteCellKeys.Sort();
|
||||
for (int i = 0; i < _clipRouteCellKeys.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
text.Append(',');
|
||||
text.Append(FormattableString.Invariant(
|
||||
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
|
||||
}
|
||||
text.Append('}');
|
||||
|
||||
ReadOnlySpan<byte> regionBytes = clipFrame.RegionBytesForTest;
|
||||
int offset = slice.Slot * ClipFrame.CellClipStrideBytes;
|
||||
if (offset >= 0 && offset + ClipFrame.CellClipStrideBytes <= regionBytes.Length)
|
||||
{
|
||||
uint count = BitConverter.ToUInt32(regionBytes.Slice(offset, 4));
|
||||
text.Append(FormattableString.Invariant($" ssbo[{slice.Slot}]: n={count}"));
|
||||
int planeCount = (int)Math.Min(count, (uint)ClipFrame.MaxPlanes);
|
||||
for (int i = 0; i < planeCount; i++)
|
||||
{
|
||||
int planeOffset = offset + ClipFrame.CellClipPlanesOffset + i * 16;
|
||||
float x = BitConverter.ToSingle(regionBytes.Slice(planeOffset, 4));
|
||||
float y = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 4, 4));
|
||||
float z = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 8, 4));
|
||||
float w = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 12, 4));
|
||||
text.Append(FormattableString.Invariant($" ({x:F3},{y:F3},{z:F3},{w:F3})"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
text.Append(FormattableString.Invariant(
|
||||
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}"));
|
||||
}
|
||||
|
||||
// S3 chunk 4 fix round 2 (L3): the "ubo: n=... p0=..." segment this
|
||||
// used to append read ClipFrame's own screen-space clip gate bytes,
|
||||
// deleted along with that gate — no shader declares the block any
|
||||
// more, so there is nothing left here to read.
|
||||
|
||||
string signature = text.ToString();
|
||||
_clipRouteSequence++;
|
||||
if (signature == _lastClipRouteSignature)
|
||||
return;
|
||||
_lastClipRouteSignature = signature;
|
||||
_log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}");
|
||||
}
|
||||
|
||||
public void EmitSeamMask(
|
||||
bool enabled,
|
||||
IReadOnlySet<uint> targetCells,
|
||||
uint cellId,
|
||||
int portalIndex,
|
||||
bool forceFarZ,
|
||||
ReadOnlySpan<Vector3> vertices)
|
||||
{
|
||||
if (!enabled || !targetCells.Contains(cellId))
|
||||
return;
|
||||
|
||||
float minimumZ = float.MaxValue;
|
||||
float maximumZ = float.MinValue;
|
||||
foreach (Vector3 vertex in vertices)
|
||||
{
|
||||
minimumZ = Math.Min(minimumZ, vertex.Z);
|
||||
maximumZ = Math.Max(maximumZ, vertex.Z);
|
||||
}
|
||||
|
||||
_log.WriteLine(FormattableString.Invariant(
|
||||
$"[seam-mask] t={Environment.TickCount64} cell=0x{cellId:X8} portal={portalIndex} far={forceFarZ} n={vertices.Length} z=[{minimumZ:F3},{maximumZ:F3}]"));
|
||||
}
|
||||
|
||||
public void EmitPViewInput(
|
||||
bool enabled,
|
||||
IReadOnlySet<uint> visibleCells,
|
||||
int outsideViewCount,
|
||||
Matrix4x4 viewProjection,
|
||||
bool outdoorRoot,
|
||||
Vector3 eye,
|
||||
Vector3 player,
|
||||
Vector3 rawPlayer,
|
||||
float yaw,
|
||||
float? terrainHeight)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
string terrain = terrainHeight is { } height
|
||||
? FormattableString.Invariant(
|
||||
$"terrZ={height:F3} eyeAbove={eye.Z - height:F3}")
|
||||
: "terrZ=n/a eyeAbove=n/a";
|
||||
char root = outdoorRoot ? 'Y' : 'n';
|
||||
Matrix4x4 vp = viewProjection;
|
||||
_log.WriteLine(FormattableString.Invariant(
|
||||
$"[pv-input] outRoot={root} visible={visibleCells.Count} outsideViews={outsideViewCount} eye=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) player=({player.X:F6},{player.Y:F6},{player.Z:F6}) rawPlayer=({rawPlayer.X:F6},{rawPlayer.Y:F6},{rawPlayer.Z:F6}) yaw={yaw:F8} {terrain} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]"));
|
||||
}
|
||||
|
||||
public void EmitRetailPViewDiagnostics(
|
||||
bool visibilityEnabled,
|
||||
bool flapEnabled,
|
||||
RetailPViewFrameResult result,
|
||||
LoadedCell clipRoot,
|
||||
uint viewerCellId,
|
||||
uint playerCellId,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition,
|
||||
CameraCellResolution cameraCellResolution)
|
||||
{
|
||||
if (visibilityEnabled)
|
||||
{
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
|
||||
clipRoot.CellId,
|
||||
result.VisibleCells.OrderBy(static id => id).ToArray(),
|
||||
result.ClipAssembly.OutsideViewSlices.Length,
|
||||
result.ClipAssembly.OutsidePlaneCount,
|
||||
result.ClipAssembly.PerCellPlaneCounts,
|
||||
result.ClipAssembly.ScissorFallbacks);
|
||||
}
|
||||
|
||||
if (flapEnabled)
|
||||
{
|
||||
bool eyeInRoot = CellVisibility.PointInCell(cameraPosition, clipRoot);
|
||||
bool playerInRoot = CellVisibility.PointInCell(playerPosition, clipRoot);
|
||||
_log.WriteLine(
|
||||
$"[flap-cam] root=0x{clipRoot.CellId:X8} "
|
||||
+ $"viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} "
|
||||
+ $"res={cameraCellResolution} "
|
||||
+ $"eyeInRoot={(eyeInRoot ? "Y" : "n")} "
|
||||
+ $"playerInRoot={(playerInRoot ? "Y" : "n")} "
|
||||
+ $"eye=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}) "
|
||||
+ $"player=({playerPosition.X:F2},{playerPosition.Y:F2},{playerPosition.Z:F2}) "
|
||||
// S3 chunk 4 fix round 2 (L2): the "terrain=" field read
|
||||
// ClipFrameAssembly.TerrainMode, deleted along with the
|
||||
// walk's own terrain-clip-mode tracking (the walk draws
|
||||
// terrain unclipped, one call, matching retail — there is no
|
||||
// longer a mode to report here).
|
||||
+ $"outVisible={result.ClipAssembly.OutdoorVisible}");
|
||||
}
|
||||
}
|
||||
|
||||
public void EmitRenderSignatureIfChanged(
|
||||
bool enabled,
|
||||
string branch,
|
||||
LoadedCell? clipRoot,
|
||||
LoadedCell? viewerRoot,
|
||||
LoadedCell? playerRoot,
|
||||
uint viewerCellId,
|
||||
uint playerCellId,
|
||||
bool playerIndoorGate,
|
||||
bool cameraInsideCell,
|
||||
bool renderSkyGate,
|
||||
bool drawSkyThisFrame,
|
||||
bool terrainDrawn,
|
||||
TerrainClipMode terrainClipMode,
|
||||
bool skyDrawn,
|
||||
bool depthClear,
|
||||
bool outdoorSceneryDrawn,
|
||||
int liveDynamicDrawnCount,
|
||||
string sceneParticles,
|
||||
IReadOnlySet<uint>? visibleCells,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
IReadOnlySet<uint>? drawableCells,
|
||||
InteriorEntityPartition.Result? partition,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition)
|
||||
{
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
_renderSignatureFrame++;
|
||||
bool eyeInRoot = clipRoot is not null
|
||||
&& CellVisibility.PointInCell(cameraPosition, clipRoot);
|
||||
bool playerInRoot = clipRoot is not null
|
||||
&& CellVisibility.PointInCell(playerPosition, clipRoot);
|
||||
|
||||
var text = new StringBuilder(512);
|
||||
text.Append("branch=").Append(branch);
|
||||
text.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8"));
|
||||
text.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8"));
|
||||
text.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8"));
|
||||
text.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8"));
|
||||
text.Append(" playerCell=0x").Append(playerCellId.ToString("X8"));
|
||||
text.Append(" gate=").Append(playerIndoorGate ? "in" : "out");
|
||||
text.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n');
|
||||
text.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n');
|
||||
text.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n');
|
||||
text.Append(" eye=").Append(FormatVector(cameraPosition));
|
||||
text.Append(" player=").Append(FormatVector(playerPosition));
|
||||
text.Append(" terrain=").Append(terrainClipMode);
|
||||
text.Append('/').Append(terrainDrawn ? "draw" : "skip");
|
||||
text.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n');
|
||||
text.Append(" sky=").Append(skyDrawn ? 'Y' : 'n');
|
||||
text.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n');
|
||||
text.Append(" zclear=").Append(depthClear ? 'Y' : 'n');
|
||||
text.Append(" sceneParticles=").Append(sceneParticles);
|
||||
// S3 chunk 4 fix round 2 (L2): "outMode=" read
|
||||
// ClipFrameAssembly.TerrainMode, deleted with the walk's own
|
||||
// terrain-clip-mode tracking.
|
||||
if (clipAssembly is not null)
|
||||
{
|
||||
text.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length);
|
||||
text.Append(" outPolys=").Append(clipAssembly.OutsideViewSlices.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
text.Append(" outSlices=0 outPolys=0");
|
||||
}
|
||||
|
||||
text.Append(" ids=").Append(FormatIds(visibleCells, false));
|
||||
text.Append(" draw=").Append(FormatIds(drawableCells, false));
|
||||
text.Append(" miss=").Append(FormatMissingDrawableCells(visibleCells, drawableCells));
|
||||
text.Append(" obj=").Append(FormatPartitionCounts(partition));
|
||||
text.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n');
|
||||
text.Append(" liveDynDraw=").Append(liveDynamicDrawnCount);
|
||||
text.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n');
|
||||
if (partition is not null)
|
||||
{
|
||||
int totalShells = 0;
|
||||
int shellsWithMeshes = 0;
|
||||
foreach (var entity in partition.OutdoorStatic)
|
||||
{
|
||||
if (!entity.IsBuildingShell)
|
||||
continue;
|
||||
totalShells++;
|
||||
if (entity.MeshRefs.Count > 0)
|
||||
shellsWithMeshes++;
|
||||
}
|
||||
text.Append(" bshell=").Append(totalShells).Append('/').Append(shellsWithMeshes);
|
||||
}
|
||||
|
||||
string signature = text.ToString();
|
||||
if (signature == _lastRenderSignature)
|
||||
{
|
||||
_renderSignatureStableFrames++;
|
||||
return;
|
||||
}
|
||||
|
||||
_log.WriteLine(
|
||||
$"[render-sig] frame={_renderSignatureFrame} "
|
||||
+ $"stable={_renderSignatureStableFrames} {signature}");
|
||||
_lastRenderSignature = signature;
|
||||
_renderSignatureStableFrames = 0;
|
||||
}
|
||||
|
||||
internal static string FormatGlState(RenderGlStateSnapshot state) =>
|
||||
$"depth={(state.DepthTest ? 1 : 0)} "
|
||||
+ $"dmask={(state.DepthWrite ? 1 : 0)} "
|
||||
+ $"dfunc=0x{state.DepthFunction:X} "
|
||||
+ $"blend={(state.Blend ? 1 : 0)} "
|
||||
+ $"bsrc=0x{state.BlendSource:X} bdst=0x{state.BlendDestination:X} "
|
||||
+ $"cull={(state.CullFace ? 1 : 0)} cmode=0x{state.CullMode:X} "
|
||||
+ $"fface=0x{state.FrontFace:X} "
|
||||
+ $"scis={(state.Scissor ? 1 : 0)} "
|
||||
+ $"sbox=({state.ScissorBox.X},{state.ScissorBox.Y},"
|
||||
+ $"{state.ScissorBox.Width},{state.ScissorBox.Height}) "
|
||||
+ $"vp=({state.Viewport.X},{state.Viewport.Y},"
|
||||
+ $"{state.Viewport.Width},{state.Viewport.Height}) "
|
||||
+ $"fbo={state.DrawFramebuffer} "
|
||||
+ $"a2c={(state.AlphaToCoverage ? 1 : 0)} "
|
||||
+ $"stencil={(state.Stencil ? 1 : 0)} "
|
||||
+ $"clip=0x{state.ClipBits:X2} err=0x{state.Error:X}";
|
||||
|
||||
private static string FormatVector(Vector3 value)
|
||||
{
|
||||
static float Quantize(float component) => MathF.Round(component * 20f) / 20f;
|
||||
return $"({Quantize(value.X):F2},{Quantize(value.Y):F2},{Quantize(value.Z):F2})";
|
||||
}
|
||||
|
||||
private static string FormatIds(IEnumerable<uint>? ids, bool preserveOrder)
|
||||
{
|
||||
if (ids is null)
|
||||
return "[]";
|
||||
|
||||
var values = new List<uint>(ids);
|
||||
if (!preserveOrder)
|
||||
values.Sort();
|
||||
var text = new StringBuilder(96).Append('[');
|
||||
const int MaximumIds = 12;
|
||||
for (int index = 0; index < values.Count && index < MaximumIds; index++)
|
||||
{
|
||||
if (index > 0)
|
||||
text.Append(',');
|
||||
text.Append("0x").Append(values[index].ToString("X8"));
|
||||
}
|
||||
if (values.Count > MaximumIds)
|
||||
text.Append(",...");
|
||||
return text.Append(']').ToString();
|
||||
}
|
||||
|
||||
private static string FormatMissingDrawableCells(
|
||||
IReadOnlySet<uint>? visibleCells,
|
||||
IReadOnlySet<uint>? drawableCells)
|
||||
{
|
||||
if (visibleCells is null || drawableCells is null)
|
||||
return "[]";
|
||||
|
||||
var text = new StringBuilder(96).Append('[');
|
||||
int written = 0;
|
||||
const int MaximumCells = 8;
|
||||
foreach (uint id in visibleCells.OrderBy(static id => id))
|
||||
{
|
||||
if (drawableCells.Contains(id))
|
||||
continue;
|
||||
if (written > 0)
|
||||
text.Append(',');
|
||||
text.Append("0x").Append(id.ToString("X8"));
|
||||
if (++written >= MaximumCells)
|
||||
{
|
||||
text.Append(",...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return text.Append(']').ToString();
|
||||
}
|
||||
|
||||
private static string FormatPartitionCounts(InteriorEntityPartition.Result? partition)
|
||||
{
|
||||
if (partition is null)
|
||||
return "cell=[] out=0 live=0";
|
||||
|
||||
var keys = new List<uint>(partition.ByCell.Keys);
|
||||
keys.Sort();
|
||||
var text = new StringBuilder(128).Append("cell=[");
|
||||
const int MaximumCells = 10;
|
||||
for (int index = 0; index < keys.Count && index < MaximumCells; index++)
|
||||
{
|
||||
uint id = keys[index];
|
||||
if (index > 0)
|
||||
text.Append(',');
|
||||
text.Append("0x").Append(id.ToString("X8"))
|
||||
.Append(':').Append(partition.ByCell[id].Count);
|
||||
}
|
||||
if (keys.Count > MaximumCells)
|
||||
text.Append(",...");
|
||||
return text.Append("] out=").Append(partition.OutdoorStatic.Count)
|
||||
.Append(" live=").Append(partition.Dynamics.Count)
|
||||
.ToString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -718,11 +718,6 @@ internal sealed class RuntimeWorldFrameBuildingSource : IWorldFrameBuildingSourc
|
|||
|
||||
if (viewerRoot is null)
|
||||
outdoorNode = OutdoorCellNode.Build(viewerCellId);
|
||||
if (RenderingDiagnostics.ProbeFlapEnabled)
|
||||
{
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_scratch.Count} (T2 frustum-gated per-building floods)"));
|
||||
}
|
||||
|
||||
return new WorldBuildingFrame(outdoorNode, _scratch);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,36 +13,6 @@ internal interface IWorldSceneDiagnostics
|
|||
{
|
||||
CameraCellResolution CameraCellResolution { get; }
|
||||
|
||||
void EmitPViewInput(
|
||||
IReadOnlySet<uint> visibleCells,
|
||||
int outsideViewCount,
|
||||
Matrix4x4 viewProjection,
|
||||
LoadedCell clipRoot,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition);
|
||||
|
||||
void EmitRenderSignature(
|
||||
string branch,
|
||||
LoadedCell? clipRoot,
|
||||
LoadedCell? viewerRoot,
|
||||
LoadedCell? playerRoot,
|
||||
uint viewerCellId,
|
||||
uint playerCellId,
|
||||
bool playerIndoorGate,
|
||||
bool cameraInsideCell,
|
||||
bool renderSky,
|
||||
bool drawSkyThisFrame,
|
||||
bool terrainDrawn,
|
||||
TerrainClipMode terrainClipMode,
|
||||
bool skyDrawn,
|
||||
bool depthClear,
|
||||
bool outdoorSceneryDrawn,
|
||||
int liveDynamicDrawnCount,
|
||||
string sceneParticles,
|
||||
RetailPViewFrameResult? pviewResult,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition);
|
||||
|
||||
WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||
in WorldCameraFrame camera,
|
||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
|
||||
|
|
@ -55,7 +25,6 @@ internal interface IWorldSceneDiagnostics
|
|||
/// </summary>
|
||||
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
||||
{
|
||||
private readonly WorldRenderDiagnostics _diagnostics;
|
||||
private readonly IWorldScenePViewDiagnosticSource _pview;
|
||||
private readonly IWorldSceneDebugStateSource _state;
|
||||
private readonly DebugLineRenderer? _lines;
|
||||
|
|
@ -67,7 +36,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
|||
private int _debugDrawLogCount;
|
||||
|
||||
public WorldSceneDiagnosticsController(
|
||||
WorldRenderDiagnostics diagnostics,
|
||||
IWorldScenePViewDiagnosticSource pview,
|
||||
IWorldSceneDebugStateSource state,
|
||||
DebugLineRenderer? lines,
|
||||
|
|
@ -77,7 +45,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
|||
DebugVmRenderFactsPublisher debugVm,
|
||||
bool debugVmConsumerActive)
|
||||
{
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
_lines = lines;
|
||||
|
|
@ -90,89 +57,11 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
|||
|
||||
public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution;
|
||||
|
||||
public void EmitPViewInput(
|
||||
IReadOnlySet<uint> visibleCells,
|
||||
int outsideViewCount,
|
||||
Matrix4x4 viewProjection,
|
||||
LoadedCell clipRoot,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition)
|
||||
{
|
||||
if (!RenderingDiagnostics.ProbePvInputEnabled)
|
||||
return;
|
||||
|
||||
_diagnostics.EmitPViewInput(
|
||||
enabled: true,
|
||||
visibleCells,
|
||||
outsideViewCount,
|
||||
viewProjection,
|
||||
clipRoot.IsOutdoorNode,
|
||||
cameraPosition,
|
||||
playerPosition,
|
||||
_pview.RawPlayerPositionOr(playerPosition),
|
||||
_pview.PlayerYaw,
|
||||
_pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y));
|
||||
}
|
||||
|
||||
public void EmitRenderSignature(
|
||||
string branch,
|
||||
LoadedCell? clipRoot,
|
||||
LoadedCell? viewerRoot,
|
||||
LoadedCell? playerRoot,
|
||||
uint viewerCellId,
|
||||
uint playerCellId,
|
||||
bool playerIndoorGate,
|
||||
bool cameraInsideCell,
|
||||
bool renderSky,
|
||||
bool drawSkyThisFrame,
|
||||
bool terrainDrawn,
|
||||
TerrainClipMode terrainClipMode,
|
||||
bool skyDrawn,
|
||||
bool depthClear,
|
||||
bool outdoorSceneryDrawn,
|
||||
int liveDynamicDrawnCount,
|
||||
string sceneParticles,
|
||||
RetailPViewFrameResult? pviewResult,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition)
|
||||
{
|
||||
_diagnostics.EmitRenderSignatureIfChanged(
|
||||
RenderingDiagnostics.ProbeFlapEnabled,
|
||||
branch,
|
||||
clipRoot,
|
||||
viewerRoot,
|
||||
playerRoot,
|
||||
viewerCellId,
|
||||
playerCellId,
|
||||
playerIndoorGate,
|
||||
cameraInsideCell,
|
||||
renderSky,
|
||||
drawSkyThisFrame,
|
||||
terrainDrawn,
|
||||
terrainClipMode,
|
||||
skyDrawn,
|
||||
depthClear,
|
||||
outdoorSceneryDrawn,
|
||||
liveDynamicDrawnCount,
|
||||
sceneParticles,
|
||||
pviewResult?.VisibleCells,
|
||||
pviewResult?.ClipAssembly,
|
||||
pviewResult?.DrawableCells,
|
||||
pviewResult?.DiagnosticPartition,
|
||||
cameraPosition,
|
||||
playerPosition);
|
||||
}
|
||||
|
||||
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||
in WorldCameraFrame camera,
|
||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bounds);
|
||||
// Brackets the normal-world phase against the clear-phase tripwire: any
|
||||
// GL state the world's geometry drew under but the next frame's clear
|
||||
// no longer sees shows up as a difference between the two lines.
|
||||
_diagnostics.EmitPostWorldGlStateIfChanged(
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
|
||||
DrawCollisionWireframes(in camera);
|
||||
|
||||
int visible = 0;
|
||||
|
|
|
|||
|
|
@ -29,11 +29,10 @@ internal interface IWorldScenePassExecutor
|
|||
uint? playerLandblockId,
|
||||
HashSet<uint> animatedEntityIds);
|
||||
|
||||
string DrawPostWorldParticles(
|
||||
void DrawPostWorldParticles(
|
||||
LoadedCell? clipRoot,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
in WorldCameraFrame camera,
|
||||
string currentSignature);
|
||||
in WorldCameraFrame camera);
|
||||
|
||||
void DrawFlatWeather(
|
||||
in WorldCameraFrame camera,
|
||||
|
|
@ -182,14 +181,13 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
|||
visibleCellIds: null,
|
||||
animatedEntityIds: animatedEntityIds);
|
||||
|
||||
public string DrawPostWorldParticles(
|
||||
public void DrawPostWorldParticles(
|
||||
LoadedCell? clipRoot,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
in WorldCameraFrame camera,
|
||||
string currentSignature)
|
||||
in WorldCameraFrame camera)
|
||||
{
|
||||
if (_particles is null || _particleRenderer is null)
|
||||
return currentSignature;
|
||||
return;
|
||||
|
||||
if (clipRoot is null)
|
||||
{
|
||||
|
|
@ -202,21 +200,21 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
|||
_visibleParticleOwners,
|
||||
includeUnattached: true,
|
||||
excludedAttachedOwnerIds: _noExcludedParticleOwners);
|
||||
return AppendSignature(currentSignature, "filtered");
|
||||
return;
|
||||
}
|
||||
|
||||
_particleRenderer.Draw(
|
||||
camera.Camera,
|
||||
camera.Position,
|
||||
ParticleRenderPass.Scene);
|
||||
return AppendSignature(currentSignature, "global");
|
||||
return;
|
||||
}
|
||||
|
||||
// Every PView root, including the outdoor sentinel, now submits scene
|
||||
// particles inside LScape::draw. Replaying them here is both a duplicate
|
||||
// and too late: it occurs after nested building cells, allowing exterior
|
||||
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
||||
return currentSignature;
|
||||
return;
|
||||
}
|
||||
|
||||
public void DrawFlatWeather(
|
||||
|
|
@ -291,6 +289,4 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
|||
}
|
||||
}
|
||||
|
||||
private static string AppendSignature(string current, string value) =>
|
||||
current == "none" ? value : current + "+" + value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,23 +176,15 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
|||
|
||||
WorldCameraFrame camera = world.Camera;
|
||||
WorldRootFrame roots = world.Roots;
|
||||
LoadedCell? clipRoot = world.ClipRoot;
|
||||
bool renderSky = roots.RenderSky;
|
||||
bool drawSkyThisFrame = false;
|
||||
bool terrainDrawn = false;
|
||||
bool skyDrawn = false;
|
||||
bool depthClear = false;
|
||||
bool outdoorSceneryDrawn = false;
|
||||
int liveDynamicDrawnCount = 0;
|
||||
string sceneParticles = "none";
|
||||
TerrainClipMode terrainClipMode = TerrainClipMode.Planes;
|
||||
RetailPViewFrameResult? pviewResult = null;
|
||||
LoadedCell? clipRoot = world.ClipRoot;
|
||||
bool renderSky = roots.RenderSky;
|
||||
bool drawSkyThisFrame = false;
|
||||
RetailPViewFrameResult? pviewResult = null;
|
||||
|
||||
if (clipRoot is null)
|
||||
{
|
||||
_passes.PrepareFlatWorldClip();
|
||||
drawSkyThisFrame = renderSky;
|
||||
skyDrawn = drawSkyThisFrame;
|
||||
_passes.PrepareFlatWorldClip();
|
||||
drawSkyThisFrame = renderSky;
|
||||
if (drawSkyThisFrame)
|
||||
{
|
||||
_passes.DrawFlatSky(
|
||||
|
|
@ -210,12 +202,7 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
|||
// waiting frame. One gate computes the frame's visibility;
|
||||
// this phase only enforces it.
|
||||
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
||||
terrainDrawn = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
terrainClipMode = TerrainClipMode.Skip;
|
||||
}
|
||||
}
|
||||
|
||||
if (clipRoot is not null)
|
||||
{
|
||||
|
|
@ -253,30 +240,6 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
|||
// VisibleCells remains a diagnostics-only union.
|
||||
_particleVisibility.MarkVisibleLandscapeCells(
|
||||
pviewResult.VisibleLandscapeCells);
|
||||
_diagnostics.EmitPViewInput(
|
||||
pviewResult.VisibleCells,
|
||||
pviewResult.ClipAssembly.OutsideViewSlices.Length,
|
||||
camera.ViewProjection,
|
||||
clipRoot,
|
||||
camera.Position,
|
||||
roots.PlayerViewPosition);
|
||||
|
||||
bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
|
||||
terrainDrawn = hasOutsideSlice;
|
||||
skyDrawn = renderSky && hasOutsideSlice;
|
||||
// This mirrors the established diagnostic meaning, including
|
||||
// outdoor roots whose PView executor does not issue an interior
|
||||
// depth clear.
|
||||
depthClear = hasOutsideSlice;
|
||||
RenderProjectionCounts sourceCounts =
|
||||
pviewResult.SourceCounts;
|
||||
if (sourceCounts.IndoorCellStatic > 0 || hasOutsideSlice)
|
||||
sceneParticles = "pviewScoped";
|
||||
outdoorSceneryDrawn =
|
||||
sourceCounts.OutdoorStatic > 0
|
||||
&& hasOutsideSlice;
|
||||
liveDynamicDrawnCount =
|
||||
sourceCounts.LiveDynamicRoot;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -288,43 +251,20 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
|||
}
|
||||
|
||||
_passes.DisableClipDistances();
|
||||
sceneParticles = _passes.DrawPostWorldParticles(
|
||||
clipRoot,
|
||||
pviewResult?.ClipAssembly,
|
||||
in camera,
|
||||
sceneParticles);
|
||||
_passes.DrawPostWorldParticles(
|
||||
clipRoot,
|
||||
pviewResult?.ClipAssembly,
|
||||
in camera);
|
||||
|
||||
if (clipRoot is null && drawSkyThisFrame)
|
||||
{
|
||||
skyDrawn = true;
|
||||
_passes.DrawFlatWeather(
|
||||
_passes.DrawFlatWeather(
|
||||
in camera,
|
||||
in foundation,
|
||||
_sky.ActiveDayGroup,
|
||||
_sky.DayFraction);
|
||||
}
|
||||
|
||||
_diagnostics.EmitRenderSignature(
|
||||
clipRoot is null ? "OutdoorRoot" : "RetailPViewInside",
|
||||
clipRoot,
|
||||
roots.ViewerRoot,
|
||||
roots.PlayerRoot,
|
||||
roots.ViewerCellId,
|
||||
roots.PlayerCellId,
|
||||
roots.PlayerIndoorGate,
|
||||
roots.CameraInsideCell,
|
||||
renderSky,
|
||||
drawSkyThisFrame,
|
||||
terrainDrawn,
|
||||
terrainClipMode,
|
||||
skyDrawn,
|
||||
depthClear,
|
||||
outdoorSceneryDrawn,
|
||||
liveDynamicDrawnCount,
|
||||
sceneParticles,
|
||||
pviewResult,
|
||||
camera.Position,
|
||||
roots.PlayerViewPosition);
|
||||
|
||||
WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish(
|
||||
in camera,
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ public sealed class LightManager
|
|||
/// registered lights (chase-boom swing churned the eviction boundary), then
|
||||
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
||||
/// purples entered/left the pool as the camera turned — the seam-floor
|
||||
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
|
||||
/// blink). Current model: all registered
|
||||
/// (=resident) lit lights, then dynamics-first nearest-player, capped here.
|
||||
/// A later last-frame drawable-cell filter was removed after the Facility Hub
|
||||
/// zoom trace proved it recreated the same camera-root coupling: the pool
|
||||
|
|
@ -341,10 +341,6 @@ public sealed class LightManager
|
|||
}
|
||||
}
|
||||
|
||||
// A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1;
|
||||
// the flag check keeps it zero-cost off.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot);
|
||||
}
|
||||
|
||||
private static int CompareRankedLights(
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public sealed class LightSource
|
|||
// cross-cell block-offset distance math. #176 correction (2026-07-06): it is
|
||||
// NOT a pool filter — retail collects from ALL resident cells
|
||||
// (CEnvCell::visible_cell_table = the loaded-cell registry, not the flood);
|
||||
// acdream keeps the tag for probes ([indoor-light]/[seam-*]) + future parity.
|
||||
// acdream keeps the tag for future parity work.
|
||||
public bool IsLit = true; // SetLightHook latch
|
||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||
// false = static dat-baked bake (1/d³, range×1.3)
|
||||
|
|
|
|||
|
|
@ -1,612 +1,44 @@
|
|||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
|
||||
/// rendering pipeline. Initialized from env vars at process start;
|
||||
/// flippable at runtime by direct assignment. Log call sites read these
|
||||
/// statics so a change takes effect on the next frame without relaunching.
|
||||
/// (#434: these used to have a DebugPanel checkbox mirror. That panel has
|
||||
/// been unreachable since Campaign V slice V11 removed its ImGui host, so
|
||||
/// every flag here is startup-or-assignment only.)
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
|
||||
/// pattern. The master <see cref="IndoorAll"/> toggle is the user's
|
||||
/// common case — flipping it cascades to all five probe flags.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>Permanent rendering diagnostics and retail render-route helpers.</summary>
|
||||
public static class RenderingDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
||||
/// <c>[indoor-walk]</c> line per visible cell entity per second:
|
||||
/// entity id, world position, parent cell id, landblock visible flag,
|
||||
/// AABB-visible flag, "in visible cells" flag, drew flag.
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_WALK=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorWalkEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-lookup]</c>
|
||||
/// line per visible cell entity per second: render-data hit/miss,
|
||||
/// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_LOOKUP=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorLookupEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbMeshAdapter</c> emits two lines per EnvCell id:
|
||||
/// <c>[indoor-upload] requested</c> on first IncrementRefCount and
|
||||
/// <c>[indoor-upload] completed</c> when WB's staged drain produces
|
||||
/// its <c>ObjectMeshData</c>. Missing "completed" lines indicate WB
|
||||
/// silently returned null (hypothesis H1).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_UPLOAD=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorUploadEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-xform]</c>
|
||||
/// line per visible cell entity per second: cell-geometry SetupPart's
|
||||
/// composed world matrix translation. Disambiguates transform
|
||||
/// double-apply (hypothesis H5).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_XFORM=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorXformEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
||||
/// <c>[indoor-cull]</c> line per cell entity that gets culled, with
|
||||
/// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
|
||||
/// cull bugs (hypothesis H3).
|
||||
/// Initial state from <c>ACDREAM_PROBE_INDOOR_CULL=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorCullEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// When true, the unified portal-visibility pass emits one <c>[vis]</c>
|
||||
/// line whenever the camera's root cell CHANGES (see <see cref="EmitVis"/>):
|
||||
/// root cell id, visible-cell count + ids, the single OutsideView's polygon
|
||||
/// + plane counts, a per-cell plane-count summary, and the scissor-fallback
|
||||
/// count for the frame. This is the runtime apparatus #103 lacked — it lets
|
||||
/// us confirm "OutsideView non-empty and narrowing at the cellar window" off
|
||||
/// a live launch.log before any GL/visual work.
|
||||
/// Initial state from <c>ACDREAM_PROBE_VIS=1</c>.
|
||||
/// <para>
|
||||
/// Phase U.2d (2026-05-30) repurposed this flag from the abandoned A8
|
||||
/// two-pipe stencil pass to the Phase U unified pipeline. The env var name
|
||||
/// is unchanged (its DebugPanel mirror is gone — #434).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool ProbeVisibilityEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Temporary Facility Hub staircase discriminator. The frame walk emits
|
||||
/// camera/root/flood facts and the leaf classifier emits change-only
|
||||
/// decisions for the authored stair GfxObj (0x010000DE) and local-player
|
||||
/// setup parts in cells 0x8A02015E/015F/01C1. Output-only; it must never
|
||||
/// influence admission. Initial state from
|
||||
/// <c>ACDREAM_PROBE_FACILITY_STAIRS=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeFacilityStairsEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_FACILITY_STAIRS") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal
|
||||
/// visibility pass emits, EVERY frame the camera root is an indoor cell, a
|
||||
/// <c>[flap]</c> line (root cell's per-portal side-test D + traverse/cull +
|
||||
/// projection, plus the frame's OutsideView/visible counts) and the call site
|
||||
/// emits a paired <c>[flap-cam]</c> line (FindCameraCell resolution reason,
|
||||
/// camera EYE worldpos, player worldpos, eye-in-root-AABB flag). Unlike the
|
||||
/// cell-change-throttled <see cref="ProbeVisibilityEnabled"/> probe, this fires
|
||||
/// per-frame so it captures the flicker (the exit cell dropping in/out at a
|
||||
/// STABLE root). Pinpoints WHY the exit cell drops: side-test cull (eye past an
|
||||
/// interior portal plane), empty projection, or a stale root (eye outside the
|
||||
/// cell while FindCameraCell still reports it via cache/grace). Throwaway
|
||||
/// apparatus — strip once the flap mechanism is confirmed.
|
||||
/// Initial state from <c>ACDREAM_PROBE_FLAP=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeFlapEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_FLAP") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE
|
||||
/// <c>[pv-input]</c> line per frame with the EXACT PortalVisibilityBuilder.Build inputs at HIGH
|
||||
/// precision (camera eye + player position to 6 dp, plus orientation-sensitive view-projection
|
||||
/// elements) alongside the resulting flood cell count. The live flap shows the flood set flipping
|
||||
/// 2↔6 at an eye/player that is identical to cm; this probe answers whether the Build INPUTS differ
|
||||
/// below cm precision (sub-cm view jitter → robustness fix) or are byte-identical while the output
|
||||
/// still flips (nondeterminism → surgical bug). Runs WITHOUT the heavy <c>[flap]</c>/<c>[render-sig]</c>
|
||||
/// spam so the log stays diffable. Throwaway apparatus — strip once the jitter source is pinned.
|
||||
/// Initial state from <c>ACDREAM_PROBE_PVINPUT=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbePvInputEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PVINPUT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// §4 outdoor full-world flap apparatus (2026-06-09). When true, GameWindow snapshots the
|
||||
/// GL fixed-function state entering the world passes each frame (depth test/mask/func, blend
|
||||
/// + factors, cull, front-face, scissor + box, viewport, draw-FBO, color mask, glGetError)
|
||||
/// and emits one <c>[gl-state]</c> line whenever the snapshot CHANGES. Pins or refutes the
|
||||
/// "leaked GL state" family for the flap (every CPU-side input — matrix, flood, clip planes,
|
||||
/// scissor box, membership, eye-vs-terrain — is already probe-exonerated). Throwaway
|
||||
/// apparatus — strip once §4 ships. Initial state from <c>ACDREAM_PROBE_GLSTATE=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeGlStateEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_GLSTATE") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// §4 outdoor full-world flap apparatus (2026-06-10) — the decisive probe between the two
|
||||
/// surviving suspects (handoff 2026-06-09 §1): (a) per-instance clip-slot routing under
|
||||
/// outdoor roots, (b) terrain/sky UBO content at draw time — plus the landscape-pass scissor
|
||||
/// box as a third ground truth. When true: RetailPViewRenderer.DrawLandscapeThroughOutsideView
|
||||
/// emits one <c>[clip-route]</c> line (print-on-change) with the outside slice's slot + NDC
|
||||
/// AABB + planes, the CellIdToSlot routing table, the region-SSBO bytes decoded at the routed
|
||||
/// slot, and the terrain-UBO head as uploaded. The <c>[clip-route-disp]</c> producer in
|
||||
/// WbDrawDispatcher.Draw was deleted with the per-instance clip routing at S3 review fix
|
||||
/// round 1 (the routing had no caller), and the <c>[clip-route-scis]</c> scissor line's
|
||||
/// landscape-slice producer went with the doorway scissor at S3 chunk 4; only the
|
||||
/// WorldRenderDiagnostics lines remain. Throwaway apparatus — the S5 cleanup inventory owns
|
||||
/// its deletion. Initial state from <c>ACDREAM_PROBE_CLIPROUTE=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeClipRouteEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CLIPROUTE") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits
|
||||
/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues,
|
||||
/// max pop count, and — per re-enqueue — the reciprocal-clip pre→post region count + grew flag. Pins
|
||||
/// whether the flap's churn is redundant reciprocal back-contributions producing non-empty drifted
|
||||
/// slivers (the hypothesis) vs another source. Throwaway apparatus — strip once the bound ships.
|
||||
/// Initial state from ACDREAM_PROBE_PORTAL_CHURN=1.
|
||||
/// </summary>
|
||||
public static bool ProbePortalChurnEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the
|
||||
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
||||
/// lived in set MEMBERSHIP, not counts). When true,
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||||
/// <c>[indoor-light]</c> line describing the point-light pool
|
||||
/// (see <see cref="EmitIndoorLight"/>):
|
||||
/// <code>
|
||||
/// [indoor-light] pool=<M> cellLess=<K> registered=<R> capped=<R-M>
|
||||
/// byCell=[0x<id>:<count>,...]
|
||||
/// </code>
|
||||
/// #176 correction (2026-07-06): the pool became retail's RESIDENT-cell
|
||||
/// collection capped nearest-the-PLAYER — the earlier gaze-coupled scoping
|
||||
/// (rebuilding the pool from a freshly re-flooded CAMERA-seeded set,
|
||||
/// <c>c500912b</c>) was the #176 flicker mechanism and was deleted.
|
||||
/// A7.L1 later added last-frame drawable-cell scoping, but the Facility Hub
|
||||
/// zoom trace proved that it also couples pool membership to the camera root
|
||||
/// (five lights became one while the player stood still), so it was removed.
|
||||
/// <c>byCell</c> now describes the resident pool; <c>cellLess==pool</c> in a
|
||||
/// fixture-rich room still
|
||||
/// means cell tagging FAILED (ParentCellId not flowing).
|
||||
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorLightEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// #176 seam-floor flicker decisive probe (2026-07-06). RenderDoc pixel-history
|
||||
/// is infeasible on this pipeline (RenderDoc does not support
|
||||
/// GL_ARB_bindless_texture and hides it from the app → our mandatory-modern
|
||||
/// startup gate throws), so this is the in-engine equivalent at draw
|
||||
/// granularity: every draw route that can put geometry at the corridor seam
|
||||
/// floor reports itself, plus the per-cell light sets ACTUALLY applied.
|
||||
/// When set, four line families emit (all change-deduped, Console):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>[seam-cell]</c> — <c>EnvCellRenderer.Render</c>
|
||||
/// (opaque pass): per target cell — in-filter flag, per-gfx instance count +
|
||||
/// transform translation (shows the +0.02 shell lift), per-batch
|
||||
/// cull/translucency, and the cell's 8-light set RESOLVED to identities
|
||||
/// (owner cell + intensity — raw snapshot indices shuffle when the pool
|
||||
/// rebuilds, so identities are the stable signature). Two instances of one
|
||||
/// (cell,gfx) = the runtime double-draw; light identities flipping with the
|
||||
/// flood = the snapshot-scope mechanism.</description></item>
|
||||
/// <item><description><c>[seam-snap]</c> — the point-light snapshot's HOT
|
||||
/// subset (intensity ≥ 50: the portal purples; fixtures are ~1–2, the viewer
|
||||
/// fill 2.25) with owner cells, emitted with the block.</description></item>
|
||||
/// <item><description><c>[seam-ent]</c> — <c>WbDrawDispatcher</c>: any entity
|
||||
/// parented to a target cell — position, culled/slot, resolved light set. A
|
||||
/// floor-coincident entity (plate/static) would be the z-fight's second draw;
|
||||
/// the player entity doubles as the probe's positive control.</description></item>
|
||||
/// <item><description><c>[seam-mask]</c> — <c>RetailPViewPassExecutor.DrawPortalDepthWrite</c>:
|
||||
/// every portal depth fan drawn in a target cell. A sealed dungeon must show
|
||||
/// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding.</description></item>
|
||||
/// </list>
|
||||
/// Value <c>1</c> = the default Facility Hub target set (corridor 0x8A020164 +
|
||||
/// seam neighbors 0165/016E/017A + under-hall 011E + portal-light cells
|
||||
/// 0118/0119); a comma-separated hex cell-id list overrides it. Throwaway
|
||||
/// apparatus — strip when #176 closes. Initial state from
|
||||
/// <c>ACDREAM_PROBE_SEAMDRAW</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeSeamDrawEnabled { get; set; } =
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
|
||||
|
||||
/// <summary>Target cell ids for the #176 seam-draw probe (see
|
||||
/// <see cref="ProbeSeamDrawEnabled"/>). Full 32-bit cell ids.</summary>
|
||||
public static IReadOnlySet<uint> SeamDrawTargetCells { get; } =
|
||||
ParseSeamDrawTargets(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
|
||||
|
||||
/// <summary>
|
||||
/// #176 stripe-hunt shader isolation mode (<c>ACDREAM_LIGHT_DEBUG</c>) —
|
||||
/// throwaway diagnostic, uploaded as <c>uLightDebug</c> by EnvCellRenderer +
|
||||
/// WbDrawDispatcher each pass. 0 = off; 1 = ambient-only vertex lighting
|
||||
/// (all point/sun contributions killed); 2 = DYNAMIC point lights killed
|
||||
/// (the intensity-100 portal purples + the viewer fill off; statics stay);
|
||||
/// 3 = raw vLit visualization in the fragment shader (texture ignored).
|
||||
/// Discriminates lighting-driven stripes (gone at 1/2, visible in the field
|
||||
/// at 3) from texture/per-pixel machinery (survive 1). Settable at
|
||||
/// runtime by direct assignment.
|
||||
/// #176 stripe-hunt shader isolation mode. Zero disables the override;
|
||||
/// values 1-3 retain the established lighting diagnostic modes.
|
||||
/// </summary>
|
||||
public static int LightDebugMode { get; set; } =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"),
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int ldm)
|
||||
? ldm
|
||||
out int mode)
|
||||
? mode
|
||||
: 0;
|
||||
|
||||
// S3 review fix round 1 (F5): ClipDebugNoShellTrim (ACDREAM_CLIP_DEBUG)
|
||||
// is deleted — the #176 stripe-hunt isolation it toggled between "clip
|
||||
// shells to their cell's portal-slice region" and "map every instance to
|
||||
// slot 0" is moot now that EnvCellRenderer's per-cell clip routing is
|
||||
// deleted outright (its arming method never had a live caller): shells
|
||||
// always draw WHOLE, unconditionally, the same behavior this flag used
|
||||
// to force.
|
||||
|
||||
/// <summary>Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176
|
||||
/// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving
|
||||
/// grammar as <see cref="ParseDumpEntityIds"/>). Internal for unit tests.</summary>
|
||||
internal static IReadOnlySet<uint> ParseSeamDrawTargets(string? raw)
|
||||
{
|
||||
// Default: the corridor + the coplanar-sweep seed neighbors + the under-hall
|
||||
// + the two portal-weenie cells whose intensity-100 purple lights are the
|
||||
// wedge's source (handoff 2026-07-06-176-seam-floor-zfight-handoff.md).
|
||||
var defaults = new HashSet<uint>
|
||||
{
|
||||
0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au,
|
||||
0x8A02011Eu, 0x8A020118u, 0x8A020119u,
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(raw)) return defaults;
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed == "1" || trimmed.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
return defaults;
|
||||
var parsed = ParseDumpEntityIds(raw);
|
||||
return parsed.Count > 0 ? parsed : defaults;
|
||||
}
|
||||
|
||||
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
||||
// so launch.log stays readable under motion (the per-frame call is a no-op
|
||||
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
||||
// root id always differs and fires. Reset between tests via
|
||||
// ResetVisibilityProbeForTests so the gate doesn't leak across cases.
|
||||
private static uint _lastVisRootCellId;
|
||||
|
||||
/// <summary>
|
||||
/// Emit ONE concise, information-dense <c>[vis]</c> line for the portal-
|
||||
/// visibility frame, but only when <see cref="ProbeVisibilityEnabled"/> is
|
||||
/// true AND <paramref name="rootCellId"/> differs from the last root the
|
||||
/// probe reported (cell-change gating). Cheap no-op otherwise.
|
||||
/// <para>
|
||||
/// Decoupled by design: the OutsideView is passed as pre-computed
|
||||
/// <paramref name="outsidePolyCount"/> + <paramref name="outsidePlaneCount"/>
|
||||
/// primitives rather than the App-layer <c>CellView</c>/<c>ClipPlaneSet</c>
|
||||
/// types, because this owner lives in <c>AcDream.Core</c> and Core must not
|
||||
/// reference the App project (Code Structure Rule 2). The U.4a call site
|
||||
/// supplies <c>OutsideView.Polygons.Count</c> and the OutsideView's
|
||||
/// <c>ClipPlaneSet.Count</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="rootCellId">The camera's root cell id (the BFS seed).</param>
|
||||
/// <param name="visibleCells">Ordered visible cell ids for this frame.</param>
|
||||
/// <param name="outsidePolyCount">Polygon count of the single OutsideView region.</param>
|
||||
/// <param name="outsidePlaneCount">Clip-plane count the OutsideView reduced to (0 ⇒ scissor/empty).</param>
|
||||
/// <param name="perCellPlaneCounts">Per-cell clip-plane count (cell id → plane count).</param>
|
||||
/// <param name="scissorFallbacks">Number of regions that fell back to a scissor AABB this frame.</param>
|
||||
public static void EmitVis(uint rootCellId,
|
||||
IReadOnlyList<uint> visibleCells,
|
||||
int outsidePolyCount,
|
||||
int outsidePlaneCount,
|
||||
IReadOnlyDictionary<uint, int> perCellPlaneCounts,
|
||||
int scissorFallbacks)
|
||||
{
|
||||
if (!ProbeVisibilityEnabled) return;
|
||||
if (rootCellId == _lastVisRootCellId) return; // unchanged root ⇒ suppress
|
||||
_lastVisRootCellId = rootCellId;
|
||||
|
||||
int cellN = visibleCells?.Count ?? 0;
|
||||
|
||||
var sb = new StringBuilder(160);
|
||||
sb.Append("[vis] root=0x").Append(rootCellId.ToString("X8"));
|
||||
sb.Append(" cells=").Append(cellN);
|
||||
|
||||
// Visible cell id list, capped so a wide BFS doesn't blow up the line.
|
||||
sb.Append(" ids=[");
|
||||
if (visibleCells is not null)
|
||||
{
|
||||
const int MaxIds = 12;
|
||||
int shown = 0;
|
||||
foreach (uint id in visibleCells)
|
||||
{
|
||||
if (shown >= MaxIds) { sb.Append(",..."); break; }
|
||||
if (shown > 0) sb.Append(',');
|
||||
sb.Append("0x").Append(id.ToString("X8"));
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
sb.Append(" outside(polys=").Append(outsidePolyCount)
|
||||
.Append(",planes=").Append(outsidePlaneCount).Append(')');
|
||||
|
||||
// Per-cell plane-count summary, capped like the id list.
|
||||
sb.Append(" percell=[");
|
||||
if (perCellPlaneCounts is not null)
|
||||
{
|
||||
const int MaxPerCell = 12;
|
||||
int shown = 0;
|
||||
foreach (var kv in perCellPlaneCounts)
|
||||
{
|
||||
if (shown >= MaxPerCell) { sb.Append(",..."); break; }
|
||||
if (shown > 0) sb.Append(',');
|
||||
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
sb.Append(" fallbacks=").Append(scissorFallbacks);
|
||||
|
||||
Console.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the <see cref="EmitVis"/> cell-change gate. Test-only — this is a
|
||||
/// process-wide static and the gate would otherwise leak across test cases
|
||||
/// (this codebase has documented static-leak flakiness; keep tests
|
||||
/// self-contained). Not part of the public runtime surface.
|
||||
/// </summary>
|
||||
internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0;
|
||||
|
||||
private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks
|
||||
|
||||
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
|
||||
private static long _lastIndoorLightEmitTicks;
|
||||
|
||||
/// <summary>
|
||||
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
||||
/// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show.
|
||||
/// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise
|
||||
/// fires at most once per second. Called from
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> after resident collection and
|
||||
/// the bounded player-nearest selection.
|
||||
/// </summary>
|
||||
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||||
/// <param name="pointSnapshot">The point-light pool just built.</param>
|
||||
public static void EmitIndoorLight(
|
||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> pointSnapshot)
|
||||
{
|
||||
if (!ProbeIndoorLightEnabled) return;
|
||||
|
||||
long now = DateTime.UtcNow.Ticks;
|
||||
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
|
||||
return;
|
||||
_lastIndoorLightEmitTicks = now;
|
||||
|
||||
int registeredLitPoints = 0;
|
||||
foreach (var l in allRegistered)
|
||||
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
|
||||
|
||||
int pool = pointSnapshot.Count;
|
||||
int cellLess = 0;
|
||||
var hist = new Dictionary<uint, int>();
|
||||
foreach (var l in pointSnapshot)
|
||||
{
|
||||
if (l.CellId == 0) cellLess++;
|
||||
hist.TryGetValue(l.CellId, out var c);
|
||||
hist[l.CellId] = c + 1;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(220);
|
||||
sb.Append("[indoor-light] pool=").Append(pool);
|
||||
sb.Append(" cellLess=").Append(cellLess);
|
||||
sb.Append(" registered=").Append(registeredLitPoints);
|
||||
// Lights dropped by the MaxGlobalLights nearest-player cap (0 in Hub-scale
|
||||
// rooms for dynamics — statics beyond the 128th-nearest are out of range).
|
||||
sb.Append(" capped=").Append(registeredLitPoints - pool);
|
||||
sb.Append(" byCell=[");
|
||||
const int MaxCells = 12;
|
||||
int shown = 0;
|
||||
foreach (var kv in hist)
|
||||
{
|
||||
if (shown >= MaxCells) { sb.Append(",..."); break; }
|
||||
if (shown > 0) sb.Append(',');
|
||||
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
|
||||
shown++;
|
||||
}
|
||||
sb.Append(']');
|
||||
Console.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
private static bool _probeEnvCellEnabled =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Phase A8 Task 9 (2026-05-28): when true, the indoor EnvCell draw path's
|
||||
/// <c>[envcells]</c> probe emits one line per indoor frame —
|
||||
/// CellsRendered / TrianglesDrawn from <c>EnvCellRenderer.Stats</c> +
|
||||
/// ourBldgs/otherBldgs/filterCnt.
|
||||
/// Also enabled implicitly when <see cref="ProbeVisibilityEnabled"/> is true.
|
||||
/// Initial state from <c>ACDREAM_PROBE_ENVCELL=1</c>.
|
||||
/// (The two-pipe <c>RenderInsideOutAcdream</c> pass that originally owned
|
||||
/// this probe was removed in Phase U.1; the env var + the
|
||||
/// <c>EnvCellRenderer.Stats</c> source remain.)
|
||||
/// </summary>
|
||||
public static bool ProbeEnvCellEnabled
|
||||
{
|
||||
get => _probeEnvCellEnabled || ProbeVisibilityEnabled;
|
||||
set => _probeEnvCellEnabled = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Master toggle. Reading reflects the AND of all five flags
|
||||
/// (true only when every probe is on). Writing cascades — setting
|
||||
/// to <see langword="true"/> turns ALL five flags on; setting to
|
||||
/// <see langword="false"/> turns ALL five off.
|
||||
/// </summary>
|
||||
public static bool IndoorAll
|
||||
{
|
||||
get => ProbeIndoorWalkEnabled
|
||||
&& ProbeIndoorLookupEnabled
|
||||
&& ProbeIndoorUploadEnabled
|
||||
&& ProbeIndoorXformEnabled
|
||||
&& ProbeIndoorCullEnabled;
|
||||
set
|
||||
{
|
||||
ProbeIndoorWalkEnabled = value;
|
||||
ProbeIndoorLookupEnabled = value;
|
||||
ProbeIndoorUploadEnabled = value;
|
||||
ProbeIndoorXformEnabled = value;
|
||||
ProbeIndoorCullEnabled = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for probe call sites. Returns <see langword="true"/> when
|
||||
/// the low 16 bits of <paramref name="id"/> are ≥ 0x0100 — the AC
|
||||
/// convention for EnvCell (indoor) cells, as opposed to outdoor cells
|
||||
/// in the 8×8 landblock grid (0x0001–0x0040).
|
||||
/// </summary>
|
||||
/// <summary>Returns true for AC indoor EnvCell ids.</summary>
|
||||
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
|
||||
|
||||
/// <summary>
|
||||
/// Parse the <c>ACDREAM_DUMP_ENTITY</c> value: comma-separated hex ids,
|
||||
/// optional 0x prefix, whitespace tolerated, malformed segments ignored
|
||||
/// (probes are forgiving — a typo'd segment must not take the launch down).
|
||||
/// Internal for unit tests.
|
||||
/// </summary>
|
||||
internal static IReadOnlySet<uint> ParseDumpEntityIds(string? raw)
|
||||
{
|
||||
var set = new HashSet<uint>();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return set;
|
||||
foreach (var seg in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var s = seg.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? seg[2..] : seg;
|
||||
if (uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var id))
|
||||
set.Add(id);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top-level render branch: should this frame run the indoor (DrawInside) path?
|
||||
///
|
||||
/// <para>Retail <c>SmartBox::RenderNormalMode</c> (0x453aa0, pc:92665) branches
|
||||
/// DrawInside vs the outdoor <c>LScape::draw</c> on <c>is_player_outside</c> — the
|
||||
/// <b>PLAYER's</b> cell (<c>(player->m_position.objcell_id & 0xFFFF) < 0x100</c>,
|
||||
/// <c>SmartBox::is_player_outside</c> 0x451e80) — NOT the camera/viewer cell. When the
|
||||
/// player is inside, acdream roots the portal flood at the player's transition-owned
|
||||
/// physics cell and projects from the camera eye, so the shell around the player remains
|
||||
/// sealed during chase-camera cell transitions.</para>
|
||||
///
|
||||
/// <para>acdream historically branched on the camera cell (a non-null
|
||||
/// <c>visibility.CameraCell</c>). A 3rd-person chase camera lags the player, so when the
|
||||
/// player had already stepped outside but the camera still sat in the doorway, the camera
|
||||
/// branch wrongly chose DrawInside rooted at the doorway cell, where the exit-portal flood
|
||||
/// degenerates → the whole static world (terrain + shells) gated off → grey screen with
|
||||
/// only entities (which bypass the gate) showing through. Branching on the player removes it.</para>
|
||||
///
|
||||
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
|
||||
/// <param name="renderRootResolved">Whether the player's indoor render root is loaded and
|
||||
/// available to DrawInside.</param>
|
||||
/// Retail chooses the indoor path from the player's cell, provided the
|
||||
/// transition-owned render root is available.
|
||||
/// </summary>
|
||||
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
||||
=> renderRootResolved && IsEnvCellId(playerCellId);
|
||||
|
||||
/// <summary>
|
||||
/// MP0 (2026-07-05) — master toggle for the permanent frame profiler
|
||||
/// (<c>AcDream.App.Diagnostics.FrameProfiler</c>): CPU frame time
|
||||
/// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
|
||||
/// per-frame allocation counters, reported as one <c>[frame-prof]</c>
|
||||
/// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
|
||||
/// do NOT strip with session probes. This paragraph previously claimed
|
||||
/// the whole-frame GPU query self-disables under <c>ACDREAM_WB_DIAG=1</c>;
|
||||
/// Campaign V slice V11 deleted that self-disable along with the GL query
|
||||
/// ring it protected, and the two flags are now independent — see
|
||||
/// <c>FrameProfiler</c>'s own class doc. Every backend reports GPU time
|
||||
/// through <c>FrameProfiler.RecordGpuSample</c>.
|
||||
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
|
||||
/// by direct assignment (its DebugPanel mirror is gone — #434).
|
||||
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
|
||||
/// </summary>
|
||||
/// <summary>Permanent frame-profiler toggle.</summary>
|
||||
public static bool FrameProfEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// 2026-07-24 measurement-tooling review — opt-in per-frame history
|
||||
/// export path for <c>AcDream.App.Diagnostics.FrameProfiler</c>. When
|
||||
/// set, the profiler accumulates one record per frame (frame index,
|
||||
/// wall timestamp, per-stage CPU microseconds, GPU microseconds,
|
||||
/// frame-thread allocation delta) in memory and writes them as CSV to
|
||||
/// this path on <c>Dispose</c> — the aggregated 5-second
|
||||
/// <c>[frame-prof]</c> report is unaffected. History recording only
|
||||
/// takes effect while <see cref="FrameProfEnabled"/> is also true: it
|
||||
/// reuses the same per-frame instrumentation rather than duplicating
|
||||
/// it. Startup-only (not runtime-toggleable) — read once, like every
|
||||
/// other <c>ACDREAM_*</c> launch flag on this owner.
|
||||
/// </summary>
|
||||
/// <summary>Optional per-frame CSV history path for the frame profiler.</summary>
|
||||
public static string? FrameHistoryPath { get; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): print-only walk-transcript
|
||||
/// emitter. When true, the production frame walk
|
||||
/// (<c>RetailFrameWalk</c> + <c>WalkFrameDriver</c>,
|
||||
/// <c>src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs</c>) prints
|
||||
/// the OH oracle-trace line kinds — F/P/LS/LC/SC/BLD/DI/DC/EC/OC — to
|
||||
/// Console at the exact points retail's cdb breakpoints sit
|
||||
/// (<c>tools/walk-oracle/oh/oh-capture-walk.cdb.template</c>). Print-
|
||||
/// only: it never gates admission, depth state, or draw order — every
|
||||
/// print call sits AFTER the walk has already decided to emit the
|
||||
/// corresponding turn, and every print call bails out before any string
|
||||
/// work when this is false.
|
||||
/// <para>
|
||||
/// This is the "owned by RenderingDiagnostics (rule 5)" half of §11.2
|
||||
/// B1's design; the sole authoritative read of
|
||||
/// <c>ACDREAM_DUMP_WALK_TRANSCRIPT</c> lives in
|
||||
/// <c>RuntimeOptions.DumpWalkTranscript</c> (rule 4) — this property
|
||||
/// defaults false and is set exactly once, at <c>GameWindow</c>
|
||||
/// construction, from that typed option (never reads the environment
|
||||
/// directly itself, unlike this file's other flags), so the deep walk
|
||||
/// call sites that have no reachable <c>RuntimeOptions</c> reference
|
||||
/// still get one static, flippable-at-runtime gate to check.
|
||||
/// </para>
|
||||
/// Print-only production walk transcript gate. RuntimeOptions owns the
|
||||
/// sole environment read and assigns this property during construction.
|
||||
/// </summary>
|
||||
public static bool DumpWalkTranscriptEnabled { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1969,7 +1969,7 @@ public sealed class PlayerMovementController
|
|||
// PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC
|
||||
// jump-looping near the cottage doorway clobbered the render root every tick → the render
|
||||
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
|
||||
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
|
||||
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 from a camera capture).
|
||||
if (publishRenderRoot)
|
||||
_physics.UpdatePlayerCurrCell(newCellId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,11 +325,6 @@ public sealed class HostInputCameraCompositionTests
|
|||
GpuFrameFlightController? frameFlights,
|
||||
IGpuDevice device) => frameFlights!;
|
||||
|
||||
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
|
||||
GameWindowGraphics graphics,
|
||||
IRenderFrameDiagnosticLog log) =>
|
||||
new(new GlStateReader(), log);
|
||||
|
||||
public SilkKeyboardSource CreateKeyboardSource(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
|
|
@ -498,12 +493,6 @@ public sealed class HostInputCameraCompositionTests
|
|||
public void Delete(nint fence) { }
|
||||
}
|
||||
|
||||
private sealed class GlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class DiagnosticLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public void WriteLine(string message) { }
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ namespace AcDream.App.Tests.Rendering;
|
|||
/// each class's <c>finally</c> sufficient.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Three distinct globals are covered, and every one of them is a shared
|
||||
/// <para>The shared globals below are covered, and every one of them is an
|
||||
/// edge between at least two classes in this collection:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>AcDream.Core.Rendering.CameraDiagnostics</c> —
|
||||
|
|
@ -28,15 +28,12 @@ namespace AcDream.App.Tests.Rendering;
|
|||
/// <c>RetailChaseCamera.Update</c>, <c>CameraController.Active</c>,
|
||||
/// <c>CameraFrameController</c>, <c>WorldRenderFrameBuilder</c> and
|
||||
/// <c>MouseLookController</c>.</description></item>
|
||||
/// <item><description><c>AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled</c>
|
||||
/// — written by <c>CornerFloodReplayTests</c> and
|
||||
/// <c>Issue181WallPressEquilibriumTests</c>.</description></item>
|
||||
/// <item><description><c>AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled</c>
|
||||
/// (Campaign OVERHAUL S3 chunk 1) — written by
|
||||
/// <c>WalkFrameDriverTests</c>' transcript-emitter tests
|
||||
/// (<c>WalkFrameDriverTranscriptTests.cs</c>).</description></item>
|
||||
/// <item><description><c>System.Console.Out</c> — redirected via
|
||||
/// <c>Console.SetOut</c> by those same classes to capture probe output.
|
||||
/// <c>Console.SetOut</c> by the transcript tests to capture output.
|
||||
/// Interleaved redirection can restore a DISPOSED <c>StringWriter</c> as the
|
||||
/// process-wide <c>Console.Out</c>, which then throws in unrelated
|
||||
/// tests.</description></item>
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ public sealed class ChargenPreviewControllerTests
|
|||
/// </summary>
|
||||
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dats unavailable");
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -87,7 +87,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
ChargenPreviewEntityBuilder.PreviewBackdropRenderId,
|
||||
ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId);
|
||||
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -121,7 +121,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuild_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -145,7 +145,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -186,7 +186,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -232,7 +232,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildAnimated_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -274,7 +274,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key.
|
||||
public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -297,7 +297,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -337,7 +337,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_AluvianHeritage_ResolvesANonEmptyMesh()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -376,7 +376,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_ExplicitRenderId_StampsThatIdOnTheEntity()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -405,7 +405,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_UnsetEnvironmentSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -419,7 +419,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -440,7 +440,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_AllThirteenHeritages_ResolveOrAreReportedByName()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
|
@ -7,30 +6,12 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public class ClipFrameAssemblerTests
|
||||
{
|
||||
private static ViewPolygon Square(float cx, float cy, float half) => new(new[]
|
||||
{
|
||||
new Vector2(cx - half, cy - half),
|
||||
new Vector2(cx + half, cy - half),
|
||||
new Vector2(cx + half, cy + half),
|
||||
new Vector2(cx - half, cy + half),
|
||||
});
|
||||
|
||||
private static CellView ViewOf(params ViewPolygon[] polys)
|
||||
{
|
||||
var view = new CellView();
|
||||
foreach (var p in polys)
|
||||
view.Add(p);
|
||||
return view;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginWalkFrame_OutdoorRoot_SeedsOneFullScreenDefaultView()
|
||||
{
|
||||
using ClipFrame frame = ClipFrame.NoClip();
|
||||
|
||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
||||
frame,
|
||||
outdoorRoot: true);
|
||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true);
|
||||
|
||||
ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices);
|
||||
Assert.Equal(0, slice.Slot);
|
||||
|
|
@ -45,9 +26,7 @@ public class ClipFrameAssemblerTests
|
|||
public void BeginWalkFrame_InteriorRoot_ResetsReusedOutdoorAssemblyToEmpty()
|
||||
{
|
||||
using ClipFrame frame = ClipFrame.NoClip();
|
||||
ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(
|
||||
frame,
|
||||
outdoorRoot: true);
|
||||
ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true);
|
||||
|
||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
||||
frame,
|
||||
|
|
@ -62,270 +41,4 @@ public class ClipFrameAssemblerTests
|
|||
Assert.Equal(0, assembly.ScissorFallbacks);
|
||||
Assert.Equal(1, frame.SlotCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
const uint cellB = 0xA9B40101;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(-0.3f, 0f, 0.3f));
|
||||
pv.CellViews[cellB] = ViewOf(Square(0.3f, 0f, 0.2f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OrderedVisibleCells.Add(cellB);
|
||||
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice
|
||||
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||
Assert.Contains(cellB, asm.CellIdToSlot.Keys);
|
||||
Assert.NotEqual(0, asm.CellIdToSlot[cellA]);
|
||||
Assert.NotEqual(0, asm.CellIdToSlot[cellB]);
|
||||
Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellB]);
|
||||
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot);
|
||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
const uint cellB = 0xA9B40101;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.CellViews[cellB] = new CellView();
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OrderedVisibleCells.Add(cellB);
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.Equal(2, asm.Frame.SlotCount);
|
||||
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||
Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys);
|
||||
Assert.False(asm.OutdoorVisible);
|
||||
Assert.Empty(asm.OutsideViewSlices);
|
||||
Assert.Equal(0, asm.OutsidePlaneCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutsideViewMultiPolygon_PreservesRetailSlices()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f));
|
||||
pv.OutsideView.Add(Square(0.5f, 0f, 0.1f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||
Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
|
||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellMultiPolygonView_PreservesRetailViewSlices()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.1f),
|
||||
Square(0.4f, 0f, 0.1f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.CellIdToSlot[cellA] > 0);
|
||||
Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length);
|
||||
Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length);
|
||||
Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
|
||||
{
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
|
||||
var expected = new Vector4(
|
||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb()
|
||||
{
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f));
|
||||
pv.OutsideView.Add(Square(0.6f, 0f, 0.15f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||
|
||||
var expected = new Vector4(
|
||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.False(asm.HasOutsideView);
|
||||
Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies()
|
||||
{
|
||||
var frame = ClipFrame.NoClip();
|
||||
|
||||
var pv1 = new PortalVisibilityFrame();
|
||||
pv1.CellViews[0xA9B40100] = ViewOf(Square(-0.3f, 0f, 0.2f));
|
||||
pv1.CellViews[0xA9B40101] = ViewOf(Square(0.3f, 0f, 0.2f));
|
||||
pv1.OrderedVisibleCells.Add(0xA9B40100);
|
||||
pv1.OrderedVisibleCells.Add(0xA9B40101);
|
||||
pv1.OutsideView.Add(Square(0f, 0.4f, 0.2f));
|
||||
var asm1 = ClipFrameAssembler.Assemble(frame, pv1);
|
||||
Assert.Equal(4, asm1.Frame.SlotCount);
|
||||
|
||||
var pv2 = new PortalVisibilityFrame();
|
||||
pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f));
|
||||
pv2.OrderedVisibleCells.Add(0xA9B40200);
|
||||
var asm2 = ClipFrameAssembler.Assemble(frame, pv2);
|
||||
|
||||
Assert.Equal(2, asm2.Frame.SlotCount);
|
||||
Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys);
|
||||
Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys);
|
||||
Assert.False(asm2.OutdoorVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_ReusedAssembly_ClearsStateAndReusesExactLengthArrays()
|
||||
{
|
||||
const uint firstCell = 0xA9B40100;
|
||||
const uint secondCell = 0xA9B40200;
|
||||
var frame = ClipFrame.NoClip();
|
||||
var reuse = new ClipFrameAssembly();
|
||||
|
||||
var first = new PortalVisibilityFrame();
|
||||
first.CellViews[firstCell] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.15f),
|
||||
Square(0.4f, 0f, 0.15f));
|
||||
first.OrderedVisibleCells.Add(firstCell);
|
||||
first.OutsideView.Add(Square(0f, 0.5f, 0.2f));
|
||||
ClipFrameAssembly firstResult = ClipFrameAssembler.Assemble(frame, first, reuse);
|
||||
Assert.Same(reuse, firstResult);
|
||||
int sliceAllocations = reuse.SliceArrayAllocationCount;
|
||||
int slotAllocations = reuse.SlotArrayAllocationCount;
|
||||
|
||||
var second = new PortalVisibilityFrame();
|
||||
second.CellViews[secondCell] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.15f),
|
||||
Square(0.4f, 0f, 0.15f));
|
||||
second.OrderedVisibleCells.Add(secondCell);
|
||||
second.OutsideView.Add(Square(0f, 0.5f, 0.2f));
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
ClipFrameAssembly actual = ClipFrameAssembler.Assemble(frame, second, reuse);
|
||||
Assert.Same(reuse, actual);
|
||||
Assert.DoesNotContain(firstCell, actual.CellIdToViewSlices.Keys);
|
||||
Assert.Contains(secondCell, actual.CellIdToViewSlices.Keys);
|
||||
Assert.Equal(2, actual.CellIdToViewSlices[secondCell].Length);
|
||||
Assert.Equal(2, actual.CellIdToViewSlots[secondCell].Length);
|
||||
Assert.Single(actual.OutsideViewSlices);
|
||||
Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount);
|
||||
Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount);
|
||||
}
|
||||
|
||||
var empty = new PortalVisibilityFrame();
|
||||
ClipFrameAssembly cleared = ClipFrameAssembler.Assemble(frame, empty, reuse);
|
||||
Assert.Empty(cleared.CellIdToSlot);
|
||||
Assert.Empty(cleared.CellIdToViewSlots);
|
||||
Assert.Empty(cleared.CellIdToViewSlices);
|
||||
Assert.Empty(cleared.PerCellPlaneCounts);
|
||||
Assert.Empty(cleared.OutsideViewSlices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_ReusedAssembly_ChangingCardinalityStaysBoundedThenReachesSteadyState()
|
||||
{
|
||||
const uint cellId = 0xA9B40100;
|
||||
var frame = ClipFrame.NoClip();
|
||||
var reuse = new ClipFrameAssembly();
|
||||
|
||||
static PortalVisibilityFrame FrameWithSlices(uint id, int count)
|
||||
{
|
||||
var portalFrame = new PortalVisibilityFrame();
|
||||
var view = new CellView();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
// The assembler consumes the retail view_poly list directly. Distinct placement is
|
||||
// irrelevant to this cache-cardinality stress; avoid CellView dedup collapsing it.
|
||||
view.Polygons.Add(Square(i * 0.001f, 0f, 0.0004f));
|
||||
}
|
||||
portalFrame.CellViews[id] = view;
|
||||
portalFrame.OrderedVisibleCells.Add(id);
|
||||
return portalFrame;
|
||||
}
|
||||
|
||||
for (int count = 1; count <= 180; count++)
|
||||
ClipFrameAssembler.Assemble(frame, FrameWithSlices(cellId, count), reuse);
|
||||
|
||||
// Flush the final live arrays into the cache too.
|
||||
ClipFrameAssembler.Assemble(frame, new PortalVisibilityFrame(), reuse);
|
||||
Assert.InRange(reuse.RetainedSliceItems, 0, ClipFrameAssembly.MaxRetainedSliceItems);
|
||||
Assert.InRange(reuse.RetainedSlotItems, 0, ClipFrameAssembly.MaxRetainedSlotItems);
|
||||
Assert.InRange(reuse.RetainedSliceArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool);
|
||||
Assert.InRange(reuse.RetainedSlotArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool);
|
||||
|
||||
PortalVisibilityFrame steady = FrameWithSlices(cellId, 73);
|
||||
ClipFrameAssembler.Assemble(frame, steady, reuse);
|
||||
int sliceAllocations = reuse.SliceArrayAllocationCount;
|
||||
int slotAllocations = reuse.SlotArrayAllocationCount;
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
ClipFrameAssembler.Assemble(frame, steady, reuse);
|
||||
Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount);
|
||||
Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,685 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// §4 corner-press flood replay (2026-06-10). The §2b camera-collision hypothesis is
|
||||
/// REFUTED (CameraCornerSealReplayTests, commit b21bb28): the eye legitimately enters
|
||||
/// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees
|
||||
/// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those
|
||||
/// eye positions. This harness drives the REAL <see cref="PortalVisibilityBuilder.Build"/>
|
||||
/// over the REAL Holtburg building cells (dat-loaded, mirroring EnvCellLandblockBuildBuilder)
|
||||
/// along the captured corner eye path, and prints which cells the flood keeps/drops per
|
||||
/// step. The player's room (0172) dropping while the player is visible on screen is the
|
||||
/// background defect; the step where it happens pins the mechanism.
|
||||
///
|
||||
/// Geometry (corner-seal-capture.log + the b21bb28 room map): all cells share origin
|
||||
/// (161.93, 7.50, 94.00), local = R180z·(world−origin). The 0171-side doorway plane is
|
||||
/// local x=4.10 (world x≈157.83), the 0172-side plane local x=3.90 (world x≈158.03);
|
||||
/// the opening is 1.9 m wide, full height (z 94..96.5). Player dwell position during the
|
||||
/// corner press: (159.94, 7.70, 94.00) in 0172; captured eyes hover near (157.4..157.5,
|
||||
/// 7.91, 96.25) — inside 0171, 0.35 m past the doorway plane, near the ceiling.
|
||||
/// </summary>
|
||||
[Collection(CameraDiagnosticsCollection.Name)]
|
||||
public class CornerFloodReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public CornerFloodReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
internal const uint Landblock = 0xA9B40000u;
|
||||
private const uint EnvironmentFilePrefix = 0x0D000000u;
|
||||
|
||||
internal static string? ResolveDatDir()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return fromEnv;
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dat → LoadedCell, mirroring EnvCellLandblockBuildBuilder.
|
||||
/// field-for-field: portals (with OtherPortalId back-link), clip planes from the
|
||||
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
|
||||
/// cell-local space, local AABB, world transform from EnvCell.Position.
|
||||
/// </summary>
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
=> LoadCell(dats, cellId, Vector3.Zero);
|
||||
|
||||
/// <summary>worldOffset: block offset for multi-landblock fixtures (dat cell
|
||||
/// positions are landblock-local; a neighbour block needs ±192 per axis).</summary>
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId, Vector3 worldOffset)
|
||||
{
|
||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
||||
var environment = dats.Get<DatEnvironment>(EnvironmentFilePrefix | envCell.EnvironmentId)
|
||||
?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found");
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||
throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing");
|
||||
|
||||
var cellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(envCell.Position.Origin + worldOffset);
|
||||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
var boundsMin = new Vector3(float.MaxValue);
|
||||
var boundsMax = new Vector3(float.MinValue);
|
||||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
var v = kvp.Value;
|
||||
var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
|
||||
boundsMin = Vector3.Min(boundsMin, pos);
|
||||
boundsMax = Vector3.Max(boundsMax, pos);
|
||||
}
|
||||
if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; }
|
||||
|
||||
var portals = new List<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<Vector3[]>();
|
||||
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new CellPortalInfo(
|
||||
portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId));
|
||||
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||||
&& poly.VertexIds.Count >= 3
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||||
{
|
||||
var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||||
var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||||
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||||
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||||
float d = -Vector3.Dot(normal, p0);
|
||||
// InsideSide from the dat PortalSide bit — mirrors the production fix
|
||||
// (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics
|
||||
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
|
||||
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
|
||||
clipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
clipPlanes.Add(default);
|
||||
}
|
||||
|
||||
Vector3[] polyVerts = Array.Empty<Vector3>();
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
|
||||
&& portalPoly.VertexIds.Count >= 3)
|
||||
{
|
||||
polyVerts = new Vector3[portalPoly.VertexIds.Count];
|
||||
bool allResolved = true;
|
||||
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||||
(ushort)portalPoly.VertexIds[vi], out var pv))
|
||||
polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
|
||||
else { allResolved = false; break; }
|
||||
}
|
||||
if (!allResolved) polyVerts = Array.Empty<Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polyVerts);
|
||||
}
|
||||
|
||||
uint lbPrefix = cellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCells.Add(lbPrefix | lowId);
|
||||
|
||||
return new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldPosition = envCell.Position.Origin + worldOffset,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = boundsMin,
|
||||
LocalBoundsMax = boundsMax,
|
||||
Portals = portals,
|
||||
ClipPlanes = clipPlanes,
|
||||
PortalPolygons = portalPolygons,
|
||||
VisibleCells = visibleCells,
|
||||
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
|
||||
};
|
||||
}
|
||||
|
||||
internal static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
|
||||
{
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
for (uint low = 0x016Fu; low <= 0x0175u; low++)
|
||||
{
|
||||
uint id = Landblock | low;
|
||||
cells[id] = LoadCell(dats, id);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// Production projection: ChaseCamera/FlyCamera use FovY ~1.2, near 1, far 5000,
|
||||
// 1280x720 (the capture's viewport). The flood's clip is near-independent
|
||||
// (PortalProjection header), 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;
|
||||
}
|
||||
|
||||
// World-x of the doorway planes (local x=4.10 / 3.90 under the R180z transform
|
||||
// about origin x=161.93).
|
||||
private const float Plane0171X = 161.93f - 4.10f; // ≈157.83
|
||||
private const float Plane0172X = 161.93f - 3.90f; // ≈158.03
|
||||
|
||||
private static uint RootCellFor(float eyeX) =>
|
||||
eyeX > Plane0172X ? (Landblock | 0x0172u)
|
||||
: eyeX > Plane0171X ? (Landblock | 0x0173u)
|
||||
: (Landblock | 0x0171u);
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: full [pv-trace] decision log for one GOOD step (47) vs one GLITCH
|
||||
/// step (48) of the sweep below — 2 cm apart, root 0171 both, yet 0172 (and its
|
||||
/// downstream 016F + outside) vanish from the flood at 48. The trace diff pins the
|
||||
/// exact gate that flips.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_TraceGoodVsGlitchStep()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
foreach (int i in new[] { 47, 48 })
|
||||
{
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
uint root = RootCellFor(ex);
|
||||
|
||||
var sw = new StringWriter();
|
||||
var prev = Console.Out;
|
||||
PortalVisibilityFrame frame;
|
||||
try
|
||||
{
|
||||
Console.SetOut(sw);
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = true;
|
||||
frame = PortalVisibilityBuilder.Build(cells[root], eye, lookup, ViewProjFor(eye, pivot));
|
||||
}
|
||||
finally
|
||||
{
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = false;
|
||||
Console.SetOut(prev);
|
||||
}
|
||||
|
||||
_out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########");
|
||||
foreach (var line in sw.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
_out.WriteLine(line.TrimEnd());
|
||||
|
||||
// Per-hop region vertices: where does the sliver come from?
|
||||
foreach (uint low in new uint[] { 0x0171u, 0x0173u, 0x0172u })
|
||||
{
|
||||
if (!frame.CellViews.TryGetValue(Landblock | low, out var view))
|
||||
{
|
||||
_out.WriteLine($" view 0x{low:X4}: (absent)");
|
||||
continue;
|
||||
}
|
||||
foreach (var p in view.Polygons)
|
||||
{
|
||||
string verts = "";
|
||||
foreach (var v in p.Vertices)
|
||||
verts += System.FormattableString.Invariant($" ({v.X:F5},{v.Y:F5})");
|
||||
_out.WriteLine($" view 0x{low:X4} [{p.Vertices.Length}]:{verts}");
|
||||
}
|
||||
if (view.Polygons.Count == 0)
|
||||
_out.WriteLine($" view 0x{low:X4}: 0 polygons (rejected by Add)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §4 conformance gate (2026-06-08 handoff §7's pre-gate, realized 2026-06-10):
|
||||
/// a smooth monotonic eye moving through/near a doorway must produce a STABLE,
|
||||
/// monotone flood — the full cell chain present on every step, the outside view
|
||||
/// always reached, and the player-room region never collapsing nor oscillating.
|
||||
/// Before the homogeneous-reciprocal fix this failed on ~10 of 61 steps (room
|
||||
/// 0172 vanished entirely or collapsed to a sub-pixel sliver — the corner /
|
||||
/// transition background strobe). Guards the ApplyReciprocalClip pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void CornerSweep_FloodIsCompleteAndMonotone()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
// W=0 port (2026-06-11) — the criterion is retail's, not the rescue's:
|
||||
// - Cells BEHIND the camera are NOT required (retail polyClipFinish clips an
|
||||
// all-behind portal to empty; the rescue used to flood them anyway). The eye
|
||||
// looks AT the player throughout, so the doorway chain is behind the camera
|
||||
// until the eye recedes through it: require 0173 from root=0173 on, 0171
|
||||
// from root=0171 on. 0172 (the looked-at room with the player) is required
|
||||
// at EVERY step — that is THE user-visible §4 invariant.
|
||||
// - The two KNIFE-EDGE steps (eye exactly ON a doorway plane, the sweep grid
|
||||
// lands on the plane constants) propagate retail's zero-area degenerate view:
|
||||
// the chain stays in the draw list (cells draw whole) but the 0172 region is
|
||||
// legitimately zero-area and the onward flood (016F/outside) legitimately
|
||||
// dies for that frame — exempt those assertions there.
|
||||
// - Monotone shrink holds WITHIN a root regime; the root flip is a legitimate
|
||||
// discontinuity (FullScreen root view -> portal-clipped view).
|
||||
var failures = new List<string>();
|
||||
float prevArea = float.MaxValue;
|
||||
uint prevRoot = 0;
|
||||
for (int i = 0; i <= 60; i++)
|
||||
{
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
uint root = RootCellFor(ex);
|
||||
bool knifeEdge = MathF.Abs(ex - Plane0172X) < 0.005f || MathF.Abs(ex - Plane0171X) < 0.005f;
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[root], eye, lookup, ViewProjFor(eye, pivot));
|
||||
|
||||
// Knife-edge steps: the eye sits EXACTLY on a cell-boundary plane, so the
|
||||
// root pick itself is ambiguous (production BSP picks either side; a damped
|
||||
// float eye never lands exactly on the plane). The portal whose plane
|
||||
// contains the eye is ~perpendicular to the gaze here — genuinely
|
||||
// off-screen, retail's screen-bounded clip floods nothing through it from
|
||||
// the far-side root. Require only the root; the neighbouring steps (±2 cm,
|
||||
// where the real strobe class lived) carry the full criterion.
|
||||
var required = new List<uint>();
|
||||
if (knifeEdge)
|
||||
{
|
||||
required.Add(root & 0xFFFFu);
|
||||
}
|
||||
else
|
||||
{
|
||||
required.Add(0x0172u);
|
||||
if (root != (Landblock | 0x0172u)) required.Add(0x0173u);
|
||||
if (root == (Landblock | 0x0171u)) required.Add(0x0171u);
|
||||
required.Add(0x016Fu);
|
||||
}
|
||||
foreach (uint low in required)
|
||||
if (!frame.OrderedVisibleCells.Contains(Landblock | low))
|
||||
failures.Add($"step {i}: 0x{low:X4} missing from flood (root=0x{root & 0xFFFFu:X4}{(knifeEdge ? ", knife-edge" : "")})");
|
||||
if (!knifeEdge && frame.OutsideView.Polygons.Count == 0)
|
||||
failures.Add($"step {i}: outside view empty");
|
||||
|
||||
float area = 0f;
|
||||
if (frame.CellViews.TryGetValue(Landblock | 0x0172u, out var view))
|
||||
foreach (var p in view.Polygons)
|
||||
area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY);
|
||||
if (!knifeEdge && area < 0.5f)
|
||||
failures.Add(System.FormattableString.Invariant(
|
||||
$"step {i}: 0172 region collapsed (area={area:F3})"));
|
||||
// Monotone shrink as the eye recedes — allow float-noise upticks only.
|
||||
// Reset at root flips + knife-edge frames (legitimate discontinuities).
|
||||
if (root == prevRoot && !knifeEdge && prevArea != float.MaxValue && area > prevArea + 0.01f)
|
||||
failures.Add(System.FormattableString.Invariant(
|
||||
$"step {i}: 0172 region grew {prevArea:F3}->{area:F3} (oscillation)"));
|
||||
prevArea = knifeEdge ? float.MaxValue : area;
|
||||
prevRoot = root;
|
||||
}
|
||||
|
||||
Assert.True(failures.Count == 0,
|
||||
"Flood instability under a monotonic eye sweep (the §4 strobe):\n "
|
||||
+ string.Join("\n ", failures));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #120 repro (T5 gate, 2026-06-11): the T5 launch log fired
|
||||
/// `[pv-ERROR] in-place propagation tripwire at depth 128` on cottage
|
||||
/// interior cells 0xA9B40175/0174 (+0162, a different building) while
|
||||
/// the user walked the doorways — i.e. the eye-on-portal-plane regime.
|
||||
/// Sweep the eye ACROSS every portal plane of every cell in this
|
||||
/// building (±6 cm in 5 mm steps, looking through the opening), seeding
|
||||
/// <see cref="PortalVisibilityBuilder.Build"/> from BOTH sides' cells.
|
||||
/// The in-place growth's fixpoint invariant must hold at every step —
|
||||
/// the tripwire count stays 0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void PortalPlaneCrossings_InPlacePropagationConverges()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var firings = new List<string>();
|
||||
|
||||
foreach (var cell in cells.Values)
|
||||
{
|
||||
for (int i = 0; i < cell.Portals.Count && i < cell.PortalPolygons.Count; i++)
|
||||
{
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3) continue;
|
||||
if (i >= cell.ClipPlanes.Count) continue;
|
||||
var plane = cell.ClipPlanes[i];
|
||||
if (plane.Normal.LengthSquared() < 1e-6f) continue;
|
||||
|
||||
// Portal centroid + plane normal in WORLD space.
|
||||
var centroidLocal = Vector3.Zero;
|
||||
foreach (var v in poly) centroidLocal += v;
|
||||
centroidLocal /= poly.Length;
|
||||
var centroidWorld = Vector3.Transform(centroidLocal, cell.WorldTransform);
|
||||
var normalWorld = Vector3.Normalize(
|
||||
Vector3.TransformNormal(plane.Normal, cell.WorldTransform));
|
||||
|
||||
uint neighbourId = cell.Portals[i].OtherCellId == 0xFFFF
|
||||
? 0u
|
||||
: (Landblock | cell.Portals[i].OtherCellId);
|
||||
var roots = new List<LoadedCell> { cell };
|
||||
if (neighbourId != 0u && cells.TryGetValue(neighbourId, out var nb))
|
||||
roots.Add(nb);
|
||||
|
||||
for (int step = -12; step <= 12; step++)
|
||||
{
|
||||
var eye = centroidWorld + normalWorld * (step * 0.005f);
|
||||
// Look through the opening (along -normal), slightly down
|
||||
// — the portal fills the view, maximizing flood activity.
|
||||
var lookAt = centroidWorld - normalWorld * 2f + new Vector3(0f, 0f, -0.2f);
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, lookAt));
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
if (after != before)
|
||||
firings.Add(System.FormattableString.Invariant(
|
||||
$"cell=0x{cell.CellId:X8} portal#{i}->0x{cell.Portals[i].OtherCellId:X4} step={step} root=0x{root.CellId:X8} eye=({eye.X:F4},{eye.Y:F4},{eye.Z:F4})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(firings.Count == 0,
|
||||
"#120: in-place propagation convergence tripwire fired during the " +
|
||||
"portal-plane sweep:\n " + string.Join("\n ", firings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #120 repro attempt 2: eyes INSIDE each cell's volume (3×3 XY grid ×
|
||||
/// 2 Z levels) with full yaw (8) × pitch (3) direction sweeps — the
|
||||
/// walking-and-turning regime of the T5 session, including the steep
|
||||
/// pitches of the cellar stairs. Same invariant: the tripwire count
|
||||
/// stays 0 across every Build.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void InCellDirectionSweep_InPlacePropagationConverges()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var firings = new List<string>();
|
||||
int builds = 0;
|
||||
|
||||
foreach (var cell in cells.Values)
|
||||
{
|
||||
var lo = cell.LocalBoundsMin;
|
||||
var hi = cell.LocalBoundsMax;
|
||||
if (hi.X - lo.X < 0.05f || hi.Y - lo.Y < 0.05f) continue;
|
||||
|
||||
for (int gx = 0; gx < 3; gx++)
|
||||
for (int gy = 0; gy < 3; gy++)
|
||||
for (int gz = 0; gz < 2; gz++)
|
||||
{
|
||||
var local = new Vector3(
|
||||
lo.X + (hi.X - lo.X) * (0.2f + 0.3f * gx),
|
||||
lo.Y + (hi.Y - lo.Y) * (0.2f + 0.3f * gy),
|
||||
lo.Z + (hi.Z - lo.Z) * (gz == 0 ? 0.3f : 0.8f));
|
||||
var eye = Vector3.Transform(local, cell.WorldTransform);
|
||||
|
||||
for (int yaw = 0; yaw < 8; yaw++)
|
||||
for (int pitch = -1; pitch <= 1; pitch++)
|
||||
{
|
||||
float a = yaw * MathF.PI / 4f;
|
||||
var dir = new Vector3(
|
||||
MathF.Cos(a), MathF.Sin(a), pitch * 0.9f);
|
||||
var lookAt = eye + Vector3.Normalize(dir) * 3f;
|
||||
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
PortalVisibilityBuilder.Build(cell, eye, lookup, ViewProjFor(eye, lookAt));
|
||||
builds++;
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
if (after != before)
|
||||
firings.Add(System.FormattableString.Invariant(
|
||||
$"cell=0x{cell.CellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) yaw={yaw} pitch={pitch}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"builds={builds} firings={firings.Count}");
|
||||
Assert.True(firings.Count == 0,
|
||||
"#120: convergence tripwire fired during the in-cell direction sweep:\n "
|
||||
+ string.Join("\n ", firings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: microscope on the failing hop. Replays the two portal hops
|
||||
/// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the
|
||||
/// good step (47) and the glitch step (48), printing the homogeneous subject
|
||||
/// vertices and full-precision outputs at every stage — the concrete numbers for
|
||||
/// the collapsing intersection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_Hop2Microscope()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
var c171 = cells[Landblock | 0x0171u];
|
||||
var c173 = cells[Landblock | 0x0173u];
|
||||
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
// 0171's portal to 0173 is index 1; 0173's portal to 0172 is index 1 (room map).
|
||||
var poly171to173 = c171.PortalPolygons[1];
|
||||
var poly173to172 = c173.PortalPolygons[1];
|
||||
|
||||
foreach (int i in new[] { 47, 48 })
|
||||
{
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
var vp = ViewProjFor(eye, pivot);
|
||||
|
||||
_out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########");
|
||||
|
||||
// Hop 1: 0171 -> 0173 against the full screen.
|
||||
var subj1 = PortalProjection.ProjectToClip(poly171to173, c171.WorldTransform, vp);
|
||||
DumpClip("hop1 subject (0171->0173 portal, clip space)", subj1);
|
||||
var region173 = PortalProjection.ClipToRegion(subj1, new[]
|
||||
{
|
||||
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
||||
});
|
||||
DumpNdc("hop1 out = 0173 region", region173);
|
||||
if (region173.Length < 3) continue;
|
||||
|
||||
// Hop 2: 0173 -> 0172 against the 0173 region.
|
||||
var subj2 = PortalProjection.ProjectToClip(poly173to172, c173.WorldTransform, vp);
|
||||
DumpClip("hop2 subject (0173->0172 portal, clip space)", subj2);
|
||||
var region172 = PortalProjection.ClipToRegion(subj2, region173);
|
||||
DumpNdc("hop2 out = 0172 region", region172);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scratch: the homogeneous reciprocal primitive in isolation, on the
|
||||
/// synthetic fixture geometry that Build_AppliesReciprocalOtherPortalClip uses.</summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Scratch_ReciprocalPrimitive_SyntheticPair()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
var vp = view * proj;
|
||||
|
||||
Vector3[] Quad(float cx, float cy, float hw, float hh, float z) => new[]
|
||||
{
|
||||
new Vector3(cx - hw, cy - hh, z), new Vector3(cx + hw, cy - hh, z),
|
||||
new Vector3(cx + hw, cy + hh, z), new Vector3(cx - hw, cy + hh, z),
|
||||
};
|
||||
|
||||
var wide = Quad(0f, 0f, 0.9f, 0.9f, -3f);
|
||||
var narrow = Quad(0f, 0f, 0.3f, 0.9f, -3f);
|
||||
|
||||
// Near-side region: wide portal clipped against the full screen.
|
||||
var wideClip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp);
|
||||
DumpClip("wide subject", wideClip);
|
||||
var wideRegion = PortalProjection.ClipToRegion(wideClip, new[]
|
||||
{
|
||||
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
||||
});
|
||||
DumpNdc("wide region (near-side)", wideRegion);
|
||||
|
||||
// Reciprocal: narrow portal, homogeneous, clipped against the wide region.
|
||||
var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp);
|
||||
DumpClip("narrow subject (reciprocal)", narrowClip);
|
||||
var tightened = PortalProjection.ClipToRegion(narrowClip, wideRegion);
|
||||
DumpNdc("tightened = narrow ∩ wide", tightened);
|
||||
|
||||
// Now the FULL Build on the same synthetic pair (mirrors
|
||||
// Build_AppliesReciprocalOtherPortalClip) with output dumps.
|
||||
var a = new LoadedCell
|
||||
{
|
||||
CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo> { new CellPortalInfo(0x0002, 0, 0, 0) },
|
||||
};
|
||||
a.PortalPolygons.Add(wide);
|
||||
var b = new LoadedCell
|
||||
{
|
||||
CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo> { new CellPortalInfo(0x0001, 0, 0, 0) },
|
||||
};
|
||||
b.PortalPolygons.Add(narrow);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001u] = a, [0x0002u] = b };
|
||||
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
foreach (var kv in f.CellViews)
|
||||
foreach (var p in kv.Value.Polygons)
|
||||
{
|
||||
string s = "";
|
||||
foreach (var v in p.Vertices)
|
||||
s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})");
|
||||
_out.WriteLine($" Build view 0x{kv.Key:X4} [{p.Vertices.Length}]:{s}");
|
||||
}
|
||||
}
|
||||
|
||||
private void DumpClip(string label, Vector4[] verts)
|
||||
{
|
||||
string s = "";
|
||||
foreach (var v in verts)
|
||||
s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4},{v.Z:F4},w={v.W:F4})");
|
||||
_out.WriteLine($" {label} [{verts.Length}]:{s}");
|
||||
}
|
||||
|
||||
private void DumpNdc(string label, Vector2[] verts)
|
||||
{
|
||||
string s = "";
|
||||
foreach (var v in verts)
|
||||
s += System.FormattableString.Invariant($" ({v.X:F7},{v.Y:F7})");
|
||||
_out.WriteLine($" {label} [{verts.Length}]:{s}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic (no assertions yet): sweep the eye along the corner-press path —
|
||||
/// from inside the player's room (0172), across the doorway threshold (0173),
|
||||
/// into the neighbour room (0171) — looking back at the player throughout, and
|
||||
/// print the flood result per step. The defect signature: the PLAYER's room
|
||||
/// (0172) absent from the flood (or its view region empty) while the root is
|
||||
/// 0171/0173 — the screen area where the player stands then shows background.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_CornerPress_FloodAcrossDoorwayPlane()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The captured corner scenario: player parked in 0172, eye orbiting near the
|
||||
// doorway plane at head height (z=96.25), looking at the pivot.
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
_out.WriteLine("step | eyeX (worldX | dPlane0171) | root | flood | 0171/0173/0172 polys | outside");
|
||||
for (int i = 0; i <= 60; i++)
|
||||
{
|
||||
// Sweep world-x 158.43 (inside 0172) -> 157.23 (inside 0171), 2 cm steps,
|
||||
// crossing the 0172 plane (~158.03) and the 0171 plane (~157.83).
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
uint root = RootCellFor(ex);
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[root], eye, lookup, ViewProjFor(eye, pivot));
|
||||
|
||||
string flood = "";
|
||||
foreach (uint id in frame.OrderedVisibleCells)
|
||||
flood += $" {id & 0xFFFFu:X4}";
|
||||
|
||||
string PolyInfo(uint low)
|
||||
{
|
||||
uint id = Landblock | low;
|
||||
if (!frame.CellViews.TryGetValue(id, out var view)) return "-";
|
||||
int n = view.Polygons.Count;
|
||||
float area = 0f;
|
||||
foreach (var p in view.Polygons)
|
||||
area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY);
|
||||
return System.FormattableString.Invariant($"{n}:{area:F3}");
|
||||
}
|
||||
|
||||
string p71 = PolyInfo(0x0171u);
|
||||
string p73 = PolyInfo(0x0173u);
|
||||
string p72 = PolyInfo(0x0172u);
|
||||
_out.WriteLine(System.FormattableString.Invariant(
|
||||
$"{i,3} | {ex:F2} ({ex - Plane0171X,6:F3}) | {root & 0xFFFFu:X4} |{flood} | {p71} / {p73} / {p72} | out={frame.OutsideView.Polygons.Count}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -738,7 +738,6 @@ public sealed class EnvCellAlphaDrawSourceTests
|
|||
particleRenderer: null,
|
||||
portalDepthMask: null,
|
||||
Queue,
|
||||
(WorldRenderDiagnostics)RuntimeHelpers.GetUninitializedObject(typeof(WorldRenderDiagnostics)),
|
||||
(TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(TerrainDrawDiagnosticsController)));
|
||||
Leaf = new WalkProductionLeafRenderer(
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class IndoorDrawPlanTests
|
||||
{
|
||||
private static ViewPolygon Quad() => new(new[]
|
||||
{ new Vector2(-1, -1), new Vector2(1, -1), new Vector2(1, 1), new Vector2(-1, 1) });
|
||||
|
||||
private static PortalVisibilityFrame FrameWith(params uint[] orderedCells)
|
||||
{
|
||||
var f = new PortalVisibilityFrame();
|
||||
foreach (var id in orderedCells)
|
||||
{
|
||||
f.OrderedVisibleCells.Add(id);
|
||||
var v = new CellView(); v.Add(Quad());
|
||||
f.CellViews[id] = v;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShellPass_IncludesEveryVisibleCell_NoFilter()
|
||||
{
|
||||
// The grey bug: a cell in OrderedVisibleCells must NEVER be dropped from the
|
||||
// shell pass. (Old code dropped cells lacking a ClipFrameAssembler slot.)
|
||||
var f = FrameWith(0x01, 0x02, 0x03);
|
||||
var plan = IndoorDrawPlan.ShellPass(f);
|
||||
Assert.Equal(new uint[] { 0x03, 0x02, 0x01 }, plan.Select(e => e.CellId).ToArray()); // reverse = far→near
|
||||
Assert.All(plan, e => Assert.NotEmpty(e.Slices));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShellPass_ExcludesEmptyViewCells()
|
||||
{
|
||||
var f = FrameWith(0x01);
|
||||
f.OrderedVisibleCells.Add(0x02); // present in the list…
|
||||
f.CellViews[0x02] = new CellView(); // …but empty view → not drawable
|
||||
var plan = IndoorDrawPlan.ShellPass(f);
|
||||
Assert.Equal(new uint[] { 0x01 }, plan.Select(e => e.CellId).ToArray());
|
||||
}
|
||||
}
|
||||
17
tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs
Normal file
17
tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
internal static class InstalledDatTestPath
|
||||
{
|
||||
internal static string? Resolve()
|
||||
{
|
||||
string? configured = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
|
||||
return configured;
|
||||
|
||||
string defaultPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
return Directory.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #113 phantom exterior staircase (2026-06-10): the Holtburg meeting hall
|
||||
/// (AAB3 building[0], model 0x010014C3 at AAB3-local (36,84,116)) shows its
|
||||
/// INTERIOR stair cells painted across the west exterior wall when viewed from
|
||||
/// outside. Attribution: retail clips drawn cell geometry to the accumulated
|
||||
/// portal aperture (Render::set_view portal scissor + polyClipFinish with
|
||||
/// planeMask=0xffffffff, decomp :343750/:427922); our DrawEnvCellShells routes
|
||||
/// per-cell clip planes via ClipFrameAssembler — but a slice whose aperture
|
||||
/// polygon exceeds the 8-plane budget falls back to slot 0 ("the renderer uses
|
||||
/// scissor for passes that need that fallback", ClipFrameAssembler.cs:13-15)
|
||||
/// and the shell pass never implemented that scissor → the cell draws fully
|
||||
/// unclipped. This harness replays the production outdoor per-building flood
|
||||
/// (PortalVisibilityBuilder.ConstructViewBuilding, the R-A2 path) + the
|
||||
/// production assembler from the user's click-time viewpoint and pins which
|
||||
/// hall cells draw and with what clip.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue113MeetingHallFloodTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue113MeetingHallFloodTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint Landblock = 0xAAB30000u;
|
||||
private const uint EnvironmentFilePrefix = 0x0D000000u;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return fromEnv;
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
// Mirrors CornerFloodReplayTests.LoadCell (EnvCellLandblockBuildBuilder shape).
|
||||
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
{
|
||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
||||
var environment = dats.Get<DatEnvironment>(EnvironmentFilePrefix | envCell.EnvironmentId)
|
||||
?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found");
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||
throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing");
|
||||
|
||||
var cellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(envCell.Position.Origin);
|
||||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
var boundsMin = new Vector3(float.MaxValue);
|
||||
var boundsMax = new Vector3(float.MinValue);
|
||||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
var v = kvp.Value;
|
||||
var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
|
||||
boundsMin = Vector3.Min(boundsMin, pos);
|
||||
boundsMax = Vector3.Max(boundsMax, pos);
|
||||
}
|
||||
if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; }
|
||||
|
||||
var portals = new List<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<Vector3[]>();
|
||||
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new CellPortalInfo(
|
||||
portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId));
|
||||
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||||
&& poly.VertexIds.Count >= 3
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||||
{
|
||||
var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||||
var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||||
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||||
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||||
float d = -Vector3.Dot(normal, p0);
|
||||
// InsideSide from the dat PortalSide bit — mirrors the production fix
|
||||
// (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics
|
||||
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
|
||||
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
|
||||
clipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
clipPlanes.Add(default);
|
||||
}
|
||||
|
||||
Vector3[] polyVerts = Array.Empty<Vector3>();
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
|
||||
&& portalPoly.VertexIds.Count >= 3)
|
||||
{
|
||||
polyVerts = new Vector3[portalPoly.VertexIds.Count];
|
||||
bool allResolved = true;
|
||||
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||||
(ushort)portalPoly.VertexIds[vi], out var pv))
|
||||
polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
|
||||
else { allResolved = false; break; }
|
||||
}
|
||||
if (!allResolved) polyVerts = Array.Empty<Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polyVerts);
|
||||
}
|
||||
|
||||
uint lbPrefix = cellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCells.Add(lbPrefix | lowId);
|
||||
|
||||
return new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldPosition = envCell.Position.Origin,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = boundsMin,
|
||||
LocalBoundsMax = boundsMax,
|
||||
Portals = portals,
|
||||
ClipPlanes = clipPlanes,
|
||||
PortalPolygons = portalPolygons,
|
||||
VisibleCells = visibleCells,
|
||||
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<uint, LoadedCell> LoadHall(DatCollection dats)
|
||||
{
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
for (uint low = 0x0100u; low <= 0x0106u; low++)
|
||||
{
|
||||
uint id = Landblock | low;
|
||||
cells[id] = LoadCell(dats, id);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay the production outdoor flood + assembler from the user's
|
||||
/// click-time viewpoint (player AAB3-local (24.4, 83.4, 116), chase eye
|
||||
/// behind at eye height, looking at the hall's west door). Prints every
|
||||
/// hall cell that enters the drawn set with its slice slot / plane count /
|
||||
/// NDC AABB. The #113 defect signature: a hall cell drawn with a slice
|
||||
/// that has Slot==0 AND zero planes (the assembler's scissor-fallback
|
||||
/// contract) — DrawEnvCellShells draws that slice fully unclipped, so the
|
||||
/// interior staircase paints across the exterior wall.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WestApproach_HallCellSlices_HaveUsableClip()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadHall(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// Chase-camera sweep along the west approach: player walked from the
|
||||
// cottage (west) toward the hall; eye behind the player at z≈117.6,
|
||||
// looking at the hall's west door (local (29.4, 84.0, 117.3)).
|
||||
var lookAt = new Vector3(29.4f, 84.0f, 117.3f);
|
||||
int unclippedDrawn = 0;
|
||||
var report = new List<string>();
|
||||
|
||||
for (int i = 0; i <= 20; i++)
|
||||
{
|
||||
float ex = 14f + i * 0.5f; // eye local x 14 → 24
|
||||
var eye = new Vector3(ex, 83.4f, 117.6f);
|
||||
var vp = ViewProjFor(eye, lookAt);
|
||||
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells.Values.ToList(), eye, lookup, vp, maxSeedDistance: 48f);
|
||||
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var assembly = ClipFrameAssembler.Assemble(clipFrame, frame);
|
||||
|
||||
string flood = string.Join(" ", frame.OrderedVisibleCells.Select(c => $"{c & 0xFFFFu:X3}"));
|
||||
var lineParts = new List<string>();
|
||||
foreach (uint cellId in frame.OrderedVisibleCells)
|
||||
{
|
||||
if (!assembly.CellIdToViewSlices.TryGetValue(cellId, out var slices))
|
||||
{
|
||||
// No slices at all → DrawEnvCellShells falls to NoClipSlice → UNCLIPPED.
|
||||
lineParts.Add($"0x{cellId & 0xFFFFu:X3}:NO-SLICES(unclipped)");
|
||||
unclippedDrawn++;
|
||||
continue;
|
||||
}
|
||||
foreach (var s in slices)
|
||||
{
|
||||
bool unclipped = s.Slot == 0 && s.Planes.Length == 0;
|
||||
if (unclipped) unclippedDrawn++;
|
||||
string tag = unclipped ? "(UNCLIPPED)" : "";
|
||||
lineParts.Add(FormattableString.Invariant(
|
||||
$"0x{cellId & 0xFFFFu:X3}:slot{s.Slot}/p{s.Planes.Length}[{s.NdcAabb.X:F2},{s.NdcAabb.Y:F2}..{s.NdcAabb.Z:F2},{s.NdcAabb.W:F2}]{tag}"));
|
||||
}
|
||||
}
|
||||
report.Add(FormattableString.Invariant(
|
||||
$"eyeX={ex,5:F1} flood=[{flood}] {string.Join(" ", lineParts)}"));
|
||||
}
|
||||
|
||||
foreach (var line in report) _out.WriteLine(line);
|
||||
_out.WriteLine($"unclipped-drawn slices across sweep: {unclippedDrawn}");
|
||||
|
||||
// Documents the defect when it fires; flips to 0 once the shell pass
|
||||
// implements the assembler's scissor-fallback contract (or exact clip).
|
||||
Assert.True(unclippedDrawn == 0,
|
||||
$"#113: {unclippedDrawn} hall-cell slices would draw fully unclipped " +
|
||||
"(slot 0, no planes, no scissor) — interior stair geometry paints " +
|
||||
"across the exterior wall (the phantom staircase).");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #120 reproduction (2026-06-11): the armed tripwire self-attributed on the
|
||||
/// re-gate launch (regate-118-119-launch.log) — a pure TWO-CELL reciprocal
|
||||
/// ping-pong, 64 laps each: `chain root=0xA9B4015C eye=(109.995,37.158,96.249)
|
||||
/// cells: 0xA9B40162x64 0xA9B4015Cx64` and `root=0xA9B3010F
|
||||
/// eye=(175.771,-107.310,118.814) cells: 0xA9B30103x64 0xA9B3010Fx64`.
|
||||
///
|
||||
/// Mechanism: with the eye within PortalSideEpsilon (±1 cm — the T2-refuted-
|
||||
/// to-tighten root-lag tolerance; retail's is 0.0002) of the portal plane,
|
||||
/// the in-plane case counts as interior for BOTH cells, so views flow
|
||||
/// A→B→A…; each lap re-clips through two near-edge-on apertures whose
|
||||
/// intersection numerics wobble by more than CellView's 1e-3 dedup grid →
|
||||
/// every lap keys as a NEW polygon → 128-deep in-place recursion. The prior
|
||||
/// convergence sweeps (CornerFloodReplayTests) could not reproduce because
|
||||
/// they only load the corner building 0x016F-0x0175 — both firing pairs are
|
||||
/// OUTSIDE that set.
|
||||
///
|
||||
/// The fix class is the handoff's own prediction ("dedup admitting
|
||||
/// near-duplicates per lap"): a round-trip re-emission is, in exact math, a
|
||||
/// SUBSET of the region that originated it — CellView.Add must reject
|
||||
/// contained polygons, which makes union growth strictly area-increasing and
|
||||
/// the flood convergent regardless of clip-numerics drift.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue120ReciprocalPingPongTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue120ReciprocalPingPongTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
internal static Dictionary<uint, LoadedCell> LoadAllInteriorCells(DatCollection dats, uint landblock)
|
||||
{
|
||||
var lbi = dats.Get<DatLandBlockInfo>(landblock | 0xFFFEu);
|
||||
Assert.NotNull(lbi);
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi!.NumCells; low++)
|
||||
{
|
||||
uint id = landblock | low;
|
||||
try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id); }
|
||||
catch (InvalidOperationException) { /* sparse cell ids — skip */ }
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
public static readonly TheoryData<uint, uint, uint, float, float, float> CapturedSites = new()
|
||||
{
|
||||
// landblock, cellA (root at firing), cellB, eye x, y, z — straight from the chain dumps
|
||||
{ 0xA9B40000u, 0x015Cu, 0x0162u, 109.995f, 37.158f, 96.249f },
|
||||
{ 0xA9B30000u, 0x010Fu, 0x0103u, 175.771f, -107.310f, 118.814f },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The captured firing sites, swept over orientations (the dump has no view
|
||||
/// matrix) and over both cells as root. Invariants: the tripwire stays 0
|
||||
/// AND no CellView accumulates a pathological polygon pile (the duplicate
|
||||
/// build-up is the defect even below the depth-128 failsafe).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(CapturedSites))]
|
||||
public void CapturedPingPongSites_Converge(uint landblock, uint lowA, uint lowB, float ex, float ey, float ez)
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadAllInteriorCells(dats, landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
Assert.True(cells.ContainsKey(landblock | lowA), $"cell 0x{landblock | lowA:X8} not loaded");
|
||||
Assert.True(cells.ContainsKey(landblock | lowB), $"cell 0x{landblock | lowB:X8} not loaded");
|
||||
|
||||
var eye = new Vector3(ex, ey, ez);
|
||||
var roots = new[] { cells[landblock | lowA], cells[landblock | lowB] };
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var failures = new List<string>();
|
||||
int builds = 0, maxPolys = 0;
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
for (int yaw = 0; yaw < 8; yaw++)
|
||||
{
|
||||
float a = yaw * MathF.PI / 4f;
|
||||
foreach (float pitch in new[] { -0.4f, 0f, 0.4f })
|
||||
{
|
||||
var dir = Vector3.Normalize(new Vector3(MathF.Cos(a), MathF.Sin(a), pitch));
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, eye + dir * 3f));
|
||||
builds++;
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
|
||||
int polys = frame.CellViews.Count == 0 ? 0 : frame.CellViews.Values.Max(v => v.Polygons.Count);
|
||||
if (polys > maxPolys) maxPolys = polys;
|
||||
|
||||
if (after != before || polys > 32)
|
||||
failures.Add(FormattableString.Invariant(
|
||||
$"root=0x{root.CellId:X8} yaw={yaw} pitch={pitch} tripwire={after - before} maxCellPolys={polys}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"builds={builds} maxCellPolys={maxPolys} failures={failures.Count}");
|
||||
foreach (var f in failures) _out.WriteLine($" {f}");
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{builds} builds broke convergence at the captured #120 site (see output)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The geometric trigger, driven directly: eye swept through the
|
||||
/// both-sides-pass window (±PortalSideEpsilon = ±1 cm) of the reciprocal
|
||||
/// portal plane between the two captured cells, looking across and along
|
||||
/// the aperture. Same convergence invariants.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(CapturedSites))]
|
||||
public void PortalPlaneWindow_BothSidesPass_Converges(uint landblock, uint lowA, uint lowB, float ex, float ey, float ez)
|
||||
{
|
||||
_ = ex; _ = ey; _ = ez; // geometric variant derives its own eyes
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadAllInteriorCells(dats, landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var cellA = cells[landblock | lowA];
|
||||
uint cellBId = landblock | lowB;
|
||||
|
||||
// the portal from A to B (the ping-pong pair)
|
||||
int portalIdx = -1;
|
||||
for (int i = 0; i < cellA.Portals.Count; i++)
|
||||
if (cellA.Portals[i].OtherCellId == (ushort)lowB) { portalIdx = i; break; }
|
||||
Assert.True(portalIdx >= 0, $"no portal 0x{lowA:X4}->0x{lowB:X4}");
|
||||
var poly = cellA.PortalPolygons[portalIdx];
|
||||
Assert.True(poly is { Length: >= 3 }, "portal polygon degenerate");
|
||||
|
||||
var centroidLocal = Vector3.Zero;
|
||||
foreach (var v in poly!) centroidLocal += v;
|
||||
centroidLocal /= poly.Length;
|
||||
var centroidWorld = Vector3.Transform(centroidLocal, cellA.WorldTransform);
|
||||
var plane = cellA.ClipPlanes[portalIdx];
|
||||
var normalWorld = Vector3.Normalize(Vector3.TransformNormal(plane.Normal, cellA.WorldTransform));
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var failures = new List<string>();
|
||||
int builds = 0, maxPolys = 0;
|
||||
|
||||
foreach (float off in new[] { -0.009f, -0.004f, 0f, 0.004f, 0.009f })
|
||||
{
|
||||
var eye = centroidWorld + normalWorld * off;
|
||||
foreach (var root in new[] { cellA, cells[cellBId] })
|
||||
{
|
||||
for (int yaw = 0; yaw < 8; yaw++)
|
||||
{
|
||||
float a = yaw * MathF.PI / 4f;
|
||||
var dir = Vector3.Normalize(new Vector3(MathF.Cos(a), MathF.Sin(a), 0.1f));
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, eye + dir * 3f));
|
||||
builds++;
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
|
||||
int polys = frame.CellViews.Count == 0 ? 0 : frame.CellViews.Values.Max(v => v.Polygons.Count);
|
||||
if (polys > maxPolys) maxPolys = polys;
|
||||
|
||||
if (after != before || polys > 32)
|
||||
failures.Add(FormattableString.Invariant(
|
||||
$"off={off:F3} root=0x{root.CellId:X8} yaw={yaw} tripwire={after - before} maxCellPolys={polys}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"builds={builds} maxCellPolys={maxPolys} failures={failures.Count}");
|
||||
foreach (var f in failures) _out.WriteLine($" {f}");
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{builds} builds broke convergence in the ±ε portal-plane window (see output)");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
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>
|
||||
/// #124 — far-building interiors under an INTERIOR root. Retail seeds the
|
||||
/// look-in flood by clipping a building's aperture against the CURRENTLY
|
||||
/// INSTALLED view (PView::GetClip 0x005a4320 inside ConstructView(CBldPortal)
|
||||
/// 0x005a59a0): full screen outdoors, the accumulated doorway (outside) view
|
||||
/// when looked into from inside. These tests pin BuildFromExterior's
|
||||
/// seedRegion parameter — the port of that installed-view clip — against the
|
||||
/// real Holtburg corner-building door.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue124LookInSeedRegionTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue124LookInSeedRegionTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static (Dictionary<uint, LoadedCell> cells, LoadedCell exitCell, int exitIdx,
|
||||
Vector3 centroid, Vector3 outward) LoadFixture(DatCollection dats)
|
||||
{
|
||||
var cells = CornerFloodReplayTests.LoadBuilding(dats);
|
||||
var exitCell = cells[ExitCellId];
|
||||
|
||||
int exitIdx = -1;
|
||||
for (int i = 0; i < exitCell.Portals.Count; i++)
|
||||
{
|
||||
if (exitCell.Portals[i].OtherCellId == 0xFFFF && i < exitCell.PortalPolygons.Count
|
||||
&& exitCell.PortalPolygons[i].Length >= 3)
|
||||
{ exitIdx = i; break; }
|
||||
}
|
||||
Assert.True(exitIdx >= 0);
|
||||
|
||||
var localPoly = exitCell.PortalPolygons[exitIdx];
|
||||
Vector3 centroid = Vector3.Zero;
|
||||
foreach (var lp in localPoly)
|
||||
centroid += Vector3.Transform(lp, exitCell.WorldTransform);
|
||||
centroid /= localPoly.Length;
|
||||
|
||||
var plane = exitCell.ClipPlanes[exitIdx];
|
||||
var normal = Vector3.TransformNormal(plane.Normal, exitCell.WorldTransform);
|
||||
var cellCenter = Vector3.Transform(
|
||||
(exitCell.LocalBoundsMin + exitCell.LocalBoundsMax) * 0.5f, exitCell.WorldTransform);
|
||||
// outward = away from the cell interior.
|
||||
if (Vector3.Dot(normal, cellCenter - centroid) > 0)
|
||||
normal = -normal;
|
||||
return (cells, exitCell, exitIdx, centroid, Vector3.Normalize(normal));
|
||||
}
|
||||
|
||||
private static Vector4 ApertureNdcAabb(LoadedCell cell, int idx, Matrix4x4 viewProj)
|
||||
{
|
||||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||||
foreach (var lp in cell.PortalPolygons[idx])
|
||||
{
|
||||
var w = Vector3.Transform(lp, cell.WorldTransform);
|
||||
var c = Vector4.Transform(new Vector4(w, 1f), viewProj);
|
||||
Assert.True(c.W > 0.05f, "fixture eye must keep the aperture fully in front");
|
||||
minX = MathF.Min(minX, c.X / c.W); maxX = MathF.Max(maxX, c.X / c.W);
|
||||
minY = MathF.Min(minY, c.Y / c.W); maxY = MathF.Max(maxY, c.Y / c.W);
|
||||
}
|
||||
return new Vector4(minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
private static ViewPolygon Quad(float minX, float minY, float maxX, float maxY) =>
|
||||
new(new[]
|
||||
{
|
||||
new Vector2(minX, minY), new Vector2(maxX, minY),
|
||||
new Vector2(maxX, maxY), new Vector2(minX, maxY),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void SeedRegion_ContainingAperture_Floods_DisjointRegion_DoesNot()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var (cells, exitCell, exitIdx, centroid, outward) = LoadFixture(dats);
|
||||
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// Eye OUTSIDE the building, 3 m in front of the exit door, gaze at it
|
||||
// — the look-in geometry of a viewer peering at this building through
|
||||
// some other opening.
|
||||
var eye = centroid + outward * 3f;
|
||||
var viewProj = ViewProjFor(eye, centroid);
|
||||
var ap = ApertureNdcAabb(exitCell, exitIdx, viewProj);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"aperture ndc=({ap.X:F3},{ap.Y:F3},{ap.Z:F3},{ap.W:F3})"));
|
||||
|
||||
// Sanity: the full-screen (outdoor-root) seed floods.
|
||||
var full = PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj);
|
||||
Assert.True(full.OrderedVisibleCells.Count > 0, "full-screen seed must flood");
|
||||
|
||||
// A region containing the aperture floods — and never MORE than the
|
||||
// full-screen seed (region-restricting can only shrink the flood).
|
||||
var containing = new[] { Quad(ap.X - 0.05f, ap.Y - 0.05f, ap.Z + 0.05f, ap.W + 0.05f) };
|
||||
var seeded = PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, containing);
|
||||
Assert.True(seeded.OrderedVisibleCells.Count > 0, "containing region must flood");
|
||||
Assert.True(seeded.OrderedVisibleCells.Count <= full.OrderedVisibleCells.Count);
|
||||
|
||||
// A region strictly disjoint from the aperture must not flood — the
|
||||
// doorway doesn't show this building, so its interior never builds
|
||||
// (retail: GetClip vs the installed view returns empty → no look-in).
|
||||
Assert.True(ap.Z < 0.70f || ap.X > -0.70f, "fixture aperture unexpectedly fills the screen");
|
||||
var disjoint = ap.Z < 0.70f
|
||||
? new[] { Quad(0.75f, 0.75f, 0.99f, 0.99f) }
|
||||
: new[] { Quad(-0.99f, -0.99f, -0.75f, -0.75f) };
|
||||
var none = PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, disjoint);
|
||||
Assert.True(none.OrderedVisibleCells.Count == 0,
|
||||
FormattableString.Invariant($"disjoint region flooded {none.OrderedVisibleCells.Count} cells"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EyeOnInteriorSide_ExitDoorNeverSeeds()
|
||||
{
|
||||
// The root's own doorway must not look-in on itself: the seed eye-side
|
||||
// test (retail ConstructView's sidedness vs portal_side) excludes any
|
||||
// aperture the eye is on the interior side of — this is what lets the
|
||||
// interior-root gather pass ALL nearby buildings including the
|
||||
// viewer's own without special-casing.
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var (cells, exitCell, _, centroid, outward) = LoadFixture(dats);
|
||||
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var eye = centroid - outward * 2f; // 2 m INSIDE the doorway
|
||||
var viewProj = ViewProjFor(eye, centroid);
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { exitCell }, eye, Lookup, viewProj);
|
||||
Assert.True(frame.OrderedVisibleCells.Count == 0,
|
||||
"an interior-side eye must not seed its own cell's exit portal");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #127 — per-building flood admissions are BISTABLE per frame under the
|
||||
/// outdoor root (the building-flap mechanism: tower roof/edges flap, #123
|
||||
/// buildings vanishing when running past). Captured live with the [viewer]
|
||||
/// fwd= probe (flap-fwd-capture2.log): a ~4 cm eye wobble flips the merged
|
||||
/// flood 24↔25 with a byte-identical gaze:
|
||||
/// flood=25 eye=(129.051,-15.636,99.512) fwd=(-0.7565,0.5877,-0.2869)
|
||||
/// flood=24 eye=(129.009,-15.610,99.503) fwd=(same)
|
||||
/// flood=25 eye=(129.051,-15.642,99.514) fwd=(same)
|
||||
/// (player parked at pCell=0xA9B40019, Holtburg town, A9B4-anchored world
|
||||
/// frame — A9B4-local == world; A9B3 cells get a (0,-192,0) block offset.)
|
||||
///
|
||||
/// This harness replays the captured (eye, fwd) pairs through the
|
||||
/// production outdoor pipeline — Build(outdoorNode) + per-building
|
||||
/// ConstructViewBuilding merged (RetailPViewRenderer.MergeNearbyBuildingFloods
|
||||
/// shape) — and DIFFS the admitted cell sets: the flipping cell names the
|
||||
/// bistable admission, and the bisect along the eye segment names the knife
|
||||
/// edge.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue127FloodFlipReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue127FloodFlipReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
// The captured flip pair (A9B4-anchored world frame).
|
||||
private static readonly Vector3 EyeA = new(129.051f, -15.636f, 99.512f);
|
||||
private static readonly Vector3 EyeB = new(129.009f, -15.610f, 99.503f);
|
||||
private static readonly Vector3 Fwd = Vector3.Normalize(new Vector3(-0.7565f, 0.5877f, -0.2869f));
|
||||
|
||||
private sealed record World(
|
||||
Dictionary<uint, LoadedCell> Cells,
|
||||
List<List<LoadedCell>> BuildingGroups,
|
||||
Func<uint, LoadedCell?> Lookup);
|
||||
|
||||
private static void LoadLandblockBuildings(
|
||||
DatCollection dats, uint landblock, Vector3 offset,
|
||||
Dictionary<uint, LoadedCell> cells, List<List<LoadedCell>> groups)
|
||||
{
|
||||
var lbi = dats.Get<DatLandBlockInfo>(landblock | 0xFFFEu);
|
||||
if (lbi is null) return;
|
||||
|
||||
// all interior cells (the lookup needs every portal-reachable cell)
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||||
{
|
||||
uint id = landblock | low;
|
||||
try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id, offset); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
// per-building groups from the bldPortal stab lists (the dat's own
|
||||
// building→cells mapping; mirrors BuildingLoader's seed+walk result)
|
||||
if (lbi.Buildings is null) return;
|
||||
foreach (var bld in lbi.Buildings)
|
||||
{
|
||||
var set = new HashSet<uint>();
|
||||
if (bld.Portals is not null)
|
||||
foreach (var bp in bld.Portals)
|
||||
if (bp.StabList is not null)
|
||||
foreach (ushort low in bp.StabList)
|
||||
set.Add(landblock | low);
|
||||
var group = new List<LoadedCell>();
|
||||
foreach (uint id in set)
|
||||
if (cells.TryGetValue(id, out var c))
|
||||
group.Add(c);
|
||||
if (group.Count > 0)
|
||||
groups.Add(group);
|
||||
}
|
||||
}
|
||||
|
||||
private World? LoadWorld()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
var groups = new List<List<LoadedCell>>();
|
||||
// A9B4 = the anchor (world == local); the live gather spans the
|
||||
// streaming near window, so load a 5x5 block neighbourhood with
|
||||
// block offsets relative to the anchor.
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
uint lbX = (uint)(0xA9 + dx);
|
||||
uint lbY = (uint)(0xB4 + dy);
|
||||
uint lb = (lbX << 24) | (lbY << 16);
|
||||
LoadLandblockBuildings(dats, lb, new Vector3(dx * 192f, dy * 192f, 0f), cells, groups);
|
||||
}
|
||||
_out.WriteLine($"loaded {cells.Count} cells in {groups.Count} building groups");
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
return new World(cells, groups, lookup);
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 fwd, float fovY)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
// PortalBounds per group: the union AABB of the group's exit-portal
|
||||
// polygons in world space — exactly BuildingLoader's construction
|
||||
// (BuildingLoader.cs:114-144).
|
||||
private static (bool Has, Vector3 Min, Vector3 Max) PortalBoundsFor(List<LoadedCell> group)
|
||||
{
|
||||
bool has = false;
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
foreach (var cell in group)
|
||||
{
|
||||
int n = Math.Min(cell.Portals.Count, cell.PortalPolygons.Count);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly is null) continue;
|
||||
foreach (var v in poly)
|
||||
{
|
||||
var world = Vector3.Transform(v, cell.WorldTransform);
|
||||
has = true;
|
||||
min = Vector3.Min(min, world);
|
||||
max = Vector3.Max(max, world);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (has, min, max);
|
||||
}
|
||||
|
||||
// The production outdoor pipeline: full-screen outdoor root + per-building
|
||||
// exterior floods merged as a union (RetailPViewRenderer.DrawInside +
|
||||
// MergeNearbyBuildingFloods + MergeBuildingFrame), INCLUDING the gather's
|
||||
// per-building frustum pre-gate on PortalBounds (GameWindow:7542-7545) —
|
||||
// controlled by <paramref name="withPreGate"/> so the gate itself can be
|
||||
// implicated or exonerated.
|
||||
private static HashSet<uint> AdmittedCells(World w, Vector3 eye, Vector3 fwd, float fovY, bool withPreGate)
|
||||
{
|
||||
var viewProj = ViewProjFor(eye, fwd, fovY);
|
||||
var frustum = FrustumPlanes.FromViewProjection(viewProj);
|
||||
var outdoor = OutdoorCellNode.Build(0xA9B40019u);
|
||||
var pv = PortalVisibilityBuilder.Build(outdoor, eye, w.Lookup, viewProj);
|
||||
foreach (var group in w.BuildingGroups)
|
||||
{
|
||||
if (withPreGate)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
if (has && !FrustumCuller.IsAabbVisible(frustum, min, max))
|
||||
continue;
|
||||
}
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj);
|
||||
foreach (uint cellId in bf.OrderedVisibleCells)
|
||||
{
|
||||
if (!bf.CellViews.TryGetValue(cellId, out var srcView)) continue;
|
||||
if (pv.CellViews.TryGetValue(cellId, out var existing))
|
||||
{
|
||||
foreach (var p in srcView.Polygons) existing.Add(p);
|
||||
continue;
|
||||
}
|
||||
pv.CellViews[cellId] = srcView;
|
||||
pv.OrderedVisibleCells.Add(cellId);
|
||||
}
|
||||
}
|
||||
return new HashSet<uint>(pv.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduce + name: the captured 4 cm pair must admit the SAME cell set
|
||||
/// (a stable world does not flap a building interior for a 4 cm eye
|
||||
/// wobble at identical gaze). The diff names the bistable cell(s); the
|
||||
/// bisect prints the knife-edge location and transition count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CapturedFlipPair_AdmissionIsStable()
|
||||
{
|
||||
var w = LoadWorld();
|
||||
if (w is null) return;
|
||||
|
||||
foreach (bool preGate in new[] { false, true })
|
||||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f, 0.9f })
|
||||
{
|
||||
var a = AdmittedCells(w, EyeA, Fwd, fov, preGate);
|
||||
var b = AdmittedCells(w, EyeB, Fwd, fov, preGate);
|
||||
var onlyA = a.Except(b).OrderBy(x => x).ToList();
|
||||
var onlyB = b.Except(a).OrderBy(x => x).ToList();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"preGate={preGate} fov={fov:F2}: |A|={a.Count} |B|={b.Count} onlyA=[{string.Join(",", onlyA.Select(x => $"0x{x:X8}"))}] onlyB=[{string.Join(",", onlyB.Select(x => $"0x{x:X8}"))}]"));
|
||||
|
||||
if (onlyA.Count == 0 && onlyB.Count == 0) continue;
|
||||
|
||||
// bisect the segment: count admission transitions per flipping cell
|
||||
foreach (uint cell in onlyA.Concat(onlyB))
|
||||
{
|
||||
int transitions = 0;
|
||||
bool? prev = null;
|
||||
for (int i = 0; i <= 40; i++)
|
||||
{
|
||||
var eye = Vector3.Lerp(EyeA, EyeB, i / 40f);
|
||||
bool admitted = AdmittedCells(w, eye, Fwd, fov, preGate).Contains(cell);
|
||||
if (prev.HasValue && admitted != prev.Value) transitions++;
|
||||
prev = admitted;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" cell 0x{cell:X8}: {transitions} admission transition(s) along the 4 cm segment"));
|
||||
}
|
||||
|
||||
Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells");
|
||||
}
|
||||
}
|
||||
|
||||
// Centre of a building group's exit-portal AABB (world space).
|
||||
private static (bool Has, Vector3 Center) PortalCenterFor(List<LoadedCell> group)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
return (has, (min + max) * 0.5f);
|
||||
}
|
||||
|
||||
// Per-building admitted cells (this group only) at one (eye, gaze) — the
|
||||
// production per-building flood + optional PortalBounds frustum pre-gate.
|
||||
private static HashSet<uint> BuildingAdmits(
|
||||
World w, List<LoadedCell> group, Vector3 eye, Matrix4x4 viewProj,
|
||||
FrustumPlanes frustum, bool withPreGate)
|
||||
{
|
||||
var result = new HashSet<uint>();
|
||||
if (withPreGate)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
if (has && !FrustumCuller.IsAabbVisible(frustum, min, max))
|
||||
return result;
|
||||
}
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj);
|
||||
foreach (uint id in bf.OrderedVisibleCells)
|
||||
result.Add(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #127 distant-building churn detector. The captured 4 cm pair is now
|
||||
/// stable (the near-eye W=0 clip port), but the user symptom is buildings
|
||||
/// flickering when RUNNING PAST at a distance. This strafes the eye past
|
||||
/// each loaded building at several distances in 1 mm steps with the gaze
|
||||
/// fixed forward (the run-past geometry) and counts, per building cell, how
|
||||
/// many times its admission toggles over the monotone strafe. A stable
|
||||
/// flood toggles a cell AT MOST ONCE along a monotone eye path (it enters
|
||||
/// or leaves the view a single time); >=2 toggles is churn — the building
|
||||
/// flickers. preGate off vs on separates flood-math churn from the
|
||||
/// PortalBounds frustum pre-gate.
|
||||
///
|
||||
/// RESULT (2026-06-12, HEAD post-W=0-clip-port + #120 containment): ZERO
|
||||
/// churning cases across all 21 building groups x {10,30,60,120,190} m x
|
||||
/// 100 mm-steps, both preGate states. The near-eye knife-edge class the
|
||||
/// W=0 polyClipFinish port (987313a) killed was the distant-building
|
||||
/// flicker too; the user re-gate ("Seems to have been fixed") agrees.
|
||||
/// Now the REGRESSION PIN — it asserts zero churn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DistantBuildingStrafe_NoAdmissionChurn()
|
||||
{
|
||||
var w = LoadWorld();
|
||||
if (w is null) return;
|
||||
|
||||
const float fovY = MathF.PI / 3f;
|
||||
const float eyeHeight = 1.8f;
|
||||
const float strafeSpanM = 0.10f; // 10 cm strafe
|
||||
const int strafeSteps = 100; // 1 mm/step
|
||||
var distances = new[] { 10f, 30f, 60f, 120f, 190f };
|
||||
|
||||
int totalChurn = 0;
|
||||
foreach (bool preGate in new[] { false, true })
|
||||
{
|
||||
int worstToggles = 0;
|
||||
string worstDesc = "(none)";
|
||||
int churningCases = 0;
|
||||
|
||||
for (int gi = 0; gi < w.BuildingGroups.Count; gi++)
|
||||
{
|
||||
var group = w.BuildingGroups[gi];
|
||||
var (has, center) = PortalCenterFor(group);
|
||||
if (!has) continue;
|
||||
|
||||
foreach (float dist in distances)
|
||||
{
|
||||
// Eye south of the building at eye height, gaze NORTH toward
|
||||
// the building centre; strafe along world +X (run-past).
|
||||
var gaze = Vector3.Normalize(new Vector3(0f, 1f, -0.05f));
|
||||
var strafeDir = Vector3.Normalize(Vector3.Cross(Vector3.UnitZ, gaze)); // ~world +X
|
||||
var eyeBase = new Vector3(center.X, center.Y - dist, center.Z + eyeHeight)
|
||||
- strafeDir * (strafeSpanM * 0.5f);
|
||||
|
||||
var toggleCount = new Dictionary<uint, int>();
|
||||
var prevIn = new Dictionary<uint, bool>();
|
||||
for (int s = 0; s <= strafeSteps; s++)
|
||||
{
|
||||
var eye = eyeBase + strafeDir * (strafeSpanM * s / strafeSteps);
|
||||
var vp = ViewProjFor(eye, gaze, fovY);
|
||||
var frustum = FrustumPlanes.FromViewProjection(vp);
|
||||
var admits = BuildingAdmits(w, group, eye, vp, frustum, preGate);
|
||||
|
||||
var seen = new HashSet<uint>(admits);
|
||||
foreach (uint id in seen)
|
||||
{
|
||||
bool wasIn = prevIn.TryGetValue(id, out var p) && p;
|
||||
if (!wasIn && prevIn.ContainsKey(id))
|
||||
toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1;
|
||||
prevIn[id] = true;
|
||||
}
|
||||
foreach (var id in new List<uint>(prevIn.Keys))
|
||||
if (!seen.Contains(id))
|
||||
{
|
||||
if (prevIn[id])
|
||||
toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1;
|
||||
prevIn[id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, toggles) in toggleCount)
|
||||
{
|
||||
if (toggles < 2) continue; // <=1 = clean enter/leave
|
||||
churningCases++;
|
||||
if (toggles > worstToggles)
|
||||
{
|
||||
worstToggles = toggles;
|
||||
worstDesc = FormattableString.Invariant(
|
||||
$"group#{gi} dist={dist:F0}m cell=0x{id:X8} toggles={toggles}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"preGate={preGate}: churningCases={churningCases} worst={worstDesc} (worstToggles={worstToggles})"));
|
||||
totalChurn += churningCases;
|
||||
}
|
||||
|
||||
Assert.True(totalChurn == 0,
|
||||
$"{totalChurn} distant-building admission churn case(s) — a building's cells toggle >=2x " +
|
||||
"over a monotone run-past strafe (the #127 flicker); see output for the worst building/distance/cell");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
using System;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
||||
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #131 diagnostic (throwaway): identify the Holtburg portal among the
|
||||
/// outside-stage setup ids captured by the [outstage] probe, by dumping each
|
||||
/// candidate setup's parts + bounds from the dat. The portal's setup is the
|
||||
/// translucent swirl; lamp posts / creatures / signs identify by part shape.
|
||||
/// </summary>
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue131SetupProbeTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue131SetupProbeTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
/// <summary>#131: from the captured cottage-interior frame (the user's
|
||||
/// portal-missing viewpoint), does the look-in flood admit the hall's
|
||||
/// PORCH cell 0xA9B4017A (the portal's owner cell, pinned by the teleport
|
||||
/// pCell flip)? If not admitted, no pass can draw the swirl regardless of
|
||||
/// the emitter plumbing.</summary>
|
||||
[Fact]
|
||||
public void Diagnostic_LookInFlood_AdmitsHallPorchFromCottage()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, 0xA9B40000u);
|
||||
_out.WriteLine(FormattableString.Invariant($"loaded {cells.Count} A9B4 interior cells; hasPorch017A={cells.ContainsKey(0xA9B4017Au)}"));
|
||||
AcDream.App.Rendering.LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The captured frame: [viewer] root=0xA9B40171 eye=(155.255,14.533,96.074)
|
||||
// fwd=(0.0702,0.9554,-0.2869) (portal-owner-verdicts.log:135118).
|
||||
var eye = new System.Numerics.Vector3(155.255f, 14.533f, 96.074f);
|
||||
var fwd = new System.Numerics.Vector3(0.0702f, 0.9554f, -0.2869f);
|
||||
var view = System.Numerics.Matrix4x4.CreateLookAt(eye, eye + fwd, System.Numerics.Vector3.UnitZ);
|
||||
var proj = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
|
||||
var root = cells[0xA9B40171u];
|
||||
var pv = AcDream.App.Rendering.PortalVisibilityBuilder.Build(
|
||||
root, eye, Lookup, viewProj,
|
||||
buildingMembership: null);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"main flood={pv.OrderedVisibleCells.Count} outPolys={pv.OutsideView.Polygons.Count}"));
|
||||
|
||||
var lookIn = AcDream.App.Rendering.PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj,
|
||||
float.PositiveInfinity, pv.OutsideView.Polygons);
|
||||
var sb = new System.Text.StringBuilder("look-in admitted:");
|
||||
foreach (uint id in lookIn.OrderedVisibleCells)
|
||||
sb.Append(FormattableString.Invariant($" 0x{id & 0xFFFFu:X4}"));
|
||||
_out.WriteLine(sb.ToString());
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"porch 0x017A admitted: {lookIn.OrderedVisibleCells.Contains(0xA9B4017Au)}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_DumpOutstageCandidateSetups()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
uint[] candidates =
|
||||
{
|
||||
0x020010AC, // 0x7A9B4050 PASS r=11.9 — portal candidate A
|
||||
0x02000B8E, // 0x7A9B403B PASS r=11.6 — portal candidate B
|
||||
0x020019FF, // many instances (lamp posts?)
|
||||
0x02000290,
|
||||
0x02000001, // baseline (human?)
|
||||
0x02000E08,
|
||||
};
|
||||
|
||||
foreach (uint setupId in candidates)
|
||||
{
|
||||
var setup = dats.Get<DatSetup>(setupId);
|
||||
if (setup is null)
|
||||
{
|
||||
_out.WriteLine(FormattableString.Invariant($"setup 0x{setupId:X8}: NOT FOUND"));
|
||||
continue;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"setup 0x{setupId:X8}: parts={setup.Parts.Count}"));
|
||||
int shown = 0;
|
||||
foreach (uint partId in setup.Parts)
|
||||
{
|
||||
if (shown++ >= 4) { _out.WriteLine(" ..."); break; }
|
||||
var gfx = dats.Get<DatGfxObj>(partId);
|
||||
if (gfx is null) { _out.WriteLine(FormattableString.Invariant($" part 0x{partId:X8}: not found")); continue; }
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(FormattableString.Invariant(
|
||||
$" part 0x{partId:X8}: polys={gfx.Polygons.Count} verts={gfx.VertexArray.Vertices.Count} surfaces=["));
|
||||
int sShown = 0;
|
||||
foreach (uint surfId in gfx.Surfaces)
|
||||
{
|
||||
if (sShown++ >= 6) { sb.Append(" ..."); break; }
|
||||
sb.Append(FormattableString.Invariant($" 0x{surfId:X8}"));
|
||||
}
|
||||
sb.Append(" ]");
|
||||
_out.WriteLine(sb.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177
|
||||
/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub
|
||||
/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads:
|
||||
/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of
|
||||
/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color
|
||||
/// where its geometry was; stair cells failing admission = the pop).
|
||||
///
|
||||
/// Production-matched inputs: Build(root, eye, lookup, viewProj,
|
||||
/// buildingMembership: null) — the production call shape (FW3.3 retired drawLiftZ)
|
||||
/// mirrors RetailPViewRenderer.DrawInside.
|
||||
///
|
||||
/// Scenarios:
|
||||
/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp
|
||||
/// (0x0182) and the lower cell (0x0183): are they admitted?
|
||||
/// B. #177 descent — eye path down the ramp crossing into 0x0183: does
|
||||
/// 0x0182 (the ramp geometry's owner) drop near the transit?
|
||||
/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw
|
||||
/// sweep at several pitches: any cell admitted at angle k, gone at k+1,
|
||||
/// back at k+2 (the bistability signature)?
|
||||
/// D. #176 walk — eye tracks down the corridor across two seams, gaze
|
||||
/// locked +X: per-step admitted-set diffs (drop-for-one-step churn).
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue176177FacilityHubFloodReplayTests
|
||||
{
|
||||
private const uint FacilityHub = 0x8A020000u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static List<uint> Flood(
|
||||
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
|
||||
buildingMembership: null);
|
||||
var result = new List<uint>(frame.OrderedVisibleCells);
|
||||
result.Sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string CellSetString(IEnumerable<uint> ids)
|
||||
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||
|
||||
[Fact]
|
||||
public void ScenarioA_StairApproach_AdmissionsFromCorridor()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded");
|
||||
|
||||
// 0x0178 spans x<=95 at z −6..−3 (floor −6); the ramp cell 0x0182 runs
|
||||
// x 95→98.33 descending; 0x0183 continues at z −9 beyond x=98.33.
|
||||
// Eye at standing height (~1.7 m above the −6 floor), approaching the
|
||||
// stair portal at x=95, gazing +X with a slight downward pitch (the
|
||||
// natural look at a descending stair).
|
||||
foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f })
|
||||
{
|
||||
var eye = new Vector3(eyeX, -40f, -4.3f);
|
||||
foreach (var (gaze, label) in new (Vector3, string)[]
|
||||
{
|
||||
(new Vector3(1f, 0f, 0f), "level"),
|
||||
(Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"),
|
||||
(Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"),
|
||||
})
|
||||
{
|
||||
var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze);
|
||||
bool ramp = visible.Contains(FacilityHub | 0x0182u);
|
||||
bool lower = visible.Contains(FacilityHub | 0x0183u);
|
||||
_out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " +
|
||||
$"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " +
|
||||
$"[{CellSetString(visible)}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ScenarioB_StairDescent_RampCellRetention()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
// Descend the ramp: floor z goes −6 (x=95) → −9 (x=98.33), then flat.
|
||||
// Eye rides ~1.7 m above the local floor. Root = the cell containing
|
||||
// the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after).
|
||||
// Gaze: forward and slightly down (running down stairs).
|
||||
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f));
|
||||
List<uint>? prev = null;
|
||||
for (float x = 94.0f; x <= 100.5f; x += 0.1f)
|
||||
{
|
||||
float floorZ = x < 95f ? -6f
|
||||
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
|
||||
: -9f;
|
||||
var eye = new Vector3(x, -40f, floorZ + 1.7f);
|
||||
uint rootId = x < 95f ? FacilityHub | 0x0178u
|
||||
: x < 98.333f ? FacilityHub | 0x0182u
|
||||
: FacilityHub | 0x0183u;
|
||||
|
||||
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||
bool ramp = visible.Contains(FacilityHub | 0x0182u);
|
||||
bool upper = visible.Contains(FacilityHub | 0x0178u);
|
||||
|
||||
string diff = "";
|
||||
if (prev is not null)
|
||||
{
|
||||
var removed = prev.Except(visible).ToList();
|
||||
var added = visible.Except(prev).ToList();
|
||||
if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]";
|
||||
if (added.Count > 0) diff += $" added=[{CellSetString(added)}]";
|
||||
}
|
||||
_out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " +
|
||||
$"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}");
|
||||
prev = visible;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ScenarioC_CorridorSeamGazeSweep_Bistability()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
// Parked eye in 0x016E near the 0x017A seam (x=85), standing height.
|
||||
var eye = new Vector3(83.5f, -40f, -4.3f);
|
||||
uint rootId = FacilityHub | 0x016Eu;
|
||||
|
||||
int churnEvents = 0;
|
||||
foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f })
|
||||
{
|
||||
List<uint>? prevSet = null;
|
||||
float prevYaw = 0f;
|
||||
var perYawSets = new List<(float yaw, List<uint> set)>();
|
||||
for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f)
|
||||
{
|
||||
float rad = yawDeg * MathF.PI / 180f;
|
||||
var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ));
|
||||
var visible = Flood(cells, rootId, eye, gaze);
|
||||
perYawSets.Add((yawDeg, visible));
|
||||
|
||||
if (prevSet is not null)
|
||||
{
|
||||
var removed = prevSet.Except(visible).ToList();
|
||||
var added = visible.Except(prevSet).ToList();
|
||||
if (removed.Count > 0 || added.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " +
|
||||
$"flood {prevSet.Count}->{visible.Count}" +
|
||||
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
|
||||
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
|
||||
}
|
||||
}
|
||||
prevSet = visible;
|
||||
prevYaw = yawDeg;
|
||||
}
|
||||
|
||||
// Bistability: a cell present at yaw k, absent at k+1, present at k+2.
|
||||
for (int i = 2; i < perYawSets.Count; i++)
|
||||
{
|
||||
var flicker = perYawSets[i - 2].set
|
||||
.Intersect(perYawSets[i].set)
|
||||
.Except(perYawSets[i - 1].set)
|
||||
.ToList();
|
||||
if (flicker.Count > 0)
|
||||
{
|
||||
churnEvents++;
|
||||
_out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " +
|
||||
$"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step");
|
||||
}
|
||||
}
|
||||
}
|
||||
_out.WriteLine($"bistable one-step drop events: {churnEvents}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE production lag-window scenario (from launch-137-gate2.log
|
||||
/// [cell-transit] lines): membership transits fire 0.1–0.6 m PAST the
|
||||
/// portal plane in the travel direction (016E→017A at x=85.33–85.47 vs
|
||||
/// the plane at x=85.00; 0182→0183 at 98.56–98.64 vs 98.33). The render
|
||||
/// root (viewer cell, same membership machinery) therefore holds the OLD
|
||||
/// cell while the camera eye is already beyond the boundary portal's
|
||||
/// plane. This scenario reproduces exactly that window: root=old cell,
|
||||
/// eye stepped across and past the plane, gaze forward. If the forward
|
||||
/// chain (the next corridor cells) drops inside the window, that is #176
|
||||
/// (purple = fog clear color where the forward cells' geometry was) and
|
||||
/// #177(a)/(c) at the stair transit.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
[InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85
|
||||
[InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33
|
||||
public void Diagnostic_ScenarioE_RootLagWindow_ForwardChainRetention(
|
||||
uint rootLow, uint forwardLow, float planeX, float eyeZ)
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
uint rootId = FacilityHub | rootLow;
|
||||
uint forwardId = FacilityHub | forwardLow;
|
||||
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
|
||||
|
||||
_out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " +
|
||||
"(eye sweeps across; root HELD at the old cell = the production lag window)");
|
||||
foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f })
|
||||
{
|
||||
var eye = new Vector3(planeX + dx, -40f, eyeZ);
|
||||
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||
bool fwd = visible.Contains(forwardId);
|
||||
_out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " +
|
||||
$"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ScenarioD_CorridorWalk_PerStepChurn()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
// Walk 0x0165 → 0x016E → 0x017A along y=−40 (seams at x=75 and x=85),
|
||||
// gaze locked +X, slight downward pitch (the running view). Root flips
|
||||
// by x-range at the seams (the camera transits the same portals).
|
||||
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
|
||||
List<uint>? prev = null;
|
||||
int churn = 0;
|
||||
for (float x = 71f; x <= 89f; x += 0.05f)
|
||||
{
|
||||
uint rootId = x < 75f ? FacilityHub | 0x0165u
|
||||
: x < 85f ? FacilityHub | 0x016Eu
|
||||
: FacilityHub | 0x017Au;
|
||||
if (!cells.ContainsKey(rootId)) continue;
|
||||
var eye = new Vector3(x, -40f, -4.3f);
|
||||
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||
|
||||
if (prev is not null)
|
||||
{
|
||||
var removed = prev.Except(visible).ToList();
|
||||
var added = visible.Except(prev).ToList();
|
||||
if (removed.Count > 0 || added.Count > 0)
|
||||
{
|
||||
churn++;
|
||||
_out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" +
|
||||
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
|
||||
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
|
||||
}
|
||||
}
|
||||
prev = visible;
|
||||
}
|
||||
_out.WriteLine($"admitted-set change events over the walk: {churn}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,727 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Scene.Arch;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
|
||||
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #177 decisive full-chain replay: drive the REAL <see cref="RetailChaseCamera"/>
|
||||
/// + REAL <see cref="PhysicsCameraCollisionProbe"/> down the Facility Hub
|
||||
/// staircase against the REAL BSP, then feed the swept (ViewerCellId, eye,
|
||||
/// View*Projection) into <see cref="PortalVisibilityBuilder.Build"/> — exactly
|
||||
/// the production coupling (GameWindow ~9055-9065 + RetailPViewRenderer.DrawInside).
|
||||
///
|
||||
/// The earlier Issue176177 replays fed SYNTHETIC (root, eye) pairs: Scenario A
|
||||
/// admitted the stairs fine from a coherent corridor root, Scenario E collapsed
|
||||
/// the flood 12->1 but only because it HELD the root at the old cell while the
|
||||
/// eye crossed a portal plane. This test removes the assumption: the root IS the
|
||||
/// swept camera cell, the eye IS the swept camera eye, both from the same sweep.
|
||||
/// If the stairs still collapse, #177 is a production root/eye coupling defect
|
||||
/// and the printed per-portal side-test D names the failing gate. If they stay
|
||||
/// admitted, the flood is exonerated and the artifact lives downstream.
|
||||
/// </summary>
|
||||
[Collection(CameraDiagnosticsCollection.Name)]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue177StairDescentCameraFloodTests
|
||||
{
|
||||
private const uint FacilityHub = 0x8A020000u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue177StairDescentCameraFloodTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv;
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildHubEngine(DatCollection dats)
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
for (uint low = 0x0100u; low <= 0x01FFu; low++)
|
||||
{
|
||||
uint id = FacilityHub | low;
|
||||
var datCell = dats.Get<DatEnvCell>(id);
|
||||
if (datCell is null) continue;
|
||||
var environment = dats.Get<DatEnvironment>(0x0D000000u | datCell.EnvironmentId);
|
||||
if (environment is null) continue;
|
||||
if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||
continue;
|
||||
var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(datCell.Position.Origin);
|
||||
cache.CacheCellStruct(id, datCell, cellStruct, world);
|
||||
}
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||
engine.AddLandblock(FacilityHub, new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
// Analytic ramp floor from Scenario B: flat -6 (x<95), descends to -9 across
|
||||
// x 95..98.333, then flat -9.
|
||||
private static float FloorZ(float x)
|
||||
=> x < 95f ? -6f
|
||||
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
|
||||
: -9f;
|
||||
|
||||
// Replicated side test (PortalVisibilityBuilder.CameraOnInteriorSide is private).
|
||||
private const float PortalSideEpsilon = 0.01f;
|
||||
private static bool EyeInteriorSide(LoadedCell cell, int i, Vector3 eye)
|
||||
{
|
||||
if (i >= cell.ClipPlanes.Count) return true;
|
||||
var pl = cell.ClipPlanes[i];
|
||||
if (pl.Normal.LengthSquared() < 1e-8f) return true;
|
||||
var local = Vector3.Transform(eye, cell.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(pl.Normal, local) + pl.D;
|
||||
return pl.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
|
||||
}
|
||||
private static float SideD(LoadedCell cell, int i, Vector3 eye)
|
||||
{
|
||||
if (i >= cell.ClipPlanes.Count) return float.NaN;
|
||||
var pl = cell.ClipPlanes[i];
|
||||
if (pl.Normal.LengthSquared() < 1e-8f) return float.NaN;
|
||||
var local = Vector3.Transform(eye, cell.InverseWorldTransform);
|
||||
return Vector3.Dot(pl.Normal, local) + pl.D;
|
||||
}
|
||||
|
||||
private static string CellSet(IEnumerable<uint> ids)
|
||||
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_StairCellComposition_ShellVsStatics()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (uint low in new uint[] { 0x015E, 0x015F, 0x01C8, 0x01C4, 0x01C9, 0x0210, 0x020E, 0x01C1, 0x01C0 })
|
||||
{
|
||||
uint id = FacilityHub | low;
|
||||
var envCell = dats.Get<DatEnvCell>(id);
|
||||
if (envCell is null) { _out.WriteLine($"0x{low:X4}: (no dat cell)"); continue; }
|
||||
var environment = dats.Get<DatEnvironment>(0x0D000000u | envCell.EnvironmentId);
|
||||
environment!.Cells.TryGetValue(envCell.CellStructure, out var cs);
|
||||
|
||||
int polys = cs?.Polygons.Count ?? -1;
|
||||
int verts = cs?.VertexArray.Vertices.Count ?? -1;
|
||||
var mn = new Vector3(float.MaxValue); var mx = new Vector3(float.MinValue);
|
||||
if (cs is not null)
|
||||
foreach (var kv in cs.VertexArray.Vertices)
|
||||
{ var o = kv.Value.Origin; var v = new Vector3(o.X, o.Y, o.Z); mn = Vector3.Min(mn, v); mx = Vector3.Max(mx, v); }
|
||||
|
||||
int statics = envCell.StaticObjects?.Count ?? 0;
|
||||
bool seenOut = (envCell.Flags & DatReaderWriter.Enums.EnvCellFlags.SeenOutside) != 0;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"0x{low:X4} env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} shellPolys={polys} shellVerts={verts} bounds=[{mn.X:F1},{mn.Y:F1},{mn.Z:F1}]..[{mx.X:F1},{mx.Y:F1},{mx.Z:F1}] portals={envCell.CellPortals.Count} statics={statics} seenOut={seenOut}"));
|
||||
if (envCell.StaticObjects is not null)
|
||||
foreach (var stab in envCell.StaticObjects)
|
||||
{
|
||||
var o = stab.Frame.Origin;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" static Id=0x{stab.Id:X8} kind={(stab.Id >> 24 == 0x02 ? "Setup" : stab.Id >> 24 == 0x01 ? "GfxObj" : "?")} pos=({o.X:F2},{o.Y:F2},{o.Z:F2})"));
|
||||
}
|
||||
|
||||
// Portal normals (local space) — horizontal (|Nz|~1) = FLOOR/CEILING portal
|
||||
// (the edge-on-clip suspect); vertical (|Nz|~0) = wall/doorway portal.
|
||||
var lc = renderLookup(id);
|
||||
if (lc is not null)
|
||||
for (int i = 0; i < lc.Portals.Count; i++)
|
||||
{
|
||||
var n = i < lc.ClipPlanes.Count ? lc.ClipPlanes[i].Normal : Vector3.Zero;
|
||||
string kind = MathF.Abs(n.Z) > 0.7f ? "FLOOR/CEIL" : MathF.Abs(n.Z) < 0.3f ? "wall" : "slope";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" portal[{i}]->0x{lc.Portals[i].OtherCellId:X4} N=({n.X:F2},{n.Y:F2},{n.Z:F2}) {kind} inside={lc.ClipPlanes[i].InsideSide}"));
|
||||
}
|
||||
}
|
||||
|
||||
LoadedCell? renderLookup(uint id)
|
||||
{
|
||||
try { return CornerFloodReplayTests.LoadCell(dats, id); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 2: retargeted from the deleted
|
||||
/// <c>ShadowObjectRegistry.ComputeStaticRenderCells</c> pure-function
|
||||
/// side channel to the production
|
||||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> transaction
|
||||
/// (the SAME BSP-collision + <c>partArray:</c> recipe
|
||||
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses, and the
|
||||
/// same one <c>RetailCellArrayComparatorInstalledDatTests</c>' fixture 1
|
||||
/// exercises for this exact Setup) plus
|
||||
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>. The entity
|
||||
/// id now MUST match the projection's
|
||||
/// <see cref="RenderSourceMetadata.LocalEntityId"/> — chunk 2's
|
||||
/// <see cref="WalkProductionWorldData"/> indoor sweep looks the retail
|
||||
/// CELLARRAY up BY that id, unlike the deleted method's seed+position
|
||||
/// pure function, which never carried entity identity. The former
|
||||
/// "WithoutCollisionRows" name no longer describes production: a part
|
||||
/// array is always supplied at the SAME registration as the collision
|
||||
/// shapes now (chunk 1b), so <c>GetOwnerCells</c> for this BSP-bearing
|
||||
/// Setup reflects that registration — and, per the comparator's fixture-1
|
||||
/// evidence, equals the retail CELLARRAY exactly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FacilityStairAssembly_RegisterAcross015FTo015E()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
const uint setupId = 0x02000623u;
|
||||
DatSetup setup = Assert.IsType<DatSetup>(dats.Get<DatSetup>(setupId));
|
||||
PhysicsEngine engine = BuildHubEngine(dats);
|
||||
PhysicsDataCache cache = Assert.IsType<PhysicsDataCache>(engine.DataCache);
|
||||
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
|
||||
foreach (MeshRef meshRef in meshRefs)
|
||||
{
|
||||
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(
|
||||
dats.Get<DatGfxObj>(meshRef.GfxObjId));
|
||||
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
|
||||
}
|
||||
|
||||
DatEnvCell parent = Assert.IsType<DatEnvCell>(
|
||||
dats.Get<DatEnvCell>(FacilityHub | 0x015Fu));
|
||||
var stair = Assert.Single(
|
||||
parent.StaticObjects,
|
||||
staticObject =>
|
||||
staticObject.Id == setupId);
|
||||
var rootPosition = new Vector3(
|
||||
stair.Frame.Origin.X,
|
||||
stair.Frame.Origin.Y,
|
||||
stair.Frame.Origin.Z);
|
||||
Quaternion rootRotation = stair.Frame.Orientation;
|
||||
|
||||
List<ShadowShape> bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
|
||||
meshRefs, isBuildingShell: false, cache.GetGfxObj);
|
||||
List<ShadowShape> parts = ShadowShapeBuilder.FromStaticRenderParts(
|
||||
meshRefs,
|
||||
cache.GetGfxObj,
|
||||
cache.GetVisualBounds,
|
||||
out bool hasPhysicsBsp);
|
||||
Assert.True(hasPhysicsBsp);
|
||||
Assert.NotEmpty(parts);
|
||||
Assert.NotEmpty(bspShapes);
|
||||
|
||||
const uint entityId = 0x48A02000u;
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
entityId,
|
||||
rootPosition,
|
||||
rootRotation,
|
||||
bspShapes,
|
||||
state: 0u,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: FacilityHub,
|
||||
seedCellId: FacilityHub | 0x015Fu,
|
||||
isStatic: true,
|
||||
partArray: parts);
|
||||
|
||||
Assert.True(engine.ShadowObjects.TryGetRetailCellArray(
|
||||
entityId, out IReadOnlyList<uint> cells));
|
||||
|
||||
ShadowShape part = parts[0];
|
||||
ShadowPartBox box = ShadowPartBox.FromShape(
|
||||
part,
|
||||
rootPosition,
|
||||
rootRotation);
|
||||
LoadedCell rootCell = CornerFloodReplayTests.LoadCell(
|
||||
dats,
|
||||
FacilityHub | 0x015Fu);
|
||||
box.RefitToLocal(rootCell.InverseWorldTransform, out Vector3 boxMin, out Vector3 boxMax);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"stair root=({rootPosition.X:F3},{rootPosition.Y:F3},{rootPosition.Z:F3}) localBox=({boxMin.X:F3},{boxMin.Y:F3},{boxMin.Z:F3})..({boxMax.X:F3},{boxMax.Y:F3},{boxMax.Z:F3}) cells=[{string.Join(',', cells.Select(static id => $"0x{id:X8}"))}]"));
|
||||
|
||||
Assert.Contains(FacilityHub | 0x015Fu, cells);
|
||||
Assert.Contains(FacilityHub | 0x015Eu, cells);
|
||||
|
||||
// The registration carries collision shapes at the SAME transaction
|
||||
// now (production always does — chunk 1b threaded the part array
|
||||
// through the SAME RegisterMultiPart call the collision dispatch
|
||||
// uses), so GetOwnerCells reflects that registration instead of
|
||||
// staying empty; retail == collision for this BSP-bearing Setup
|
||||
// (RetailCellArrayComparatorInstalledDatTests fixture 1), so the two
|
||||
// sets agree exactly.
|
||||
Assert.Equal(
|
||||
cells.OrderBy(id => id),
|
||||
engine.ShadowObjects.GetOwnerCells(entityId).OrderBy(id => id));
|
||||
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
RenderProjectionId projectionId = RenderProjectionId.FromRaw(entityId);
|
||||
RenderTransform transform = RenderTransform.FromRoot(
|
||||
rootPosition,
|
||||
rootRotation,
|
||||
1f);
|
||||
RenderProjectionRecord projection = new RenderProjectionRecord() with
|
||||
{
|
||||
Id = projectionId,
|
||||
ProjectionClass = RenderProjectionClass.IndoorCellStatic,
|
||||
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
|
||||
Transform = transform,
|
||||
PreviousTransform = new PreviousRenderTransform(transform.LocalToWorld),
|
||||
Residency = new RenderSpatialResidency(
|
||||
RenderSpatialBucket.FromRaw(FacilityHub | 0x015Fu),
|
||||
FacilityHub,
|
||||
FacilityHub | 0x015Fu),
|
||||
Flags = RenderProjectionFlags.Draw,
|
||||
Source = new RenderSourceMetadata() with
|
||||
{
|
||||
LocalEntityId = entityId,
|
||||
SourceId = setupId,
|
||||
ParentCellId = FacilityHub | 0x015Fu,
|
||||
},
|
||||
EntityPayload = new RenderEntityPayload(meshRefs, null, false),
|
||||
};
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
|
||||
|
||||
var worldData = new WalkProductionWorldData(
|
||||
new WalkBuildingRegistry(),
|
||||
engine.ShadowObjects);
|
||||
worldData.BeginFrame(
|
||||
scene.OpenQuery(),
|
||||
FacilityHub,
|
||||
renderCenterLbX: 0x8A,
|
||||
renderCenterLbY: 0x02);
|
||||
|
||||
Assert.Contains(
|
||||
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
|
||||
record => record.Id == projectionId);
|
||||
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 re-diagnosis (fix#1 failed the visual gate): the vanish flips on a SLIGHT turn +
|
||||
/// depends on zoom, so it is a gaze/eye knife-edge, not the eye-squarely-in-opening case
|
||||
/// fix#1 handled. Drive the REAL camera at a fixed player spot on the 015F stairs, sweep
|
||||
/// yaw FINELY at two zooms, and print the viewer cell + stair-cell admission each step to
|
||||
/// locate the knife-edge and which cell flips.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_RealStaircase_FineYawZoomSweep_FindKnifeEdge()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
uint[] stair = { 0x015Eu, 0x015Fu, 0x01C1u, 0x01C0u, 0x020Fu };
|
||||
// Player standing mid-015F stairs, eye near the ceiling boundary (per the sweep char).
|
||||
foreach (var playerPos in new[] { new Vector3(60.5f, -46f, -2.5f), new Vector3(60.5f, -47f, -3.0f) })
|
||||
foreach (float dist in new[] { 2.61f, 12.0f })
|
||||
{
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"=== player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1}) zoom={dist} ==="));
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist,
|
||||
};
|
||||
uint prevViewer = 0; string prevAdmit = "";
|
||||
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 6f)
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f;
|
||||
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Fu, playerPos + new Vector3(0, 0, 0.1f));
|
||||
for (int i = 0; i < 160; i++)
|
||||
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
|
||||
var eye = cam.Position; uint viewer = cam.ViewerCellId;
|
||||
string admit = "?";
|
||||
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rc))
|
||||
{
|
||||
var f = PortalVisibilityBuilder.Build(rc, eye, lookup, cam.View * cam.Projection, null);
|
||||
var s = f.OrderedVisibleCells;
|
||||
admit = string.Concat(stair.Select(low => (s.Contains(FacilityHub | low) ? "1" : "0")));
|
||||
}
|
||||
// Print only when viewer or the admit pattern CHANGES (knife-edges).
|
||||
if (viewer != prevViewer || admit != prevAdmit)
|
||||
{
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} stair[5E,5F,C1,C0,0F]={admit}"));
|
||||
prevViewer = viewer; prevAdmit = admit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 retail-vs-us flood DEPTH: retail's cdb capture shows PView cell_draw_num reaching 26
|
||||
/// cells from camera cell 015E (avg 14.3) looking down the spiral shaft. Sweep OUR real camera
|
||||
/// from 015E over many gaze/zoom poses and report the max/avg OrderedVisibleCells — if ours
|
||||
/// caps well below retail's 26, our flood truncates the spiral (the vanish).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_FloodDepthFrom015E_VsRetail26()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
int maxCells = 0; uint bestViewer = 0; double sum = 0; int n = 0; string bestSet = "";
|
||||
foreach (var playerPos in new[] { new Vector3(61f, -42f, -4f), new Vector3(60f, -41f, -5f), new Vector3(62f, -43f, -4f) })
|
||||
foreach (float dist in new[] { 2.61f, 4f, 8f })
|
||||
foreach (float pitch in new[] { 0.291f, 0.7f, -0.2f })
|
||||
{
|
||||
var cam = new RetailChaseCamera { CollisionProbe = new PhysicsCameraCollisionProbe(engine), FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist, Pitch = pitch };
|
||||
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 10f)
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f;
|
||||
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Eu, playerPos + new Vector3(0, 0, 0.1f));
|
||||
for (int i = 0; i < 140; i++) cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
|
||||
uint viewer = cam.ViewerCellId;
|
||||
if (viewer == 0u || !renderCells.TryGetValue(viewer, out var rc)) continue;
|
||||
var f = PortalVisibilityBuilder.Build(rc, cam.Position, lookup, cam.View * cam.Projection, null);
|
||||
int c = f.OrderedVisibleCells.Count; sum += c; n++;
|
||||
if (c > maxCells) { maxCells = c; bestViewer = viewer; bestSet = CellSet(f.OrderedVisibleCells.OrderBy(v => v)); }
|
||||
}
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"OUR flood from 015E-area: maxCells={maxCells} (retail max=26) avg={sum / Math.Max(1, n):F1} (retail avg=14.3) bestViewer=0x{bestViewer & 0xFFFF:X4}"));
|
||||
_out.WriteLine($" best set: [{bestSet}]");
|
||||
}
|
||||
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProj(Vector3 eye, Vector3 dir)
|
||||
=> Matrix4x4.CreateLookAt(eye, eye + Vector3.Normalize(dir), Vector3.UnitZ)
|
||||
* Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
|
||||
|
||||
// Signed distance of a WORLD point to a render cell's portal[i] plane (local-space plane).
|
||||
private static float PortalPlaneDistance(LoadedCell cell, int i, Vector3 worldPt)
|
||||
{
|
||||
var pl = cell.ClipPlanes[i];
|
||||
var local = Vector3.Transform(worldPt, cell.InverseWorldTransform);
|
||||
return Vector3.Dot(pl.Normal, local) + pl.D;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 THE decisive experiment. Reproduce the captured failing frame — camera in
|
||||
/// cell 0x015F with the eye sitting IN the ceiling-portal plane (production [flap]:
|
||||
/// root=015F eye=(60.47,-46.57,-0.00), p->01C1 clip=0) — and confirm the upper
|
||||
/// stair cell 01C1 drops from the flood. Then move the SAME eye 0.4 m off that
|
||||
/// plane (into the cell interior) and confirm 01C1 is re-admitted. If the contrast
|
||||
/// holds, #177's fix is a viewpoint-off-the-portal-plane rule, and the load-bearing
|
||||
/// clip (#119/#181) is correct as-is.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EdgeOnCeilingPortal_DropsUpperCell_OffPlaneReadmits()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var root = cells[FacilityHub | 0x015Fu];
|
||||
uint upperId = FacilityHub | 0x01C1u;
|
||||
|
||||
// Find 015F's portal to 01C1 (the ceiling/up portal) + report its normal.
|
||||
int upIdx = -1;
|
||||
for (int i = 0; i < root.Portals.Count; i++)
|
||||
if (root.Portals[i].OtherCellId == 0x01C1u) { upIdx = i; break; }
|
||||
Assert.True(upIdx >= 0, "015F has no portal to 01C1");
|
||||
var upN = root.ClipPlanes[upIdx].Normal;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"015F portal[{upIdx}]->01C1 localN=({upN.X:F2},{upN.Y:F2},{upN.Z:F2})"));
|
||||
|
||||
// Look UP the stairs so the ceiling portal + 01C1 land on screen.
|
||||
var gaze = new Vector3(0f, -0.3f, 1f);
|
||||
|
||||
// Captured FAILING eye — in the ceiling-portal plane (world z ~0).
|
||||
var eyeEdgeOn = new Vector3(60.47f, -46.57f, -0.00f);
|
||||
// Same eye, pulled 0.4 m DOWN (off the ceiling portal, into 015F's interior).
|
||||
var eyeOffPlane = eyeEdgeOn + new Vector3(0f, 0f, -0.40f);
|
||||
|
||||
foreach (var (label, eye) in new[] { ("edge-on(captured)", eyeEdgeOn), ("off-plane(-0.4z)", eyeOffPlane) })
|
||||
{
|
||||
float d = PortalPlaneDistance(root, upIdx, eye);
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
root, eye, lookup, ViewProj(eye, gaze),
|
||||
buildingMembership: null);
|
||||
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
|
||||
bool up = vis.Contains(upperId);
|
||||
string admit = up ? "ADMITTED" : "DROPPED";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"{label,-18} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) D->01C1={d,6:F3} 01C1={admit} flood={vis.Count} [{CellSet(vis)}]"));
|
||||
}
|
||||
|
||||
// #177 CHARACTERIZATION (fix#1 reverted — this edge-on-in-plane case was NOT the
|
||||
// production mechanism; the real vanish is a grazing/sliver flood collapse in the
|
||||
// spiral, root 0x01C8, portals off-screen/sliver, camera NOT collision-jammed).
|
||||
// Kept as a mechanism pin: an eye exactly in the ceiling-portal plane drops the upper
|
||||
// cell (edge-on → <3 clip), an eye 0.4 m off admits it via the normal clip.
|
||||
var fEdge = PortalVisibilityBuilder.Build(root, eyeEdgeOn, lookup, ViewProj(eyeEdgeOn, gaze), null);
|
||||
var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null);
|
||||
Assert.DoesNotContain(upperId, fEdge.OrderedVisibleCells); // edge-on drops (mechanism)
|
||||
Assert.Contains(upperId, fOff.OrderedVisibleCells); // off-plane admits (control)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 sweep characterization: place the player on the 015F staircase and drive the
|
||||
/// REAL chase camera + collision sweep, reporting where the eye lands relative to the
|
||||
/// 015F ceiling-portal plane (world z=0). If the eye rests AT the plane (|D|~0) the
|
||||
/// flood collapses (see EdgeOnCeilingPortal test); this shows whether that is a swept
|
||||
/// contact leaving the eye on the surface vs free-space boom geometry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_StaircaseSweep_EyeClearanceFromCeilingPortal()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
var root015F = renderCells[FacilityHub | 0x015Fu];
|
||||
int upIdx = 0; // portal[0]->01C1 (ceiling)
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
// Player on the 015F stairs (world y~-46, ceiling portal at world z~0), swept
|
||||
// at a range of heights + facings (up-stairs = -Y, down-stairs = +Y).
|
||||
foreach (float pz in new[] { -1.0f, -1.5f, -2.0f, -2.5f, -3.0f, -3.5f })
|
||||
foreach (var (fLabel, yaw) in new (string, float)[] { ("up(-Y)", -MathF.PI / 2f), ("down(+Y)", MathF.PI / 2f) })
|
||||
{
|
||||
var playerPos = new Vector3(60.5f, -46f, pz);
|
||||
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Fu, playerPos + new Vector3(0, 0, 0.1f));
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f, Aspect = 16f / 9f,
|
||||
};
|
||||
for (int i = 0; i < 300; i++)
|
||||
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
|
||||
|
||||
var eye = cam.Position;
|
||||
uint viewer = cam.ViewerCellId;
|
||||
// D from the eye to 015F's ceiling-portal plane (only meaningful if viewer==015F).
|
||||
float dCeil = PortalPlaneDistance(root015F, upIdx, eye);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"playerZ={pz,5:F1} face={fLabel,-8} pcell=0x{pcell & 0xFFFF:X4} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} D->ceilPortal={dCeil,6:F3}"));
|
||||
}
|
||||
}
|
||||
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
// spot label, player x, player cell, description
|
||||
[InlineData(90.0f, 0x0178u, "TOP corridor 0178 (approach view)")]
|
||||
[InlineData(100.0f, 0x0183u, "BOTTOM room 0183 (look-back view)")]
|
||||
public void Diagnostic_ParkedYawZoomSweep_StairAdmission(float px, uint pcell, string label)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
float sT = CameraDiagnostics.TranslationStiffness, sR = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
var playerPos = new Vector3(px, -40f, FloorZ(px));
|
||||
uint playerCell = FacilityHub | pcell;
|
||||
_out.WriteLine($"=== {label} @ ({px:F1},-40,{FloorZ(px):F2}) ===");
|
||||
foreach (float dist in new[] { 2.61f, 8.0f })
|
||||
{
|
||||
_out.WriteLine($" -- zoom Distance={dist} --");
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist,
|
||||
};
|
||||
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 30f)
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f;
|
||||
// Settle at this yaw (static player).
|
||||
for (int i = 0; i < 200; i++)
|
||||
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, playerCell, 0x5000000A);
|
||||
Vector3 eye = cam.Position; uint viewer = cam.ViewerCellId;
|
||||
var vp = cam.View * cam.Projection;
|
||||
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell))
|
||||
{
|
||||
var f = PortalVisibilityBuilder.Build(rootCell, eye, lookup, vp, null);
|
||||
var vis = f.OrderedVisibleCells.OrderBy(v => v).ToList();
|
||||
string flags = $"0178={(vis.Contains(FacilityHub | 0x0178u) ? "Y" : "-")} 0182={(vis.Contains(FacilityHub | 0x0182u) ? "Y" : "-")} 0183={(vis.Contains(FacilityHub | 0x0183u) ? "Y" : "-")} 0181={(vis.Contains(FacilityHub | 0x0181u) ? "Y" : "-")}";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} {flags} flood={vis.Count} [{CellSet(vis)}]"));
|
||||
}
|
||||
else
|
||||
_out.WriteLine(FormattableString.Invariant($" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer:X8} ROOT-MISSING"));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA;
|
||||
CameraDiagnostics.TranslationStiffness = sT; CameraDiagnostics.RotationStiffness = sR;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_Descent_RealCameraSweep_StairCellRetention()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool savedColl = CameraDiagnostics.CollideCamera;
|
||||
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||
float savedT = CameraDiagnostics.TranslationStiffness;
|
||||
float savedR = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true;
|
||||
CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f,
|
||||
Aspect = 16f / 9f,
|
||||
};
|
||||
|
||||
const float yaw = 0f; // facing +X, descending
|
||||
const float speed = 3.0f; // m/s run
|
||||
const float dt = 1f / 60f;
|
||||
float startX = 88f, endX = 101.5f;
|
||||
|
||||
// Seat the player + settle the camera at the start pose.
|
||||
uint playerCell = FacilityHub | 0x0178u;
|
||||
Vector3 P(float x) => new Vector3(x, -40f, FloorZ(x));
|
||||
for (int i = 0; i < 400; i++)
|
||||
cam.Update(P(startX), yaw, Vector3.Zero, true, Vector3.UnitZ, dt, playerCell, 0x5000000A);
|
||||
|
||||
_out.WriteLine("x playerCell eye=(x,y,z) viewerCell coh flood stairs rootPortalsPastPlane");
|
||||
float x = startX;
|
||||
uint prevViewer = cam.ViewerCellId;
|
||||
int collapses = 0, incoherentFrames = 0;
|
||||
while (x <= endX)
|
||||
{
|
||||
var playerPos = P(x);
|
||||
// Track player membership like production: AdjustPosition carry-forward.
|
||||
var (pc, found) = engine.AdjustPosition(playerCell, playerPos + new Vector3(0, 0, 0.1f));
|
||||
if (found) playerCell = pc;
|
||||
|
||||
cam.Update(playerPos, yaw, new Vector3(speed, 0, 0), true, Vector3.UnitZ, dt, playerCell, 0x5000000A);
|
||||
|
||||
Vector3 eye = cam.Position;
|
||||
uint viewer = cam.ViewerCellId;
|
||||
var viewProj = cam.View * cam.Projection;
|
||||
|
||||
string floodStr, stairs, coh, past;
|
||||
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell))
|
||||
{
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
rootCell, eye, lookup, viewProj,
|
||||
buildingMembership: null);
|
||||
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
|
||||
floodStr = $"{vis.Count,2} [{CellSet(vis)}]";
|
||||
bool ramp = vis.Contains(FacilityHub | 0x0182u);
|
||||
bool lower = vis.Contains(FacilityHub | 0x0183u);
|
||||
stairs = $"r{(ramp ? "Y" : "-")}l{(lower ? "Y" : "-")}";
|
||||
|
||||
// Coherence: is the eye on the interior side of ALL of root's portals?
|
||||
// If not, the eye is outside root -> the crossed portal's subtree drops.
|
||||
var pastList = new List<string>();
|
||||
bool coherent = true;
|
||||
for (int i = 0; i < rootCell.Portals.Count && i < rootCell.ClipPlanes.Count; i++)
|
||||
{
|
||||
if (!EyeInteriorSide(rootCell, i, eye))
|
||||
{
|
||||
coherent = false;
|
||||
pastList.Add($"p{i}->0x{rootCell.Portals[i].OtherCellId:X4}(D={SideD(rootCell, i, eye):F3})");
|
||||
}
|
||||
}
|
||||
coh = coherent ? "IN " : "OUT";
|
||||
past = pastList.Count == 0 ? "-" : string.Join(",", pastList);
|
||||
if (!coherent) incoherentFrames++;
|
||||
if (vis.Count <= 1) collapses++;
|
||||
}
|
||||
else
|
||||
{
|
||||
floodStr = "root-missing/0"; stairs = "--"; coh = "?"; past = $"viewer=0x{viewer:X8}";
|
||||
}
|
||||
|
||||
bool viewerFlip = viewer != prevViewer;
|
||||
prevViewer = viewer;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"{x,6:F2} 0x{playerCell:X8} ({eye.X,7:F3},{eye.Y,7:F3},{eye.Z,7:F3}) 0x{viewer:X8} {coh} {floodStr} {stairs} {(viewerFlip ? "FLIP " : "")}{past}"));
|
||||
|
||||
x += speed * dt;
|
||||
}
|
||||
_out.WriteLine($"SUMMARY: collapses(flood<=1)={collapses} incoherentFrames(eye-outside-root)={incoherentFrames}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = savedColl;
|
||||
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||
CameraDiagnostics.TranslationStiffness = savedT;
|
||||
CameraDiagnostics.RotationStiffness = savedR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #181 — the portal-flood admitted set flaps 31↔32 on MICRON eye noise at a
|
||||
/// parked camera (live: launch-176-leakfix.log, root 0x8A020142, vis flips
|
||||
/// every ~100–200 frames for 517k frames; the swept eye carries ~7 µm
|
||||
/// float-roundtrip noise). The flapping cell's visibility-scoped lights + the
|
||||
/// union-AABB scissor rect strobe = the #176 washed-region flicker.
|
||||
///
|
||||
/// This replay hunts the knife edge headlessly: at the live parked eye
|
||||
/// (49.15, −38.62, −3.98), sweep gazes and perturb the eye by ±0.5 mm per
|
||||
/// axis; ANY admitted-set difference at sub-mm perturbation is the #181
|
||||
/// instability, and the symmetric difference names the flapping cell.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue181VisFlapReplayTests
|
||||
{
|
||||
private const uint FacilityHub = 0x8A020000u;
|
||||
private const uint LiveRoot = FacilityHub | 0x0142u;
|
||||
|
||||
private static readonly Vector3 LiveEye = new(49.15f, -38.62f, -3.98f);
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue181VisFlapReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static List<uint> Flood(
|
||||
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
|
||||
buildingMembership: null);
|
||||
var result = new List<uint>(frame.OrderedVisibleCells);
|
||||
result.Sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string CellSetString(IEnumerable<uint> ids)
|
||||
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_FlappingCellViewRegion_SliverOrLarge()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
uint flapper = FacilityHub | 0x0181u;
|
||||
|
||||
// The most unstable pose from the perturbation sweep.
|
||||
float yaw = 15f * MathF.PI / 180f, pitch = -20f * MathF.PI / 180f;
|
||||
var gaze = new Vector3(
|
||||
MathF.Cos(pitch) * MathF.Cos(yaw),
|
||||
MathF.Cos(pitch) * MathF.Sin(yaw),
|
||||
MathF.Sin(pitch));
|
||||
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
foreach (var (label, eye) in new (string, Vector3)[]
|
||||
{
|
||||
("base ", LiveEye),
|
||||
("+0.5mm x ", LiveEye + new Vector3(0.0005f, 0, 0)),
|
||||
("-0.5mm x ", LiveEye - new Vector3(0.0005f, 0, 0)),
|
||||
("+5mm x ", LiveEye + new Vector3(0.005f, 0, 0)),
|
||||
("-5mm x ", LiveEye - new Vector3(0.005f, 0, 0)),
|
||||
("+2cm z ", LiveEye + new Vector3(0, 0, 0.02f)),
|
||||
("-2cm z ", LiveEye - new Vector3(0, 0, 0.02f)),
|
||||
})
|
||||
{
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[LiveRoot], eye, lookup, ViewProjFor(eye, gaze),
|
||||
buildingMembership: null);
|
||||
|
||||
if (!frame.CellViews.TryGetValue(flapper, out var view) || view.IsEmpty)
|
||||
{
|
||||
_out.WriteLine($"{label}: 0181 NOT ADMITTED");
|
||||
continue;
|
||||
}
|
||||
|
||||
// NDC shoelace area of the admitted region (screen is 4.0 NDC-units total).
|
||||
float area = 0f; int polys = 0, verts = 0;
|
||||
foreach (var vp in view.Polygons)
|
||||
{
|
||||
var v = vp.Vertices;
|
||||
polys++; verts += v.Length;
|
||||
float a = 0f;
|
||||
for (int i = 0; i < v.Length; i++)
|
||||
{
|
||||
var p = v[i]; var q = v[(i + 1) % v.Length];
|
||||
a += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
area += MathF.Abs(a) * 0.5f;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"{label}: 0181 admitted polys={polys} verts={verts} ndcArea={area:F4} ({area / 4f * 100f:F1}% of screen)"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_SubMillimeterEyePerturbation_MustNotChangeAdmission()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
Assert.True(cells.ContainsKey(LiveRoot), "live root 0x0142 not loaded");
|
||||
|
||||
// ±0.5 mm per axis — an order of magnitude above the live µm noise, well
|
||||
// below anything visually meaningful.
|
||||
var perturbations = new[]
|
||||
{
|
||||
new Vector3( 0.0005f, 0, 0), new Vector3(-0.0005f, 0, 0),
|
||||
new Vector3(0, 0.0005f, 0), new Vector3(0, -0.0005f, 0),
|
||||
new Vector3(0, 0, 0.0005f), new Vector3(0, 0, -0.0005f),
|
||||
};
|
||||
|
||||
int unstableGazes = 0;
|
||||
for (int yawDeg = 0; yawDeg < 360; yawDeg += 15)
|
||||
{
|
||||
foreach (float pitchDeg in new[] { 0f, -20f, -35f })
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f, pitch = pitchDeg * MathF.PI / 180f;
|
||||
var gaze = new Vector3(
|
||||
MathF.Cos(pitch) * MathF.Cos(yaw),
|
||||
MathF.Cos(pitch) * MathF.Sin(yaw),
|
||||
MathF.Sin(pitch));
|
||||
|
||||
var baseline = Flood(cells, LiveRoot, LiveEye, gaze);
|
||||
foreach (var d in perturbations)
|
||||
{
|
||||
var perturbed = Flood(cells, LiveRoot, LiveEye + d, gaze);
|
||||
if (perturbed.SequenceEqual(baseline)) continue;
|
||||
|
||||
unstableGazes++;
|
||||
var removed = baseline.Except(perturbed).ToList();
|
||||
var added = perturbed.Except(baseline).ToList();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"UNSTABLE yaw={yawDeg} pitch={pitchDeg} d=({d.X * 1000:F1},{d.Y * 1000:F1},{d.Z * 1000:F1})mm base={baseline.Count} pert={perturbed.Count} removed=[{CellSetString(removed)}] added=[{CellSetString(added)}]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"unstable (gaze, perturbation) pairs: {unstableGazes}");
|
||||
// Diagnostic first: report, don't assert — the fix turns this into a hard pin.
|
||||
}
|
||||
}
|
||||
|
|
@ -155,29 +155,12 @@ public class Issue181WallPressEquilibriumTests
|
|||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"viewer cells seen: {cells.Count} transitions={cellTransitions} eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6}) cell=0x{cam.ViewerCellId:X8}"));
|
||||
|
||||
// Orbit structure: 16 consecutive frames at 6dp, with the sweep's
|
||||
// own [flap-sweep] lines captured for the same frames.
|
||||
bool savedFlap = RenderingDiagnostics.ProbeFlapEnabled;
|
||||
var savedOut = Console.Out;
|
||||
try
|
||||
// Orbit structure: 16 consecutive frames at 6dp.
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
RenderingDiagnostics.ProbeFlapEnabled = true;
|
||||
using var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
Step();
|
||||
writer.WriteLine(FormattableString.Invariant(
|
||||
$"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
|
||||
}
|
||||
Console.SetOut(savedOut);
|
||||
foreach (var line in writer.ToString().Split('\n'))
|
||||
if (line.Length > 1) _out.WriteLine(line.TrimEnd());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(savedOut);
|
||||
RenderingDiagnostics.ProbeFlapEnabled = savedFlap;
|
||||
Step();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes
|
||||
/// WB-DIAG to ~9.1M instances/frame. Suspected cause: <see cref="PortalVisibilityBuilder.Build"/>
|
||||
/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding
|
||||
/// (decomp:311878). A dungeon cell has <c>seen_outside==0</c>; retail's PVS for it is just the
|
||||
/// cell's <c>stab_list</c> (<see cref="LoadedCell.VisibleCells"/>) — typically a small bounded
|
||||
/// set. If our flood instead visits ~all cells of the landblock, that is the blowup.
|
||||
///
|
||||
/// This is a DIAGNOSTIC, not a fix: it loads the real 0x0007 interior cells, runs the real
|
||||
/// production flood from representative dungeon-cell roots, and PRINTS the ground-truth numbers —
|
||||
/// flood visited-cell-set size (<see cref="PortalVisibilityFrame.OrderedVisibleCells"/>) vs the
|
||||
/// root's stab_list size (<see cref="LoadedCell.VisibleCells"/>), plus how many visited cells
|
||||
/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue95DungeonFloodDiagnosticTests
|
||||
{
|
||||
private const uint TownNetwork = 0x00070000u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue95DungeonFloodDiagnosticTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
// Production-ish projection (mirrors the sibling harnesses): FovY ~1.2, 1280x720,
|
||||
// near 0.1, far 5000. The flood's clip is near-independent, so exactness is not
|
||||
// load-bearing for cell-count measurement.
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Measure_DungeonFlood_VisibleCellCount()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat dir did not resolve (ACDREAM_DAT_DIR unset and "
|
||||
+ "%USERPROFILE%\\Documents\\Asheron's Call absent). No numbers measured.");
|
||||
// Diagnostic test: do not hard-fail when dats are absent (matches sibling harnesses).
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
}
|
||||
_out.WriteLine($"dat dir resolved: {datDir}");
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// 1) LandBlockInfo header — NumCells for 0x0007.
|
||||
var lbi = dats.Get<DatLandBlockInfo>(TownNetwork | 0xFFFEu);
|
||||
if (lbi is null)
|
||||
{
|
||||
_out.WriteLine($"SKIP: LandBlockInfo 0x{TownNetwork | 0xFFFEu:X8} not found in the dat "
|
||||
+ "(0x0007 may not exist in this client_cell_1.dat).");
|
||||
return;
|
||||
}
|
||||
_out.WriteLine($"=== 0x0007 (Town Network) LandBlockInfo ===");
|
||||
_out.WriteLine($"NumCells (DatLandBlockInfo.NumCells) = {lbi.NumCells}");
|
||||
|
||||
// 2) Load ALL interior cells (sparse ids tolerated — see LoadAllInteriorCells).
|
||||
var loaded = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, TownNetwork);
|
||||
_out.WriteLine($"cells actually loaded = {loaded.Count}");
|
||||
Assert.True(loaded.Count > 0, "no interior cells loaded for 0x0007 — cannot measure");
|
||||
|
||||
Func<uint, LoadedCell?> lookup = id => loaded.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// 3) Per-cell stab_list (VisibleCells) distribution across ALL loaded cells.
|
||||
// This is the bounded retail PVS size we expect the flood to roughly match.
|
||||
var stabSizes = loaded.Values.Select(c => c.VisibleCells.Count).ToList();
|
||||
int seenOutsideCount = loaded.Values.Count(c => c.SeenOutside);
|
||||
int interiorCount = loaded.Count - seenOutsideCount;
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("=== stab_list (LoadedCell.VisibleCells) distribution over ALL loaded cells ===");
|
||||
_out.WriteLine($"cells with SeenOutside==true (entrance/exterior-facing) = {seenOutsideCount}");
|
||||
_out.WriteLine($"cells with SeenOutside==false (interior dungeon) = {interiorCount}");
|
||||
if (stabSizes.Count > 0)
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"VisibleCells.Count min={stabSizes.Min()} max={stabSizes.Max()} avg={stabSizes.Average():F1} sum={stabSizes.Sum()}"));
|
||||
int emptyStab = stabSizes.Count(s => s == 0);
|
||||
_out.WriteLine($"cells with EMPTY stab_list (no dat PVS) = {emptyStab}");
|
||||
|
||||
// 4) Pick representative DUNGEON roots: the first interior (SeenOutside==false) cells in
|
||||
// ascending id order. If none exist, fall back to 0x00070100 and report that.
|
||||
var interiorRoots = loaded
|
||||
.Where(kv => !kv.Value.SeenOutside)
|
||||
.OrderBy(kv => kv.Key)
|
||||
.Select(kv => kv.Value)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
if (interiorRoots.Count == 0)
|
||||
{
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("NOTE: NO cell has SeenOutside==false (all cells see the exterior). "
|
||||
+ "Falling back to root 0x00070100 for the flood measurement.");
|
||||
if (loaded.TryGetValue(TownNetwork | 0x0100u, out var fallback))
|
||||
interiorRoots.Add(fallback);
|
||||
else
|
||||
{
|
||||
_out.WriteLine("WARN: 0x00070100 not loaded either; using the lowest-id loaded cell.");
|
||||
interiorRoots.Add(loaded.OrderBy(kv => kv.Key).First().Value);
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("=== PER-ROOT FLOOD MEASUREMENT (PortalVisibilityBuilder.Build) ===");
|
||||
_out.WriteLine("property read for the visited-cell set: PortalVisibilityFrame.OrderedVisibleCells");
|
||||
_out.WriteLine("root | seenOut | stab(VisibleCells) | flood(OrderedVisibleCells) | crossLB | dir");
|
||||
|
||||
var floodSizes = new List<int>();
|
||||
foreach (var root in interiorRoots)
|
||||
{
|
||||
// Eye at the root cell's world origin, looking toward its first portal (or +X if none),
|
||||
// so the flood actually fires through an opening. Sweep all 6 axis directions and KEEP
|
||||
// the maximum visited-set — the blowup is a worst-case-over-orientation quantity.
|
||||
var eye = root.WorldPosition;
|
||||
int bestFlood = -1;
|
||||
string bestDir = "?";
|
||||
int bestCrossLb = -1;
|
||||
List<uint>? bestVisited = null;
|
||||
|
||||
// Direction candidates: toward each portal's polygon centroid (the natural look-through),
|
||||
// plus the 6 cardinal axes as a fallback sweep.
|
||||
var lookTargets = new List<(Vector3 target, string label)>();
|
||||
for (int pi = 0; pi < root.Portals.Count && pi < root.PortalPolygons.Count; pi++)
|
||||
{
|
||||
var poly = root.PortalPolygons[pi];
|
||||
if (poly is { Length: >= 1 })
|
||||
{
|
||||
var cl = Vector3.Zero;
|
||||
foreach (var v in poly) cl += v;
|
||||
cl /= poly.Length;
|
||||
lookTargets.Add((Vector3.Transform(cl, root.WorldTransform),
|
||||
$"portal{pi}->0x{root.Portals[pi].OtherCellId:X4}"));
|
||||
}
|
||||
}
|
||||
foreach (var (d, lbl) in new (Vector3, string)[]
|
||||
{
|
||||
(Vector3.UnitX, "+X"), (-Vector3.UnitX, "-X"),
|
||||
(Vector3.UnitY, "+Y"), (-Vector3.UnitY, "-Y"),
|
||||
(Vector3.UnitZ, "+Z"), (-Vector3.UnitZ, "-Z"),
|
||||
})
|
||||
lookTargets.Add((eye + d * 5f, lbl));
|
||||
|
||||
foreach (var (target, label) in lookTargets)
|
||||
{
|
||||
if (Vector3.DistanceSquared(target, eye) < 1e-6f) continue;
|
||||
var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, target));
|
||||
int floodN = frame.OrderedVisibleCells.Count;
|
||||
if (floodN > bestFlood)
|
||||
{
|
||||
bestFlood = floodN;
|
||||
bestDir = label;
|
||||
bestVisited = frame.OrderedVisibleCells;
|
||||
bestCrossLb = frame.OrderedVisibleCells.Count(id => (id & 0xFFFF0000u) != TownNetwork);
|
||||
}
|
||||
}
|
||||
|
||||
floodSizes.Add(bestFlood);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"0x{root.CellId:X8} | {(root.SeenOutside ? "Y" : "N"),5} | {root.VisibleCells.Count,18} | {bestFlood,26} | {bestCrossLb,7} | {bestDir}"));
|
||||
|
||||
// For the FIRST root, also print the actual visited set + stab set for eyeballing.
|
||||
if (ReferenceEquals(root, interiorRoots[0]) && bestVisited is not null)
|
||||
{
|
||||
_out.WriteLine(" first-root visited (OrderedVisibleCells, low ids): "
|
||||
+ string.Join(" ", bestVisited.Select(id => $"{id & 0xFFFFu:X4}")));
|
||||
_out.WriteLine(" first-root stab_list (VisibleCells, low ids): "
|
||||
+ string.Join(" ", root.VisibleCells.Select(id => $"{id & 0xFFFFu:X4}")));
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Aggregate flood-size stats across the sampled roots — the headline numbers.
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("=== AGGREGATE over sampled roots ===");
|
||||
if (floodSizes.Count > 0)
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"flood visited-set size (OrderedVisibleCells): min={floodSizes.Min()} max={floodSizes.Max()} avg={floodSizes.Average():F1} (NumCells={lbi.NumCells}, loaded={loaded.Count})"));
|
||||
var sampledStab = interiorRoots.Select(r => r.VisibleCells.Count).ToList();
|
||||
if (sampledStab.Count > 0)
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"sampled roots' stab_list size (VisibleCells): min={sampledStab.Min()} max={sampledStab.Max()} avg={sampledStab.Average():F1}"));
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("INTERPRETATION: if flood max ~= loaded.Count (visits ~all cells) while stab "
|
||||
+ "is small, that is the #95 blowup — the flood is unbounded by the retail stab_list PVS.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +1,21 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// The OUTDOOR render root (R-A2, 2026-06-08): a portal-less <see cref="LoadedCell"/> whose only job is
|
||||
/// to root the render outdoors so the flood seeds OutsideView FULL-SCREEN (terrain draws). Buildings are
|
||||
/// flooded SEPARATELY per-building (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>), so the
|
||||
/// node carries NO reverse portals — the pre-R-A2 reverse-portal unified flood (which oscillated at the
|
||||
/// doorway) is gone. Its tests moved here; the old <c>UnifiedFloodTests</c> was retired.
|
||||
/// </summary>
|
||||
public class OutdoorCellNodeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsPortallessOutdoorRoot()
|
||||
{
|
||||
uint outdoorId = 0xA9B40031;
|
||||
var node = OutdoorCellNode.Build(outdoorId);
|
||||
LoadedCell node = OutdoorCellNode.Build(outdoorId);
|
||||
|
||||
Assert.Equal(outdoorId, node.CellId);
|
||||
Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on
|
||||
Assert.True(node.IsOutdoorNode);
|
||||
Assert.True(node.SeenOutside);
|
||||
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
||||
Assert.Empty(node.Portals); // R-A2: no reverse portals into buildings (buildings flood per-building)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OutdoorRoot_SeedsFullScreenOutsideView_OnlyNodeVisible()
|
||||
{
|
||||
// The load-bearing outdoor-terrain behavior: rooting at the IsOutdoorNode node seeds OutsideView
|
||||
// with the FULL-SCREEN NDC quad, so ClipFrameAssembler yields TerrainMode != Skip and
|
||||
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery everywhere. The portal-less node
|
||||
// resolves to exactly {node} — no interior cells flooded from the root (those come per-building).
|
||||
var node = OutdoorCellNode.Build(0xA9B40031);
|
||||
|
||||
var eye = new Vector3(0, -3, 1);
|
||||
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(node, eye, _ => null, view * proj);
|
||||
|
||||
Assert.Equal(new[] { 0xA9B40031u }, frame.OrderedVisibleCells); // only the node
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
Assert.Equal(-1f, frame.OutsideView.MinX, 3);
|
||||
Assert.Equal(-1f, frame.OutsideView.MinY, 3);
|
||||
Assert.Equal(1f, frame.OutsideView.MaxX, 3);
|
||||
Assert.Equal(1f, frame.OutsideView.MaxY, 3);
|
||||
Assert.Empty(node.Portals);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,999 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class PortalVisibilityBuilderTests
|
||||
{
|
||||
private static Matrix4x4 ViewProj()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
|
||||
{
|
||||
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
|
||||
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
|
||||
};
|
||||
|
||||
// Full-height (y in [-0.9, 0.9]) quad spanning [minX, maxX] in X at plane z.
|
||||
// Used by the multi-back-portal fixture where the X span is the only thing
|
||||
// distinguishing the LEFT and RIGHT apertures.
|
||||
private static Vector3[] QuadX(float minX, float maxX, float z) => new[]
|
||||
{
|
||||
new Vector3(minX, -0.9f, z), new Vector3(maxX, -0.9f, z),
|
||||
new Vector3(maxX, 0.9f, z), new Vector3(minX, 0.9f, z),
|
||||
};
|
||||
|
||||
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
|
||||
{
|
||||
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo>(portals),
|
||||
};
|
||||
|
||||
private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary<uint, LoadedCell> all)
|
||||
=> PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
[Fact]
|
||||
public void Build_ReusedFrame_NestedGraph_MatchesFreshAndStabilizesScratch()
|
||||
{
|
||||
var root = Cell(0x0001,
|
||||
new CellPortalInfo(0x0002, 0, 0, 0),
|
||||
new CellPortalInfo(0x0003, 1, 0, 0));
|
||||
root.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -2f));
|
||||
root.PortalPolygons.Add(QuadX(0.05f, 0.8f, -3f));
|
||||
var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
left.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -5f));
|
||||
var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
right.PortalPolygons.Add(QuadX(0.05f, 0.8f, -5f));
|
||||
var exit = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
exit.PortalPolygons.Add(QuadX(-0.8f, 0.8f, -8f));
|
||||
var cells = new Dictionary<uint, LoadedCell>
|
||||
{
|
||||
[root.CellId] = root,
|
||||
[left.CellId] = left,
|
||||
[right.CellId] = right,
|
||||
[exit.CellId] = exit,
|
||||
};
|
||||
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
|
||||
|
||||
PortalVisibilityFrame fresh = PortalVisibilityBuilder.Build(
|
||||
root, Vector3.Zero, Lookup, ViewProj());
|
||||
uint[] expectedOrder = fresh.OrderedVisibleCells.ToArray();
|
||||
uint[] expectedCells = fresh.CellViews.Keys.OrderBy(id => id).ToArray();
|
||||
int expectedOutsidePolygons = fresh.OutsideView.Polygons.Count;
|
||||
|
||||
var reuse = new PortalVisibilityFrame();
|
||||
PortalVisibilityBuilder.Build(root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
int scratchHighWater = reuse.ClipRegionScratchCount;
|
||||
int vertexArrayHighWater = reuse.PolygonVertexAllocationCount;
|
||||
Assert.True(scratchHighWater > 0);
|
||||
Assert.True(vertexArrayHighWater > 0);
|
||||
|
||||
for (int frame = 0; frame < 16; frame++)
|
||||
{
|
||||
PortalVisibilityFrame actual = PortalVisibilityBuilder.Build(
|
||||
root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, actual);
|
||||
Assert.Equal(expectedOrder, actual.OrderedVisibleCells);
|
||||
Assert.Equal(expectedCells, actual.CellViews.Keys.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(expectedOutsidePolygons, actual.OutsideView.Polygons.Count);
|
||||
Assert.Equal(scratchHighWater, reuse.ClipRegionScratchCount);
|
||||
Assert.Equal(vertexArrayHighWater, reuse.PolygonVertexAllocationCount);
|
||||
Assert.Empty(reuse.TodoScratch);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReusedFrame_AfterLookupException_HasNoStaleWorkOrViews()
|
||||
{
|
||||
var root = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
root.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f));
|
||||
var reuse = new PortalVisibilityFrame();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => PortalVisibilityBuilder.Build(
|
||||
root,
|
||||
Vector3.Zero,
|
||||
_ => throw new InvalidOperationException("fixture lookup failure"),
|
||||
ViewProj(),
|
||||
reuseFrame: reuse));
|
||||
|
||||
var isolatedRoot = Cell(0x0010);
|
||||
PortalVisibilityFrame recovered = PortalVisibilityBuilder.Build(
|
||||
isolatedRoot, Vector3.Zero, _ => null, ViewProj(), reuseFrame: reuse);
|
||||
|
||||
Assert.Same(reuse, recovered);
|
||||
Assert.Equal(new[] { isolatedRoot.CellId }, recovered.OrderedVisibleCells);
|
||||
Assert.Equal(new[] { isolatedRoot.CellId }, recovered.CellViews.Keys);
|
||||
Assert.Empty(recovered.CrossBuildingViews);
|
||||
Assert.True(recovered.OutsideView.IsEmpty);
|
||||
Assert.Empty(reuse.TodoScratch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForBuild_DropsPathologicalContainerHighWater_AfterIdleHysteresis()
|
||||
{
|
||||
const int pathologicalCount =
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity * 4;
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var placeholder = new LoadedCell();
|
||||
|
||||
for (int i = 0; i < pathologicalCount; i++)
|
||||
{
|
||||
uint id = (uint)i + 1u;
|
||||
frame.CellViews.Add(id, new CellView());
|
||||
frame.CrossBuildingViews.Add(id, new CellView());
|
||||
frame.OrderedVisibleCells.Add(id);
|
||||
frame.QueuedScratch.Add(id);
|
||||
frame.DrawListedScratch.Add(id);
|
||||
frame.ProcessedViewCountsScratch.Add(id, i);
|
||||
frame.TodoScratch.Add((placeholder, i));
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
frame.CellViewMapCapacity
|
||||
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.True(
|
||||
frame.QueuedCapacity
|
||||
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
|
||||
frame.ResetForBuild();
|
||||
|
||||
Assert.Empty(frame.CellViews);
|
||||
Assert.Empty(frame.CrossBuildingViews);
|
||||
Assert.Empty(frame.OrderedVisibleCells);
|
||||
Assert.Empty(frame.QueuedScratch);
|
||||
Assert.Empty(frame.DrawListedScratch);
|
||||
Assert.Empty(frame.ProcessedViewCountsScratch);
|
||||
Assert.Empty(frame.TodoScratch);
|
||||
Assert.InRange(
|
||||
frame.RetainedCellViewCount,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.True(
|
||||
frame.CellViewMapCapacity
|
||||
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
|
||||
for (int i = 0; i < PortalVisibilityFrame.CapacityTrimIdleFrames; i++)
|
||||
frame.ResetForBuild();
|
||||
|
||||
Assert.InRange(
|
||||
frame.CellViewMapCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.CrossBuildingViewMapCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.QueuedCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.DrawListedCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.ProcessedViewCountCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.OrderedVisibleCells.Capacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.TodoScratch.Capacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
|
||||
LoadedCell root = Cell(0x1000);
|
||||
PortalVisibilityFrame rebuilt = PortalVisibilityBuilder.Build(
|
||||
root,
|
||||
Vector3.Zero,
|
||||
_ => null,
|
||||
ViewProj(),
|
||||
reuseFrame: frame);
|
||||
|
||||
Assert.Same(frame, rebuilt);
|
||||
Assert.Equal(new[] { root.CellId }, rebuilt.OrderedVisibleCells);
|
||||
Assert.True(rebuilt.CellViews.ContainsKey(root.CellId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForBuild_RecurringLargeFlood_PreservesWarmCapacity()
|
||||
{
|
||||
const int recurringCount =
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity + 163;
|
||||
var frame = new PortalVisibilityFrame();
|
||||
|
||||
for (int iteration = 0;
|
||||
iteration < PortalVisibilityFrame.CapacityTrimIdleFrames + 5;
|
||||
iteration++)
|
||||
{
|
||||
for (int i = 0; i < recurringCount; i++)
|
||||
{
|
||||
uint id = (uint)i + 1u;
|
||||
frame.QueuedScratch.Add(id);
|
||||
frame.OrderedVisibleCells.Add(id);
|
||||
}
|
||||
|
||||
int queuedCapacity = frame.QueuedCapacity;
|
||||
int orderedCapacity = frame.OrderedVisibleCells.Capacity;
|
||||
frame.ResetForBuild();
|
||||
|
||||
Assert.Equal(queuedCapacity, frame.QueuedCapacity);
|
||||
Assert.Equal(orderedCapacity, frame.OrderedVisibleCells.Capacity);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.1f, 1.0f, -3f)); // narrow stairwell
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // wide window
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX;
|
||||
float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth(
|
||||
new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj());
|
||||
Assert.True(outsideWidth < windowOnlyWidth * 0.5f,
|
||||
$"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet()
|
||||
{
|
||||
// Flap root-cause apparatus (2026-06-07): the live threshold flap shows OrderedVisibleCells
|
||||
// flipping 2<->6 at an eye+player identical to cm. Build is a pure static function with
|
||||
// all-fresh per-call state, so identical inputs MUST yield an identical visible set. If this
|
||||
// FAILS, the flap is a determinism bug INSIDE Build; if it PASSES (expected), the live flip is
|
||||
// sub-cm INPUT jitter and the fix must make membership robust, not Build deterministic.
|
||||
// Exercises the re-enqueue fixpoint via a diamond: 0x0004 is reached from BOTH 0x0002 and 0x0003.
|
||||
var cam = Cell(0x0001,
|
||||
new CellPortalInfo(0x0002, 0, 0, 0),
|
||||
new CellPortalInfo(0x0003, 1, 0, 0));
|
||||
cam.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -3f)); // left -> 0002
|
||||
cam.PortalPolygons.Add(QuadX(0.05f, 0.6f, -3f)); // right -> 0003
|
||||
var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
left.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -6f));
|
||||
var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
right.PortalPolygons.Add(QuadX(0.05f, 0.6f, -6f));
|
||||
var back = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
back.PortalPolygons.Add(QuadX(-0.6f, 0.6f, -9f));
|
||||
var all = new Dictionary<uint, LoadedCell>
|
||||
{ [0x0001] = cam, [0x0002] = left, [0x0003] = right, [0x0004] = back };
|
||||
|
||||
var a = Build(cam, all);
|
||||
var b = Build(cam, all);
|
||||
|
||||
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells);
|
||||
Assert.Equal(
|
||||
a.CellViews.Keys.OrderBy(k => k).ToArray(),
|
||||
b.CellViews.Keys.OrderBy(k => k).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_EyeStandingInInteriorPortal_FloodsNeighbour()
|
||||
{
|
||||
// R1 void fix (2026-06-05): when the chase camera roots in a thin doorway cell and the eye is
|
||||
// STANDING IN an interior portal opening, the live capture showed the vestibule->room portal at
|
||||
// D=0.16 m projecting to 0 verts (proj=0), so the neighbour was wrongly culled (cells=1) and
|
||||
// only the thin cell drew -> bluish void. Retail's 3D portal clip imposes no constraint for a
|
||||
// portal the eye is inside, so the neighbour is fully visible. The flood MUST reach the neighbour.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.3f, -0.03f)); // opening 3 cm in front — eye standing in it
|
||||
var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = room };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.CellViews.ContainsKey(0x0002),
|
||||
"eye standing in the doorway must flood the neighbour (degenerate projection was culling it -> void)");
|
||||
Assert.Contains(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion()
|
||||
{
|
||||
// Guard against the fix over-flooding: a portal whose opening the eye is NOT standing in (3 cm
|
||||
// in front but 2 m to the SIDE) also projects degenerate, but the eye is OUTSIDE the opening, so
|
||||
// it must stay culled — otherwise the eye-in-doorway fix would blow up the visible set (#95) by
|
||||
// flooding every degenerate-projecting portal regardless of where the eye actually is.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(2.0f, 0f, 0.3f, 0.3f, -0.03f)); // 2 m to the side — eye NOT in it
|
||||
var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = room };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.False(frame.CellViews.ContainsKey(0x0002),
|
||||
"a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PortalFullyBehindEye_NotFlooded_RetailEmptyClipRule()
|
||||
{
|
||||
// W=0 port (2026-06-11): a portal ENTIRELY behind the eye clips to empty in retail
|
||||
// polyClipFinish (every vertex w < 0 -> <3 survivors -> reject), so the flood does not
|
||||
// reach its neighbour — the cell is off-screen and drawing nothing through it is correct.
|
||||
//
|
||||
// HISTORY: this test used to assert the OPPOSITE (rescue-era pin from a 2026-06-06 cellar
|
||||
// capture, "0174->0175 must flood at 1.4 m behind the camera"). That pinned the
|
||||
// EyeInsidePortalOpening rescue — the documented compensation for ProjectToClip's old
|
||||
// EyePlaneW=1e-4 divergence — not retail. The rescue is deleted with the polyClipFinish
|
||||
// W=0 port (docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md); the live
|
||||
// cellar behaviors are re-verified by the dat-backed replay harnesses + the visual gate.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // entirely behind the eye
|
||||
var stairs = Cell(0x0002);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = stairs };
|
||||
var vp = ViewProj();
|
||||
|
||||
Assert.True(PortalProjection.ProjectToClip(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3,
|
||||
"a fully-behind portal must clip to empty (polyClipFinish part 1)");
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit()
|
||||
{
|
||||
// Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then
|
||||
// a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its
|
||||
// exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves
|
||||
// enough to discover B in the other order.
|
||||
const uint A = 0x0001, B = 0x0002, D = 0x0003;
|
||||
|
||||
var a = Cell(A,
|
||||
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0));
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D
|
||||
|
||||
var b = Cell(B,
|
||||
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D
|
||||
|
||||
var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2));
|
||||
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
|
||||
|
||||
var frame = Build(a, all);
|
||||
|
||||
Assert.Contains(B, frame.OrderedVisibleCells);
|
||||
Assert.Contains(D, frame.OrderedVisibleCells);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell()
|
||||
{
|
||||
// Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as
|
||||
// Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B
|
||||
// through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix).
|
||||
const uint A = 0x0001, B = 0x0002, D = 0x0003;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0));
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f));
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
|
||||
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0),
|
||||
new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f));
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f));
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
|
||||
var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2));
|
||||
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
|
||||
|
||||
var frame = Build(a, all);
|
||||
|
||||
// Membership (the flap-relevant output) is each cell once, regardless of re-pops.
|
||||
Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray());
|
||||
Assert.Contains(D, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 0.1f, 1f, -3f));
|
||||
var inner = Cell(0x0002); // no portals at all
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
|
||||
Assert.True(Build(cam, all).OutsideView.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
var frame = Build(cam, all);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
Assert.True(frame.OutsideView.MaxX - frame.OutsideView.MinX > 0.3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_BackFacingPortal_NotTraversed()
|
||||
{
|
||||
// Portal to 0x0002, but its clip plane puts the camera (origin) on the OUTSIDE.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 0.5f, 0.5f, -3f));
|
||||
cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = -1f, InsideSide = 0 });
|
||||
// dot = (0,0,1)·origin + (-1) = -1 < 0; InsideSide==0 requires dot >= -eps → camera OUTSIDE → skip.
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
Assert.False(frame.CellViews.ContainsKey(0x0002)); // neighbour never reached
|
||||
Assert.True(frame.OutsideView.IsEmpty); // its window never marked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_BackFacingPortal_EyeStandingInOpening_StillCulled()
|
||||
{
|
||||
// R-A2b (Option B1, the flap fix): retail PView::InitCell (:432962) culls a back-facing portal by
|
||||
// the side test REGARDLESS of eye proximity — there is NO eye-in-opening bypass. The live pin
|
||||
// (flap-sidechk.log, 2026-06-09) showed the back portal 0173->0171 with camInterior=False but
|
||||
// eyeIn=True (eye within 1.75 m of the shared doorway); the OLD `&& !eyeInsideOpening` side-cull
|
||||
// bypass let it through -> the 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap.
|
||||
// A back-facing portal the eye is STANDING IN must stay culled (the forward-portal clip-empty void
|
||||
// rescue, tested by Build_EyeStandingInInteriorPortal_FloodsNeighbour, is a separate path and stays).
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -1f)); // 1 m in front -> eyeInsideOpening = True
|
||||
// ClipPlane puts the eye on the EXIT side (camInterior = False), like the back portal of a doorway
|
||||
// just crossed: Normal·origin + D = 1 > 0, InsideSide==1 wants dot<=eps -> not interior.
|
||||
cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1f, InsideSide = 1 });
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0f, 0f, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.False(frame.CellViews.ContainsKey(0x0002),
|
||||
"a back-facing portal (camInterior=False) must stay culled even when the eye is standing in its " +
|
||||
"opening (eyeInsideOpening=True) — retail's side test has no bypass; the bypass WAS the flap cycle");
|
||||
Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CwWoundExitPortal_OutsideRegionIsCcw()
|
||||
{
|
||||
// Exit portal authored CLOCKWISE — the builder must normalize to CCW so downstream stays valid.
|
||||
var cwQuad = new[]
|
||||
{
|
||||
new Vector3(-1, -1, -6), new Vector3(-1, 1, -6), new Vector3(1, 1, -6), new Vector3(1, -1, -6),
|
||||
};
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(cwQuad);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
var frame = Build(cam, all);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
var p = frame.OutsideView.Polygons[0].Vertices;
|
||||
float area2 = 0f;
|
||||
for (int i = 0; i < p.Length; i++) { var a = p[i]; var b = p[(i + 1) % p.Length]; area2 += a.X * b.Y - b.X * a.Y; }
|
||||
Assert.True(area2 > 0f, "clipped OutsideView region should be CCW after winding normalization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CyclicGraph_TerminatesWithBoundedPolys()
|
||||
{
|
||||
// A <-> B cycle; B also has an exit window. Must terminate and not blow up.
|
||||
var a = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
|
||||
var b = Cell(0x0002, new CellPortalInfo(0x0001, 0, 0, 0), new CellPortalInfo(0xFFFF, 1, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // back to A
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // exit window
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = a, [0x0002] = b };
|
||||
|
||||
var frame = Build(a, all); // must return (no infinite loop)
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
Assert.True(frame.OutsideView.Polygons.Count < 256,
|
||||
$"OutsideView poly count {frame.OutsideView.Polygons.Count} — termination/dedup regression guard");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2a: ordered visible-cell list (closest-first) + grow-watermark
|
||||
// fixpoint termination (replaces MaxReprocessPerCell hard cap).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Straight chain A -> B -> C, camera in A looking down -Z. Each onward portal
|
||||
// is progressively farther in -Z so the camera-to-portal distance is monotonic,
|
||||
// forcing the priority queue to dequeue A, then B, then C in that order.
|
||||
private static (LoadedCell[] cells, Dictionary<uint, LoadedCell> lookup) SyntheticChain()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002, C = 0x0003;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f)); // portal A->B at z=-2 (nearer)
|
||||
var b = Cell(B, new CellPortalInfo((ushort)C, 0, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -5f)); // portal B->C at z=-5 (farther)
|
||||
var c = Cell(C, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
c.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -8f)); // exit window
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [C] = c };
|
||||
return (new[] { a, b, c }, all);
|
||||
}
|
||||
|
||||
[Fact] // closest-first ordering
|
||||
public void Build_OrdersVisibleCells_ClosestFirst()
|
||||
{
|
||||
var (cells, lookup) = SyntheticChain();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
Assert.Equal(new uint[] { 0x0001, 0x0002, 0x0003 }, f.OrderedVisibleCells.ToArray());
|
||||
}
|
||||
|
||||
// Hub cell with 4 rooms, each room portal-linked BACK to the hub (a cycle on
|
||||
// every spoke). A naive FIFO with no real fixpoint re-enqueues the hub once per
|
||||
// returning spoke and the rooms once per hub re-process — the watermark must
|
||||
// converge instead, bounding the visible set to {hub + 4 rooms} with no dupes.
|
||||
private static (LoadedCell hub, Dictionary<uint, LoadedCell> lookup) SyntheticCyclicHub()
|
||||
{
|
||||
const uint HUB = 0x0010;
|
||||
uint[] rooms = { 0x0011, 0x0012, 0x0013, 0x0014 };
|
||||
// Hub has one portal to each room; rooms sit at distinct depths so ordering is deterministic.
|
||||
var hub = Cell(HUB,
|
||||
new CellPortalInfo((ushort)rooms[0], 0, 0, 0), new CellPortalInfo((ushort)rooms[1], 1, 0, 0),
|
||||
new CellPortalInfo((ushort)rooms[2], 2, 0, 0), new CellPortalInfo((ushort)rooms[3], 3, 0, 0));
|
||||
for (int i = 0; i < 4; i++)
|
||||
hub.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); // -2,-3,-4,-5
|
||||
var all = new Dictionary<uint, LoadedCell> { [HUB] = hub };
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var room = Cell(rooms[i], new CellPortalInfo((ushort)HUB, 0, 0, 0)); // links back to hub → cycle
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i));
|
||||
all[rooms[i]] = room;
|
||||
}
|
||||
return (hub, all);
|
||||
}
|
||||
|
||||
[Fact] // cyclic graph terminates and bounds the visible set
|
||||
public void Build_CyclicHub_TerminatesAndBounds()
|
||||
{
|
||||
var (hub, lookup) = SyntheticCyclicHub();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
hub, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
Assert.True(f.OrderedVisibleCells.Count <= 5,
|
||||
$"hub + 4 rooms expected, got {f.OrderedVisibleCells.Count} — fixpoint failed to converge");
|
||||
Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2b: reciprocal OtherPortalClip (retail PView::OtherPortalClip
|
||||
// decomp:433524). When a portal leads to a loaded neighbour, the
|
||||
// propagated region must ALSO be clipped against the neighbour's matching
|
||||
// (reciprocal) back-portal polygon — the result is the intersection of the
|
||||
// opening seen from BOTH sides. This can only tighten, never widen.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Camera in A looking down -Z through a WIDE near-side portal (A->B). B's
|
||||
// matching back-portal (B->A) is a NARROW opening at the same plane, so the
|
||||
// reciprocal opening projects to a strictly smaller NDC region. Without the
|
||||
// reciprocal clip, B's CellView equals the wide near-side projection; with
|
||||
// it, B's CellView is bounded by the narrow reciprocal opening.
|
||||
private static (LoadedCell camCell, LoadedCell neighbour, Dictionary<uint, LoadedCell> lookup)
|
||||
SyntheticReciprocalPair()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
// A's portal into B: wide opening (half-width 0.9) at z = -3. Its
|
||||
// reciprocal back-portal lives at index 0 in B (OtherPortalId = 0).
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
|
||||
// B's reciprocal portal back to A: NARROW opening (half-width 0.3),
|
||||
// same height and plane, so it projects fully inside the near-side rect
|
||||
// but covers only ~1/3 of its width.
|
||||
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.9f, -3f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
return (a, b, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AppliesReciprocalOtherPortalClip()
|
||||
{
|
||||
var (camCell, neighbour, lookup) = SyntheticReciprocalPair();
|
||||
var vp = ViewProj();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated");
|
||||
|
||||
// The reciprocal opening's area in NDC: project B's narrow back-portal
|
||||
// polygon and take its (CCW-magnitude) shoelace area. This is the
|
||||
// tightest the neighbour region can be — the propagated region must not
|
||||
// exceed it once both-sides clipping is applied.
|
||||
float reciprocalArea = ProjectedPolygonArea(neighbour.PortalPolygons[0], vp);
|
||||
|
||||
float area = CellViewArea(f.CellViews[0x0002]);
|
||||
const float eps = 1e-4f;
|
||||
Assert.True(area <= reciprocalArea + eps,
|
||||
$"neighbour CellView area {area} must be clipped to the narrower reciprocal opening " +
|
||||
$"{reciprocalArea} (without OtherPortalClip it equals the WIDE near-side projection)");
|
||||
|
||||
// Falsifiability guard: the near-side projection is genuinely WIDER than
|
||||
// the reciprocal, so a no-op clip would have failed the assertion above.
|
||||
float nearSideArea = ProjectedPolygonArea(camCell.PortalPolygons[0], vp);
|
||||
Assert.True(nearSideArea > reciprocalArea * 1.5f,
|
||||
$"fixture sanity: near-side area {nearSideArea} must dominate reciprocal {reciprocalArea}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal()
|
||||
{
|
||||
// Same wide A->B opening, but B has NO portal pointing back to A (data gap).
|
||||
// The reciprocal clip must no-op, so B's CellView equals the WIDE near-side
|
||||
// projection — proving degradation never under-includes (and never throws).
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
|
||||
var b = Cell(B); // no portals at all → no back-portal to match
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
var vp = ViewProj();
|
||||
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(B), "neighbour must still be reached when no back-portal exists");
|
||||
float nearSideArea = ProjectedPolygonArea(a.PortalPolygons[0], vp);
|
||||
float area = CellViewArea(f.CellViews[B]);
|
||||
Assert.True(System.MathF.Abs(area - nearSideArea) < 1e-3f,
|
||||
$"with no reciprocal portal the region must equal the near-side projection " +
|
||||
$"(got {area}, near-side {nearSideArea}) — degrade must not tighten or expand");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2b CRITICAL — multiple portals to the SAME neighbour must each
|
||||
// resolve their OWN reciprocal back-portal (retail arg2->other_portal_id,
|
||||
// decomp:433557), NOT the first OtherCellId match. This is the real
|
||||
// Holtburg-cellar shape: cell 0x148 has two portals to 0x149 (poly 40, 41)
|
||||
// and 0x149 has two reciprocals back to 0x148. A scan-by-first-match clips
|
||||
// BOTH near-side openings against the FIRST reciprocal — if the apertures
|
||||
// are disjoint, the second opening's intersection underflows to empty and
|
||||
// its geometry is HIDDEN (under-inclusion). Direct-index resolution clips
|
||||
// each opening against the matching reciprocal, so both survive.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Camera in A looking down -Z. A has TWO portals to the SAME neighbour B,
|
||||
// with DISJOINT openings: a LEFT aperture (x in [-0.9,-0.3]) and a RIGHT
|
||||
// aperture (x in [0.3,0.9]). B has two reciprocals back to A, one matching
|
||||
// each side. A.Portals[0].OtherPortalId = 0 (B's LEFT reciprocal),
|
||||
// A.Portals[1].OtherPortalId = 1 (B's RIGHT reciprocal).
|
||||
//
|
||||
// Scan-by-first-match resolves BOTH A-portals to B.Portals[0] (LEFT),
|
||||
// so the RIGHT near-side region (x>0) intersects an x<0 reciprocal → empty
|
||||
// → B's CellView never reaches the RIGHT aperture. Direct-index keeps it.
|
||||
private static (LoadedCell camCell, LoadedCell neighbour, Dictionary<uint, LoadedCell> lookup)
|
||||
SyntheticMultiBackPortalPair()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
// A: portal[0] → B through the LEFT opening, portal[1] → B through the RIGHT opening.
|
||||
// The OtherPortalId back-links pick the matching reciprocal in B (0 = LEFT, 1 = RIGHT).
|
||||
var a = Cell(A,
|
||||
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), // LEFT → B recip[0]
|
||||
new CellPortalInfo((ushort)B, PolygonId: 1, Flags: 0, OtherPortalId: 1)); // RIGHT → B recip[1]
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // LEFT near-side aperture
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT near-side aperture
|
||||
|
||||
// B: two reciprocals back to A. Index 0 covers the LEFT opening, index 1
|
||||
// the RIGHT opening — disjoint, same plane (z=-3) as the near side so each
|
||||
// reciprocal exactly overlaps its matching A aperture.
|
||||
var b = Cell(B,
|
||||
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)A, PolygonId: 1, Flags: 0, OtherPortalId: 1));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // reciprocal[0] = LEFT
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // reciprocal[1] = RIGHT
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
return (a, b, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal()
|
||||
{
|
||||
var (camCell, neighbour, lookup) = SyntheticMultiBackPortalPair();
|
||||
var vp = ViewProj();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated");
|
||||
|
||||
// The RIGHT reciprocal opening (B.PortalPolygons[1]) projects to a region
|
||||
// with strictly positive NDC X. Geometry visible through the SECOND opening
|
||||
// survives ONLY if A.Portals[1] was clipped against B's RIGHT reciprocal
|
||||
// (index 1) rather than the LEFT one (index 0).
|
||||
var rightNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[1], Matrix4x4.Identity, vp);
|
||||
float rightMinX = float.MaxValue, rightMaxX = float.MinValue;
|
||||
foreach (var v in rightNdc) { if (v.X < rightMinX) rightMinX = v.X; if (v.X > rightMaxX) rightMaxX = v.X; }
|
||||
Assert.True(rightMinX > 0f, $"fixture sanity: RIGHT reciprocal must project to positive NDC X (got minX {rightMinX})");
|
||||
|
||||
// The neighbour CellView must extend into the RIGHT aperture. With the
|
||||
// scan-by-first-match bug both near-side openings clip against the LEFT
|
||||
// reciprocal (x<0), so the CellView's MaxX stays well left of rightMinX
|
||||
// and this assertion FAILS (the RIGHT opening's geometry is hidden).
|
||||
var bView = f.CellViews[0x0002];
|
||||
Assert.True(bView.MaxX >= rightMinX - 1e-4f,
|
||||
$"neighbour CellView MaxX {bView.MaxX} must reach the RIGHT reciprocal opening " +
|
||||
$"[{rightMinX}, {rightMaxX}] — under scan-by-first-match the second opening is clipped " +
|
||||
$"against the LEFT reciprocal and HIDDEN (under-inclusion bug #102 M-4).");
|
||||
|
||||
// And the LEFT aperture must still be present (the first opening was never
|
||||
// in question) — guards against a fix that accidentally drops the LEFT side.
|
||||
var leftNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[0], Matrix4x4.Identity, vp);
|
||||
float leftMinX = float.MaxValue;
|
||||
foreach (var v in leftNdc) if (v.X < leftMinX) leftMinX = v.X;
|
||||
Assert.True(bView.MinX <= leftMinX + 1e-4f,
|
||||
$"neighbour CellView MinX {bView.MinX} must still cover the LEFT reciprocal opening (minX {leftMinX})");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.4c: an earlier synthetic flap test (Build_NearBoundaryIntermediatePortal)
|
||||
// lived here. It modeled the doorway flap as a BUILDER side-test cull dropping the
|
||||
// exit cell. The live ACDREAM_PROBE_FLAP capture (2026-05-31) DISPROVED that model:
|
||||
// the real cause is the visibility ROOT being driven by the 3rd-person camera EYE —
|
||||
// the eye drifts out of the player's cell, FindCameraCell returns a STALE cell for
|
||||
// its grace frames, and the doorway is then culled as "behind" the eye. The fix is
|
||||
// at the GameWindow integration level (root visibility at the PLAYER's cell:
|
||||
// visRootPos), NOT in the builder — the builder's side test is correct and unchanged,
|
||||
// so the old test asserted a non-bug and was removed rather than left red. The fix is
|
||||
// validated by the visual gate + the [flap] probe (RenderingDiagnostics.ProbeFlapEnabled);
|
||||
// see docs/research/2026-05-31-u4c-flap-characterization.md.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase W Stage 4/5: BFS exit-portal + root-cell ordering guarantees
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Build_ExitPortalVisible_OutsideViewNonEmpty()
|
||||
{
|
||||
// A cell with a single exit portal (OtherCellId == 0xFFFF) whose polygon
|
||||
// projects in front of the camera. The BFS must union the clipped region
|
||||
// into OutsideView, making it non-empty.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); // in front, in frustum
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.OutsideView.Polygons.Count > 0,
|
||||
"An exit portal (OtherCellId=0xFFFF) in front of the camera must populate OutsideView");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
// A cell whose only portal leads to another interior cell (OtherCellId != 0xFFFF).
|
||||
// With no exit portal in any reachable cell the OutsideView must remain empty.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
|
||||
var inner = Cell(0x0002); // no portals — no exit
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.OutsideView.Polygons.Count == 0,
|
||||
"No exit portal in any reachable cell must leave OutsideView empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room },
|
||||
Vector3.Zero,
|
||||
id => id == room.CellId ? room : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Contains(room.CellId, frame.OrderedVisibleCells);
|
||||
Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view));
|
||||
Assert.False(view!.IsEmpty);
|
||||
Assert.True(view.MaxX - view.MinX < 1.0f,
|
||||
"exterior seed should be clipped to the door opening, not full-screen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_ReusedFrame_ClearsPriorOutputAndProducesSameResult()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
var reuse = new PortalVisibilityFrame();
|
||||
LoadedCell? Lookup(uint id) => id == room.CellId ? room : null;
|
||||
|
||||
var first = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, first);
|
||||
Assert.Equal(new[] { room.CellId }, first.OrderedVisibleCells);
|
||||
|
||||
var empty = PortalVisibilityBuilder.BuildFromExterior(
|
||||
Array.Empty<LoadedCell>(), Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, empty);
|
||||
Assert.Empty(empty.OrderedVisibleCells);
|
||||
Assert.Empty(empty.CellViews);
|
||||
Assert.True(empty.OutsideView.IsEmpty);
|
||||
|
||||
var second = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, second);
|
||||
Assert.Equal(new[] { room.CellId }, second.OrderedVisibleCells);
|
||||
Assert.True(second.CellViews.TryGetValue(room.CellId, out CellView? view));
|
||||
Assert.False(view!.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = -1f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room },
|
||||
Vector3.Zero,
|
||||
id => id == room.CellId ? room : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Empty(frame.OrderedVisibleCells);
|
||||
Assert.Empty(frame.CellViews);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_TraversesDeeperInteriorPortals()
|
||||
{
|
||||
var entry = Cell(0x0001,
|
||||
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
||||
new CellPortalInfo(0x0002, 1, 0, 0));
|
||||
entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f));
|
||||
entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f));
|
||||
entry.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 2f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var backRoom = Cell(0x0002);
|
||||
var all = new Dictionary<uint, LoadedCell>
|
||||
{
|
||||
[entry.CellId] = entry,
|
||||
[backRoom.CellId] = backRoom,
|
||||
};
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { entry },
|
||||
Vector3.Zero,
|
||||
id => all.TryGetValue(id, out var c) ? c : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray());
|
||||
Assert.True(frame.CellViews.ContainsKey(0x0002));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal()
|
||||
{
|
||||
var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
nearby.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f));
|
||||
distant.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 199f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { nearby, distant },
|
||||
Vector3.Zero,
|
||||
id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null,
|
||||
ViewProj(),
|
||||
maxSeedDistance: 48f);
|
||||
|
||||
Assert.Contains(nearby.CellId, frame.OrderedVisibleCells);
|
||||
Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
|
||||
{
|
||||
// The camera cell seeds the BFS at distance 0 so it must always pop
|
||||
// first → OrderedVisibleCells[0] == cameraCell.CellId.
|
||||
// Use the three-cell chain: A→B→C with exit window on C.
|
||||
var (cells, lookup) = SyntheticChain();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.NotEmpty(f.OrderedVisibleCells);
|
||||
Assert.Equal(cells[0].CellId, f.OrderedVisibleCells[0]);
|
||||
}
|
||||
|
||||
// Note: Build_CyclicPortalGraph_Terminates is already covered by
|
||||
// Builder_CyclicGraph_TerminatesWithBoundedPolys and Build_CyclicHub_TerminatesAndBounds.
|
||||
// The dedup invariant (each cell appears at most once) is validated by
|
||||
// Build_CyclicHub_TerminatesAndBounds via OrderedVisibleCells.Distinct().Count().
|
||||
|
||||
// Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC.
|
||||
private static float CellViewArea(CellView view)
|
||||
{
|
||||
float total = 0f;
|
||||
foreach (var poly in view.Polygons) total += ShoelaceAbs(poly.Vertices);
|
||||
return total;
|
||||
}
|
||||
|
||||
// Project a cell-local polygon (identity world transform in these fixtures)
|
||||
// to NDC via the same path the builder uses, then take its area magnitude.
|
||||
private static float ProjectedPolygonArea(Vector3[] localPoly, Matrix4x4 vp)
|
||||
{
|
||||
var ndc = PortalProjection.ProjectToNdc(localPoly, Matrix4x4.Identity, vp);
|
||||
return ShoelaceAbs(ndc);
|
||||
}
|
||||
|
||||
private static float ShoelaceAbs(Vector2[] poly)
|
||||
{
|
||||
if (poly == null || poly.Length < 3) return 0f;
|
||||
float area2 = 0f;
|
||||
for (int i = 0; i < poly.Length; i++)
|
||||
{
|
||||
var p = poly[i];
|
||||
var q = poly[(i + 1) % poly.Length];
|
||||
area2 += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
return System.MathF.Abs(area2) * 0.5f;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PortalFrameTestHelper
|
||||
{
|
||||
public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp)
|
||||
{
|
||||
var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp);
|
||||
var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp);
|
||||
return System.MathF.Abs(a.X / a.W - b.X / b.W);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// R-A2 conformance: the per-building flood (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>)
|
||||
/// is the robustness mechanism for the doorway flap. These are SOUNDNESS gates against the measured
|
||||
/// retail values (handoff 2026-06-08 OPTION-A §3.4): a per-building flood touches ≈2 cells and its
|
||||
/// membership is stable under the eye's ~36 µm rest jitter. They prove the per-building path is built
|
||||
/// correctly and does not regress; the live doorway is the visual acceptance test for "the flap is
|
||||
/// gone" (a synthetic fixture cannot reproduce the cottage's exact knife-edge geometry).
|
||||
/// </summary>
|
||||
public class PortalVisibilityRobustnessTests
|
||||
{
|
||||
private static Matrix4x4 ViewProj()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
|
||||
{
|
||||
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
|
||||
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
|
||||
};
|
||||
|
||||
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
|
||||
{
|
||||
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo>(portals),
|
||||
};
|
||||
|
||||
// A realistic 2-cell building viewed from OUTSIDE: a vestibule (0x0170) with an entrance portal to
|
||||
// the outdoors plus an interior portal to a back room (0x0171). The entrance clip plane puts the
|
||||
// origin eye on the EXTERIOR side (D=3, InsideSide=1 → dot=3 > ε), so the exterior seed fires —
|
||||
// exactly the BuildFromExterior seeding contract (mirrors the existing BuildFromExterior_* fixtures).
|
||||
private static (LoadedCell[] cells, Dictionary<uint, LoadedCell> lookup) TwoCellBuilding()
|
||||
{
|
||||
const uint VEST = 0x0170, ROOM = 0x0171;
|
||||
var vest = Cell(VEST,
|
||||
new CellPortalInfo(0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0), // entrance to outdoors
|
||||
new CellPortalInfo((ushort)ROOM, PolygonId: 1, Flags: 0, OtherPortalId: 0)); // interior to room
|
||||
vest.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // entrance opening, in front of eye
|
||||
vest.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); // vestibule -> room, deeper
|
||||
vest.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 3f, InsideSide = 1 });
|
||||
|
||||
var room = Cell(ROOM, new CellPortalInfo((ushort)VEST, PolygonId: 0, Flags: 0, OtherPortalId: 1));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); // reciprocal back to vestibule
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [VEST] = vest, [ROOM] = room };
|
||||
return (new[] { vest, room }, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructViewBuilding_FloodsTheBuildingFromItsEntrance()
|
||||
{
|
||||
var (cells, lookup) = TwoCellBuilding();
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.Contains(0x0170u, frame.OrderedVisibleCells); // entrance cell seeded
|
||||
Assert.Contains(0x0171u, frame.OrderedVisibleCells); // back room reached through the interior portal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructViewBuilding_TouchesAboutTwoCells()
|
||||
{
|
||||
// Conformance to §3.4: each retail per-building flood has cell_draw_num ≈ 2.
|
||||
var (cells, lookup) = TwoCellBuilding();
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.InRange(frame.OrderedVisibleCells.Count, 1, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructViewBuilding_MembershipStableUnderMicrometreEyeJitter()
|
||||
{
|
||||
// Conformance to §3.4: retail's per-building membership is stable while the eye jitters ~36 µm
|
||||
// at rest (measured X≈15 µm, Y≈36 µm, Z≈8 µm). The entrance-bounded seed must return the SAME
|
||||
// OrderedVisibleCells for the eye and the eye + that per-axis jitter — no flap.
|
||||
var (cells, lookup) = TwoCellBuilding();
|
||||
var vp = ViewProj();
|
||||
System.Func<uint, LoadedCell?> lk = id => lookup.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var a = PortalVisibilityBuilder.ConstructViewBuilding(cells, Vector3.Zero, lk, vp);
|
||||
var b = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells, new Vector3(15e-6f, 36e-6f, 8e-6f), lk, vp);
|
||||
|
||||
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells);
|
||||
}
|
||||
}
|
||||
|
|
@ -564,12 +564,8 @@ public sealed class RetailPViewPassExecutorTests
|
|||
var frames = new GpuDeviceFrameLifetime(device);
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope);
|
||||
var diagnostics = new WorldRenderDiagnostics(new NeverCalledGlStateReader(), new NeverCalledDiagnosticLog());
|
||||
|
||||
// DrawPortalDepthWrite only reads _portalDepthMask, frame.Cells,
|
||||
// frame.RootCell.IsOutdoorNode, frame.ViewProjection, and
|
||||
// _diagnostics (short-circuited off by RenderingDiagnostics.
|
||||
// ProbeSeamDrawEnabled's default-off value) — the same
|
||||
// frame.RootCell.IsOutdoorNode, and frame.ViewProjection — the same
|
||||
// constructor-bypass pattern WalkOutsideViewReassemblyTests already
|
||||
// uses for exercising a real leaf without a full GL/DAT renderer
|
||||
// graph.
|
||||
|
|
@ -578,10 +574,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
typeof(RetailPViewPassExecutor)
|
||||
.GetField("_portalDepthMask", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(executor, portalDepthMask);
|
||||
typeof(RetailPViewPassExecutor)
|
||||
.GetField("_diagnostics", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(executor, diagnostics);
|
||||
|
||||
var root = new LoadedCell { CellId = 0xF4180003u, IsOutdoorNode = false };
|
||||
RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset(
|
||||
rootCell: root,
|
||||
|
|
@ -625,21 +617,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
public LoadedCell? Find(uint cellId) => cellId == cell.CellId ? cell : null;
|
||||
}
|
||||
|
||||
private sealed class NeverCalledGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() =>
|
||||
throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state.");
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor() =>
|
||||
throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state.");
|
||||
}
|
||||
|
||||
private sealed class NeverCalledDiagnosticLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public void WriteLine(string message) =>
|
||||
throw new InvalidOperationException("EmitSeamMask must short-circuit before logging (ProbeSeamDrawEnabled defaults off).");
|
||||
}
|
||||
|
||||
private static int RequiredCallIndex(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
Type declaringType,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(false, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -25,7 +25,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new ThrowOnceLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -47,7 +47,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new ThrowOnFirstFrameLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -70,7 +70,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -99,7 +99,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.AccumulateWalkBatch(1_000);
|
||||
|
|
@ -121,7 +121,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
// not depend on AccumulateWalkBatch ever having been called.
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.CompleteWalkFrame(10_000);
|
||||
|
|
@ -135,7 +135,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.AccumulateWalkBatch(5_000);
|
||||
|
|
@ -171,12 +171,6 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class NullGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
|
|
|||
|
|
@ -1,429 +0,0 @@
|
|||
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>
|
||||
/// #119 residual — the tower-ascent harness (the #118 exit-walk pattern,
|
||||
/// vertical). User report (post-#120 build): running UP the AAB3 tower
|
||||
/// (cell 0xAAB30107, building[1] model 0x01001117) the TOP stairs disappear
|
||||
/// visually but stay walkable, and at the top the roof/edges FLAP in and
|
||||
/// out. The tower's upper cells are thin landing/roof slabs with EXIT
|
||||
/// portals (0x0108 z≈121–123, 0x0109 z≈112–114.5, 0x010A z≈126.8–127.2 —
|
||||
/// the roof lip), so the ascent walks the eye across exit-portal planes —
|
||||
/// the same decision-stack territory as #118/#120.
|
||||
///
|
||||
/// Per step of a helix matching the spiral staircase, this drives the
|
||||
/// production stack headlessly: viewer-cell resolution (smallest containing
|
||||
/// AABB; outdoor when none) → PortalVisibilityBuilder.Build (+ the
|
||||
/// per-building exterior floods on outdoor steps, mirroring
|
||||
/// RetailPViewRenderer.MergeNearbyBuildingFloods) → ClipFrameAssembler →
|
||||
/// ViewconeCuller → the DrawCellObjectLists predicate for the staircase
|
||||
/// static (Setup 0x020003F2, ParentCellId 0x0107). The failing steps pin
|
||||
/// the vanish; root/visibility instability across adjacent steps pins the
|
||||
/// flap.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class TowerAscentReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public TowerAscentReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint Landblock = 0xAAB30000u;
|
||||
private const uint MainCell = Landblock | 0x0107u;
|
||||
|
||||
// Tower geometry (Issue119TowerDumpTests dat facts): origin (108,60,112);
|
||||
// the staircase Setup spans local x,y ∈ [-4,4], z ∈ [0,15.5]. Production
|
||||
// EntitySphere = AABB center + half-diagonal.
|
||||
private static readonly Vector3 TowerCenter = new(108f, 60f, 112f);
|
||||
private static readonly Vector3 StairSphereCenter = new(108f, 60f, 119.75f);
|
||||
private const float StairSphereRadius = 9.45f;
|
||||
|
||||
private sealed record AscentStep(
|
||||
int Index,
|
||||
Vector3 Eye,
|
||||
uint RootCellId, // 0 = outdoor
|
||||
bool FloodHasMainCell,
|
||||
bool StairsConeVisible,
|
||||
int OutsidePolys,
|
||||
int FloodCells);
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static uint? ResolveInteriorCellByAabb(
|
||||
Dictionary<uint, LoadedCell> cells, Vector3 worldPoint, float margin = 0.05f)
|
||||
{
|
||||
uint? best = null;
|
||||
float bestVolume = float.MaxValue;
|
||||
foreach (var (id, cell) in cells)
|
||||
{
|
||||
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
|
||||
var min = cell.LocalBoundsMin - new Vector3(margin);
|
||||
var max = cell.LocalBoundsMax + new Vector3(margin);
|
||||
if (local.X < min.X || local.Y < min.Y || local.Z < min.Z
|
||||
|| local.X > max.X || local.Y > max.Y || local.Z > max.Z)
|
||||
continue;
|
||||
var ext = cell.LocalBoundsMax - cell.LocalBoundsMin;
|
||||
float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f);
|
||||
if (volume < bestVolume) { bestVolume = volume; best = id; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Mirror of RetailPViewRenderer.MergeBuildingFrame (union semantics).
|
||||
private static void MergeFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
|
||||
{
|
||||
foreach (uint cellId in src.OrderedVisibleCells)
|
||||
{
|
||||
if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue;
|
||||
if (target.CellViews.TryGetValue(cellId, out var existing))
|
||||
{
|
||||
foreach (var p in srcView.Polygons) existing.Add(p);
|
||||
continue;
|
||||
}
|
||||
target.CellViews[cellId] = srcView;
|
||||
target.OrderedVisibleCells.Add(cellId);
|
||||
}
|
||||
}
|
||||
|
||||
private List<AscentStep> RunAscent()
|
||||
{
|
||||
var datDir = RequireDatDir();
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
Assert.True(cells.ContainsKey(MainCell), "tower main cell not loaded");
|
||||
|
||||
// The tower building's cell list (Issue119TowerDumpTests: building[1]
|
||||
// stabs = 0x0107..0x010A) — the per-building flood candidates on
|
||||
// outdoor-root steps, mirroring GameWindow's gather.
|
||||
var towerCells = new List<LoadedCell>();
|
||||
for (uint low = 0x0107u; low <= 0x010Au; low++)
|
||||
if (cells.TryGetValue(Landblock | low, out var c))
|
||||
towerCells.Add(c);
|
||||
|
||||
// Eye helix matching the spiral: two turns, radius 2.5 m, z from head
|
||||
// height at the base to above the roof lip (cell 0x010A tops at 127.2).
|
||||
const int Steps = 220;
|
||||
const float ZStart = 113.6f, ZEnd = 128.6f;
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var steps = new List<AscentStep>(Steps + 1);
|
||||
|
||||
for (int i = 0; i <= Steps; i++)
|
||||
{
|
||||
float t = i / (float)Steps;
|
||||
float theta = t * 4f * MathF.PI;
|
||||
float z = ZStart + t * (ZEnd - ZStart);
|
||||
var eye = new Vector3(
|
||||
TowerCenter.X + 2.5f * MathF.Cos(theta),
|
||||
TowerCenter.Y + 2.5f * MathF.Sin(theta),
|
||||
z);
|
||||
// look AT the staircase (the user's framing while climbing: the
|
||||
// camera tracks the player on the stairs). An unambiguous pin:
|
||||
// if the staircase cone-culls while looked at directly, the
|
||||
// visibility stack is wrong — no gaze excuse.
|
||||
var gaze = StairSphereCenter - eye;
|
||||
if (gaze.LengthSquared() < 1e-4f) gaze = new Vector3(0f, 0f, -1f);
|
||||
var viewProj = ViewProjFor(eye, eye + Vector3.Normalize(gaze) * 3f);
|
||||
|
||||
uint rootCellId = 0u;
|
||||
PortalVisibilityFrame pv;
|
||||
var contained = ResolveInteriorCellByAabb(cells, eye);
|
||||
if (contained.HasValue)
|
||||
{
|
||||
rootCellId = contained.Value;
|
||||
pv = PortalVisibilityBuilder.Build(cells[rootCellId], eye, lookup, viewProj);
|
||||
}
|
||||
else
|
||||
{
|
||||
// outdoor root: full-screen outside view + the per-building
|
||||
// exterior floods (RetailPViewRenderer.MergeNearbyBuildingFloods)
|
||||
var outdoor = OutdoorCellNode.Build(Landblock | 0x0001u);
|
||||
pv = PortalVisibilityBuilder.Build(outdoor, eye, lookup, viewProj);
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
towerCells, eye, lookup, viewProj);
|
||||
MergeFrame(pv, bf);
|
||||
}
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
|
||||
var cone = ViewconeCuller.Build(asm, viewProj);
|
||||
|
||||
bool floodHasMain = pv.CellViews.ContainsKey(MainCell);
|
||||
// DrawCellObjectLists predicate for a 0x0107 static: sphere vs the
|
||||
// cell's views (RetailPViewRenderer.cs DrawCellObjectLists / T3).
|
||||
bool stairsVisible = cone.SphereVisibleInCell(MainCell, StairSphereCenter, StairSphereRadius);
|
||||
|
||||
steps.Add(new AscentStep(
|
||||
i, eye, rootCellId, floodHasMain, stairsVisible,
|
||||
pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private void DumpSteps(IEnumerable<AscentStep> steps, Func<AscentStep, bool>? filter = null)
|
||||
{
|
||||
foreach (var st in steps)
|
||||
{
|
||||
if (filter is not null && !filter(st)) continue;
|
||||
string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}");
|
||||
string stairs = st.StairsConeVisible ? "VIS " : "CULL";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"step={st.Index,3} eye=({st.Eye.X:F2},{st.Eye.Y:F2},{st.Eye.Z:F2}) root={root} floodMain={st.FloodHasMainCell} stairs={stairs} outPolys={st.OutsidePolys} flood={st.FloodCells}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The vanish pin: the staircase static must stay cone-visible on every
|
||||
/// ascent step (the gaze looks STRAIGHT AT it — no gaze excuse).
|
||||
///
|
||||
/// PINNED RED 2026-06-11 (skip carries the defect; un-skip with the fix):
|
||||
/// steps 195–201 (eye z 126.9–127.3, the roof-lip band between the main
|
||||
/// cell's ceiling at 126.8 and the roof aperture plane at ~127.2) resolve
|
||||
/// OUTDOOR and the per-building exterior flood admits NOTHING (flood=1 =
|
||||
/// the outdoor node alone) — the eye is above every side aperture's
|
||||
/// useful view and ON/INSIDE the roof aperture's plane, so
|
||||
/// BuildFromExterior's seed side-test/in-plane reject refuses every exit
|
||||
/// portal. The tower interior never floods → the staircase (a 0x0107
|
||||
/// static) has no views → culled while walkable, and the roof-lip cell
|
||||
/// geometry flaps as the live eye bobs across the band's edges. Fix
|
||||
/// needs the retail oracle: does retail keep the viewer cell INTERIOR
|
||||
/// through this band (curr_cell keep-curr above open-top cells), or can
|
||||
/// ConstructView(CBldPortal) seed with an in-plane eye?
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void TowerAscent_StaircaseStaysConeVisible_EveryStep()
|
||||
{
|
||||
var steps = RunAscent();
|
||||
var failures = steps.FindAll(s => !s.StairsConeVisible);
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {failures.Count} stairs-CULLED steps ---");
|
||||
DumpSteps(failures);
|
||||
}
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{steps.Count} ascent steps cone-cull the staircase (first at step {(failures.Count > 0 ? failures[0].Index : -1)}) — see output");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The flap pin: along a smooth monotone ascent, the resolved root may
|
||||
/// transition between cells/outdoor but must not PING-PONG (A→B→A within
|
||||
/// three consecutive steps = a knife-edge decision the live camera jitter
|
||||
/// turns into per-frame flapping).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TowerAscent_RootDoesNotPingPong()
|
||||
{
|
||||
var steps = RunAscent();
|
||||
var flips = new List<int>();
|
||||
for (int i = 2; i < steps.Count; i++)
|
||||
{
|
||||
uint a = steps[i - 2].RootCellId, b = steps[i - 1].RootCellId, c = steps[i].RootCellId;
|
||||
if (a != b && b != c && a == c)
|
||||
flips.Add(i - 1);
|
||||
}
|
||||
if (flips.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {flips.Count} root ping-pong steps ---");
|
||||
DumpSteps(steps, s => flips.Contains(s.Index) || flips.Contains(s.Index - 1) || flips.Contains(s.Index + 1));
|
||||
}
|
||||
Assert.True(flips.Count == 0,
|
||||
$"{flips.Count} root ping-pongs along a monotone ascent — knife-edge root decisions (see output)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE captured top-of-stairs vanish (flap-diff-capture.log, live user
|
||||
/// pan): root=0xAAB3010A (the roof-lip cell), eye 3 mm above its floor
|
||||
/// portal plane, gazing DOWN at the staircase — and the flood admitted
|
||||
/// ONLY {010A}: 0x0107 (the whole tower interior, staircase included)
|
||||
/// dropped while being looked straight at. Exact production inputs:
|
||||
/// eye=(301.448,-128.448,126.803) fwd=(-0.9361,-0.2459,-0.2514)
|
||||
/// eye=(301.371,-128.401,126.807) fwd=(-0.8938,-0.3596,-0.2678)
|
||||
/// The 010A→0107 portal is HORIZONTAL at z≈126.8 — the eye rides within
|
||||
/// millimetres of its plane at the stair top, knife-edge projection
|
||||
/// territory (the #120 family's geometry, admission-side).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(301.448f, -128.448f, 126.803f, -0.9361f, -0.2459f, -0.2514f)]
|
||||
[InlineData(301.371f, -128.401f, 126.807f, -0.8938f, -0.3596f, -0.2678f)]
|
||||
public void CapturedTopOfStairs_MainCellStaysInFlood(
|
||||
float ex, float ey, float ez, float fx, float fy, float fz)
|
||||
{
|
||||
var datDir = RequireDatDir();
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The capture is A9B4-anchored world; the loaded cells are AAB3-local
|
||||
// (LoadCell without offset). AAB3 = A9B4 + (1,-1) blocks → local =
|
||||
// world - (192,-192,0).
|
||||
var eye = new Vector3(ex - 192f, ey + 192f, ez);
|
||||
var fwd = Vector3.Normalize(new Vector3(fx, fy, fz));
|
||||
|
||||
// ROOT CAUSE (found via this capture): production fed BuildLoadedCell
|
||||
// the +0.02 m RENDER-LIFTED transform ("keep the small render lift out
|
||||
// of physics", GameWindow:5567) — the visibility graph's HORIZONTAL
|
||||
// portal planes all sat 2 cm high, so an eye standing on the deck
|
||||
// (3 mm above the TRUE plane) was 17 mm BELOW the lifted plane —
|
||||
// outside the side test's ±10 mm in-plane window → 010A→0107
|
||||
// side-culled → the staircase vanished while walkable. FIXED: the
|
||||
// visibility LoadedCell now gets the PHYSICS (unlifted) transform.
|
||||
//
|
||||
// Arm 1 (unlifted = post-fix production): the main cell must be
|
||||
// admitted. Arm 2 (lifted = the old bug, kept as the mechanism
|
||||
// canary): the main cell is dropped — if this arm ever starts
|
||||
// PASSING, the in-plane window has changed enough to absorb a 2 cm
|
||||
// lift; re-evaluate both this canary and the no-lift rule.
|
||||
foreach (bool lift in new[] { false, true })
|
||||
{
|
||||
var armCells = lift ? new Dictionary<uint, LoadedCell>() : cells;
|
||||
if (lift)
|
||||
{
|
||||
var datDir2 = CornerFloodReplayTests.ResolveDatDir()!;
|
||||
using var dats2 = new DatCollection(datDir2, DatAccessType.Read);
|
||||
var lbi = dats2.Get<DatReaderWriter.DBObjs.LandBlockInfo>(Landblock | 0xFFFEu)!;
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||||
{
|
||||
try { armCells[Landblock | low] = CornerFloodReplayTests.LoadCell(dats2, Landblock | low, new Vector3(0f, 0f, 0.02f)); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
Func<uint, LoadedCell?> armLookup = id => armCells.TryGetValue(id, out var c) ? c : null;
|
||||
var root = armCells[Landblock | 0x010Au];
|
||||
|
||||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f })
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fov, 1280f / 720f, 0.1f, 5000f);
|
||||
var pv = PortalVisibilityBuilder.Build(root, eye, armLookup, view * proj);
|
||||
bool hasMain = pv.CellViews.ContainsKey(MainCell);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"lift={lift} fov={fov:F2}: flood={pv.OrderedVisibleCells.Count} hasMain={hasMain}"));
|
||||
if (!lift)
|
||||
Assert.True(hasMain,
|
||||
$"fov={fov:F2}: the tower main cell 0x{MainCell:X8} is NOT in the flood from the roof-lip root (unlifted = post-fix production)");
|
||||
else
|
||||
Assert.False(hasMain,
|
||||
$"fov={fov:F2}: the LIFTED canary unexpectedly admits the main cell — the in-plane window now absorbs a 2 cm lift; re-evaluate the canary + the no-lift rule");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate-by-gate diagnosis of the captured drop: for the 010A→0107 portal
|
||||
/// at the exact captured eye/gaze, print each admission gate's verdict —
|
||||
/// side test (CameraOnInteriorSide replica), homogeneous clip vs the
|
||||
/// full-screen view (ProjectToClip + ClipToRegion, the forward-hop
|
||||
/// pipeline), and the eye-in-opening rescue inputs (perp distance +
|
||||
/// footprint containment).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_TopOfStairs_GateByGate()
|
||||
{
|
||||
var datDir = RequireDatDir();
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
var root = cells[Landblock | 0x010Au];
|
||||
|
||||
// capture eye converted to the cells' AAB3-local frame (world - (192,-192,0))
|
||||
var eye = new Vector3(301.448f - 192f, -128.448f + 192f, 126.803f);
|
||||
var fwd = Vector3.Normalize(new Vector3(-0.9361f, -0.2459f, -0.2514f));
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
|
||||
for (int i = 0; i < root.Portals.Count; i++)
|
||||
{
|
||||
var portal = root.Portals[i];
|
||||
var poly = i < root.PortalPolygons.Count ? root.PortalPolygons[i] : null;
|
||||
_out.WriteLine($"portal[{i}] -> 0x{portal.OtherCellId:X4} polyLen={poly?.Length ?? -1}");
|
||||
if (poly is null || poly.Length < 3) continue;
|
||||
|
||||
// gate 1: side test (CameraOnInteriorSide replica, ε=0.01 in-plane-allowed)
|
||||
string side = "no-plane(allow)";
|
||||
if (i < root.ClipPlanes.Count && root.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
|
||||
{
|
||||
var plane = root.ClipPlanes[i];
|
||||
var localCam = Vector3.Transform(eye, root.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||||
bool pass = plane.InsideSide == 0 ? dot >= -0.01f : dot <= 0.01f;
|
||||
side = FormattableString.Invariant($"dot={dot:F4} insideSide={plane.InsideSide} -> {(pass ? "TRAVERSE" : "CULL")}");
|
||||
}
|
||||
_out.WriteLine($" side: {side}");
|
||||
|
||||
// gate 2: homogeneous clip vs the full-screen view
|
||||
var clip = PortalProjection.ProjectToClip(poly, root.WorldTransform, viewProj);
|
||||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
var region = clip.Length >= 3 ? PortalProjection.ClipToRegion(clip, fullScreen) : Array.Empty<Vector2>();
|
||||
_out.WriteLine($" clip: projVerts={clip.Length} regionVerts={region.Length}");
|
||||
|
||||
// gate 3: rescue inputs — perp distance to the portal plane + the
|
||||
// perpendicular foot inside the opening's footprint
|
||||
var centroid = Vector3.Zero;
|
||||
foreach (var v in poly) centroid += Vector3.Transform(v, root.WorldTransform);
|
||||
centroid /= poly.Length;
|
||||
var n0 = Vector3.Transform(poly[0], root.WorldTransform);
|
||||
var n1 = Vector3.Transform(poly[1], root.WorldTransform);
|
||||
var n2 = Vector3.Transform(poly[2], root.WorldTransform);
|
||||
var planeN = Vector3.Normalize(Vector3.Cross(n1 - n0, n2 - n0));
|
||||
float perp = MathF.Abs(Vector3.Dot(planeN, eye - centroid));
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" rescue: perpDist={perp:F4} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) planeN=({planeN.X:F2},{planeN.Y:F2},{planeN.Z:F2})"));
|
||||
}
|
||||
|
||||
// knife-edge sweep: the probe rounds the eye to mm — sweep z within the
|
||||
// rounding envelope and count clip flips for the 010A→0107 portal. A
|
||||
// chaotic 6↔0 region inside ±2 mm proves the live drop is the same
|
||||
// frame at sub-mm precision.
|
||||
{
|
||||
var poly = root.PortalPolygons[0];
|
||||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
int flips = 0;
|
||||
bool? prev = null;
|
||||
var results = new System.Text.StringBuilder();
|
||||
for (int i = -20; i <= 20; i++)
|
||||
{
|
||||
var e2 = eye with { Z = eye.Z + i * 0.0002f };
|
||||
var v2 = Matrix4x4.CreateLookAt(e2, e2 + fwd * 3f, Vector3.UnitZ);
|
||||
var vp2 = v2 * proj;
|
||||
var clip2 = PortalProjection.ProjectToClip(poly, root.WorldTransform, vp2);
|
||||
var region2 = clip2.Length >= 3 ? PortalProjection.ClipToRegion(clip2, fullScreen) : Array.Empty<Vector2>();
|
||||
bool ok = region2.Length >= 3;
|
||||
if (prev.HasValue && ok != prev.Value) flips++;
|
||||
prev = ok;
|
||||
results.Append(ok ? '1' : '0');
|
||||
}
|
||||
_out.WriteLine($" knife-edge sweep (z ±4 mm, 0.2 mm steps): {results} flips={flips}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Full per-step table for the investigation record.</summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_TowerAscent_PerStepTable()
|
||||
{
|
||||
var steps = RunAscent();
|
||||
DumpSteps(steps);
|
||||
}
|
||||
|
||||
private static string RequireDatDir()
|
||||
=> CornerFloodReplayTests.ResolveDatDir()
|
||||
?? throw new InvalidOperationException(
|
||||
"Installed client_portal.dat is required for the tower-ascent lane.");
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class ViewconeCullerReuseTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReusedCuller_ReplacesPriorCellAndOutsideState()
|
||||
{
|
||||
const uint firstCell = 0xA9B40100;
|
||||
const uint secondCell = 0xA9B40101;
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var assemblyScratch = new ClipFrameAssembly();
|
||||
var culler = new ViewconeCuller();
|
||||
|
||||
var first = Frame(firstCell, includeOutside: true);
|
||||
ClipFrameAssembly firstAssembly = ClipFrameAssembler.Assemble(
|
||||
clipFrame,
|
||||
first,
|
||||
assemblyScratch);
|
||||
Assert.Same(culler, ViewconeCuller.Build(firstAssembly, Matrix4x4.Identity, culler));
|
||||
Assert.True(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f));
|
||||
Assert.True(culler.SphereVisibleOutside(Vector3.Zero, 0f));
|
||||
|
||||
var second = Frame(secondCell, includeOutside: false);
|
||||
ClipFrameAssembly secondAssembly = ClipFrameAssembler.Assemble(
|
||||
clipFrame,
|
||||
second,
|
||||
assemblyScratch);
|
||||
Assert.Same(culler, ViewconeCuller.Build(secondAssembly, Matrix4x4.Identity, culler));
|
||||
|
||||
Assert.False(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f));
|
||||
Assert.True(culler.SphereVisibleInCell(secondCell, Vector3.Zero, 0f));
|
||||
Assert.False(culler.SphereVisibleInCell(secondCell, new Vector3(2f, 0f, 0f), 0f));
|
||||
Assert.False(culler.SphereVisibleOutside(Vector3.Zero, 0f));
|
||||
}
|
||||
|
||||
private static PortalVisibilityFrame Frame(uint cellId, bool includeOutside)
|
||||
{
|
||||
var result = new PortalVisibilityFrame();
|
||||
var cellView = new CellView();
|
||||
cellView.Add(Square(0.5f));
|
||||
result.CellViews.Add(cellId, cellView);
|
||||
result.OrderedVisibleCells.Add(cellId);
|
||||
if (includeOutside)
|
||||
result.OutsideView.Add(Square(0.5f));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ViewPolygon Square(float half)
|
||||
=> new(new[]
|
||||
{
|
||||
new Vector2(-half, -half),
|
||||
new Vector2(half, -half),
|
||||
new Vector2(half, half),
|
||||
new Vector2(-half, half),
|
||||
});
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ public sealed class WalkBuildingInstalledDatCensusTests
|
|||
[Fact]
|
||||
public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
using var dats = new DatCollection(datDir!, DatAccessType.Read);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public sealed class WalkBuildingJoinDiagnosticTests
|
|||
public void Traced_lookin_cells_are_reachable_through_the_building_portal_table(
|
||||
uint buildingCellId, uint[] tracedLookInCells)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ public sealed partial class WalkFrameDriverTests
|
|||
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
||||
|
||||
// Exit seals replay through the exact portal_view slices captured by
|
||||
// this walk. The legacy PortalVisibilityFrame is not consulted.
|
||||
// this walk; there is no independently reconstructed visibility graph.
|
||||
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0));
|
||||
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1));
|
||||
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length);
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ namespace AcDream.App.Tests.Rendering.Walk;
|
|||
/// <see cref="RenderingDiagnostics.DumpWalkTranscriptEnabled"/> and
|
||||
/// <see cref="Console.Out"/> are both process-wide mutable statics — joins
|
||||
/// <see cref="CameraDiagnosticsCollection"/> for the SAME reason
|
||||
/// <c>CornerFloodReplayTests</c>/<c>Issue181WallPressEquilibriumTests</c> do
|
||||
/// (that collection's own doc comment, issue #251): interleaved
|
||||
/// <c>Issue181WallPressEquilibriumTests</c> does (that collection's own doc
|
||||
/// comment, issue #251): interleaved
|
||||
/// <c>Console.SetOut</c> redirection across parallel test classes can
|
||||
/// restore a disposed <see cref="StringWriter"/> process-wide.
|
||||
/// </para>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public sealed class WalkLookInGateSweepTests
|
|||
+ "(the doorway-still fixture tests it in isolation).")]
|
||||
public void Sweep_the_lookin_gate_decodes_against_the_street_fixture()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public sealed class WalkPortalGateDumpTests
|
|||
[Fact]
|
||||
public void Dump_the_gate_inputs_for_a_punching_and_a_silent_building()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
@ -102,7 +102,7 @@ public sealed class WalkPortalGateDumpTests
|
|||
[Fact]
|
||||
public void Dump_the_exit_view_look_in_inputs_for_walkout_f2()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
@ -232,7 +232,7 @@ public sealed class WalkPortalGateDumpTests
|
|||
[Fact]
|
||||
public void Dump_the_foundry_f67_bsp_traversal_for_0036()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public sealed class WalkProductionWorldConformanceTests
|
|||
|
||||
private static DatCollection OpenDats()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
|
|
|||
|
|
@ -453,31 +453,6 @@ public sealed class WalkProductionWorldDataTests
|
|||
Assert.Equal(first.Records.Count, second.Records.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticBucketContains_FindsARegisteredSourceIdInItsRetailCell()
|
||||
{
|
||||
const uint entityId = 0x48A02040u;
|
||||
const uint sourceId = 0x020009A2u;
|
||||
const uint retailCellId = 0xF4180112u;
|
||||
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
Register(shadows, entityId, retailCellId, landblockId: 0xF4180000u);
|
||||
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
RenderProjectionRecord projection =
|
||||
IndoorStaticRecord(entityId, sourceId, retailCellId);
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
|
||||
|
||||
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
|
||||
worldData.BeginFrame(
|
||||
scene.OpenQuery(), 0xF4180000u, renderCenterLbX: 0xF4, renderCenterLbY: 0x18);
|
||||
|
||||
Assert.True(worldData.StaticBucketContains(retailCellId, sourceId));
|
||||
Assert.False(worldData.StaticBucketContains(retailCellId, sourceId + 1));
|
||||
Assert.False(worldData.StaticBucketContains(0xF4180107u, sourceId));
|
||||
}
|
||||
|
||||
private static RenderProjectionRecord Record(uint id, Vector3 position) =>
|
||||
new RenderProjectionRecord() with
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Walk;
|
||||
|
||||
public sealed class WalkRendererArchitectureGuardTests
|
||||
{
|
||||
private static readonly Assembly AppAssembly = typeof(RetailFrameWalk).Assembly;
|
||||
|
||||
private static readonly string[] DeletedGraphTypes =
|
||||
[
|
||||
"PortalVisibilityBuilder",
|
||||
"PortalVisibilityFrame",
|
||||
"ExteriorPortalSeed",
|
||||
"IndoorDrawPlan",
|
||||
"CellDrawEntry",
|
||||
"ViewconeCuller",
|
||||
];
|
||||
|
||||
private static readonly string[] DeletedProbeSymbols =
|
||||
[
|
||||
"ProbeFacilityStairsEnabled",
|
||||
"ACDREAM_PROBE_FACILITY_STAIRS",
|
||||
"ProbeIndoorWalkEnabled",
|
||||
"ProbeIndoorLookupEnabled",
|
||||
"ProbeIndoorUploadEnabled",
|
||||
"ProbeIndoorXformEnabled",
|
||||
"ProbeIndoorCullEnabled",
|
||||
"ACDREAM_PROBE_INDOOR_WALK",
|
||||
"ACDREAM_PROBE_INDOOR_LOOKUP",
|
||||
"ACDREAM_PROBE_INDOOR_UPLOAD",
|
||||
"ACDREAM_PROBE_INDOOR_XFORM",
|
||||
"ACDREAM_PROBE_INDOOR_CULL",
|
||||
"ACDREAM_PROBE_INDOOR_ALL",
|
||||
"ProbeVisibilityEnabled",
|
||||
"ACDREAM_PROBE_VIS",
|
||||
"EmitVis",
|
||||
"ProbeEnvCellEnabled",
|
||||
"ACDREAM_PROBE_ENVCELL",
|
||||
"ProbeFlapEnabled",
|
||||
"ACDREAM_PROBE_FLAP",
|
||||
"ProbePvInputEnabled",
|
||||
"ACDREAM_PROBE_PVINPUT",
|
||||
"ProbeGlStateEnabled",
|
||||
"ACDREAM_PROBE_GLSTATE",
|
||||
"ProbeClipRouteEnabled",
|
||||
"ACDREAM_PROBE_CLIPROUTE",
|
||||
"ProbePortalChurnEnabled",
|
||||
"ACDREAM_PROBE_PORTAL_CHURN",
|
||||
"ProbeIndoorLightEnabled",
|
||||
"ACDREAM_PROBE_INDOOR_LIGHT",
|
||||
"EmitIndoorLight",
|
||||
"ProbeSeamDrawEnabled",
|
||||
"ACDREAM_PROBE_SEAMDRAW",
|
||||
"SeamDrawTargetCells",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void AppAssembly_ContainsNoSupersededPortalGraphTypes()
|
||||
{
|
||||
string[] survivors = AppAssembly.GetTypes()
|
||||
.Where(type => DeletedGraphTypes.Contains(type.Name, StringComparer.Ordinal))
|
||||
.Select(type => type.FullName ?? type.Name)
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(survivors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkFrameOwners_AreUnique()
|
||||
{
|
||||
const BindingFlags Fields = BindingFlags.Instance | BindingFlags.Public |
|
||||
BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||||
|
||||
Type[] sourceTypes = AppAssembly.GetTypes()
|
||||
.Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false))
|
||||
.ToArray();
|
||||
(Type Owner, FieldInfo Field)[] pviewOwners = sourceTypes
|
||||
.SelectMany(type => type.GetFields(Fields).Select(field => (type, field)))
|
||||
.Where(pair => pair.field.FieldType == typeof(WalkPView))
|
||||
.Select(pair => (pair.type, pair.field))
|
||||
.ToArray();
|
||||
Assert.All(pviewOwners, pair => Assert.Equal(typeof(RetailFrameWalk), pair.Owner));
|
||||
Assert.Equal(2, pviewOwners.Length);
|
||||
|
||||
(Type Owner, FieldInfo Field)[] frameWalkOwners = sourceTypes
|
||||
.SelectMany(type => type.GetFields(Fields).Select(field => (type, field)))
|
||||
.Where(pair => pair.field.FieldType == typeof(RetailFrameWalk))
|
||||
.Select(pair => (pair.type, pair.field))
|
||||
.ToArray();
|
||||
(Type Owner, FieldInfo Field) owner = Assert.Single(frameWalkOwners);
|
||||
Assert.Equal(typeof(RetailPViewRenderer), owner.Owner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameTimeWalkOwners_HaveNoRawDatDependency()
|
||||
{
|
||||
Type[] owners =
|
||||
[
|
||||
typeof(RetailFrameWalk),
|
||||
typeof(WalkFrameDriver),
|
||||
typeof(RetailPViewRenderer),
|
||||
typeof(RetailPViewPassExecutor),
|
||||
typeof(WalkProductionLeafRenderer),
|
||||
typeof(WalkStaticStreamPopulator),
|
||||
typeof(WbDrawDispatcher),
|
||||
];
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (Type owner in owners)
|
||||
{
|
||||
const BindingFlags Members = BindingFlags.Instance | BindingFlags.Static |
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||||
foreach (FieldInfo field in owner.GetFields(Members))
|
||||
AddIfRawDat(violations, owner, $"field {field.Name}", field.FieldType);
|
||||
foreach (PropertyInfo property in owner.GetProperties(Members))
|
||||
AddIfRawDat(violations, owner, $"property {property.Name}", property.PropertyType);
|
||||
|
||||
IEnumerable<MethodBase> methods = owner.GetMethods(Members).Cast<MethodBase>()
|
||||
.Concat(owner.GetConstructors(Members));
|
||||
foreach (MethodBase method in methods)
|
||||
{
|
||||
foreach (ParameterInfo parameter in method.GetParameters())
|
||||
{
|
||||
AddIfRawDat(
|
||||
violations,
|
||||
owner,
|
||||
$"parameter {method.Name}.{parameter.Name}",
|
||||
parameter.ParameterType);
|
||||
}
|
||||
|
||||
if (method is MethodInfo methodInfo)
|
||||
AddIfRawDat(violations, owner, $"return {method.Name}", methodInfo.ReturnType);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionSource_ContainsNoSecondPortalGraph()
|
||||
{
|
||||
string[] sources = ProductionSources();
|
||||
string[] banned = [.. DeletedGraphTypes, "ClipFrameAssembler.Assemble"];
|
||||
AssertNoSourceMatches(sources, banned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedWalkStream_HasNoCrossStreamReorder()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Wb", "WbDrawDispatcher.OrderedStream.cs"));
|
||||
|
||||
Assert.DoesNotContain(".Sort(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("OrderBy(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("OrderByDescending(", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionSource_ContainsNoDeletedRendererProbe_AndRetainsWalkTranscriptProof()
|
||||
{
|
||||
AssertNoSourceMatches(ProductionSources(), DeletedProbeSymbols);
|
||||
|
||||
string root = FindRepoRoot();
|
||||
string diagnostics = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.Core", "Rendering", "RenderingDiagnostics.cs"));
|
||||
string transcriptTests = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"tests", "AcDream.App.Tests", "Rendering", "Walk",
|
||||
"WalkFrameDriverTranscriptTests.cs"));
|
||||
Assert.Contains("DumpWalkTranscriptEnabled", diagnostics, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"TranscriptEmitter_FlagOff_EveryPrintMethodIsAZeroCostNoOp",
|
||||
transcriptTests,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Collect_TranscriptFlagOn_PrintedLinesRoundTripThroughTheParser",
|
||||
transcriptTests,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddIfRawDat(
|
||||
ICollection<string> violations,
|
||||
Type owner,
|
||||
string member,
|
||||
Type memberType)
|
||||
{
|
||||
if (ContainsRawDat(memberType))
|
||||
violations.Add($"{owner.FullName} {member}: {memberType}");
|
||||
}
|
||||
|
||||
private static bool ContainsRawDat(Type type)
|
||||
{
|
||||
if (type.IsByRef || type.IsArray || type.IsPointer)
|
||||
return ContainsRawDat(type.GetElementType()!);
|
||||
if (type == typeof(DatCollection) || type == typeof(IDatReaderWriter))
|
||||
return true;
|
||||
return type.IsGenericType && type.GetGenericArguments().Any(ContainsRawDat);
|
||||
}
|
||||
|
||||
private static void AssertNoSourceMatches(IEnumerable<string> sources, IEnumerable<string> banned)
|
||||
{
|
||||
string[] violations = sources
|
||||
.SelectMany(path => banned
|
||||
.Where(symbol => File.ReadAllText(path).Contains(symbol, StringComparison.Ordinal))
|
||||
.Select(symbol => $"{Path.GetRelativePath(FindRepoRoot(), path)}: {symbol}"))
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
private static string[] ProductionSources()
|
||||
{
|
||||
string root = Path.Combine(FindRepoRoot(), "src");
|
||||
return Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
string? directory = AppContext.BaseDirectory;
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
|
||||
return directory;
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -318,7 +318,7 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
Assert.DoesNotContain(
|
||||
"one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Contains("active total to **161**", register, StringComparison.Ordinal);
|
||||
Assert.Contains("current active total to **160**", register, StringComparison.Ordinal);
|
||||
int apSectionStart = register.IndexOf(
|
||||
"## 3. Documented approximation (AP)", StringComparison.Ordinal);
|
||||
int apSectionEnd = register.IndexOf(
|
||||
|
|
@ -342,7 +342,7 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
Assert.Single(File.ReadLines(registerPath), static line =>
|
||||
line.StartsWith("| AP-244 |", StringComparison.Ordinal));
|
||||
Assert.Equal(
|
||||
161,
|
||||
160,
|
||||
File.ReadLines(registerPath).Count(static line =>
|
||||
line.StartsWith("| AP-", StringComparison.Ordinal)));
|
||||
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ public sealed partial class WalkTraceConformanceTests
|
|||
|
||||
private static DatCollection OpenDats()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class WalkWorldDatAdapterTests
|
|||
{
|
||||
private static DatCollection OpenDats()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
|
|
|||
|
|
@ -39,21 +39,13 @@ public class WalkOutsideViewReassemblyTests
|
|||
return view;
|
||||
}
|
||||
|
||||
private static ClipFrameAssembly AssembledWithOldOutside()
|
||||
{
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(new ViewPolygon(new[]
|
||||
{
|
||||
new Vector2(-0.9f, -0.9f), new Vector2(0.9f, -0.9f),
|
||||
new Vector2(0.9f, 0.9f), new Vector2(-0.9f, 0.9f),
|
||||
}));
|
||||
return ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
}
|
||||
private static ClipFrameAssembly BeginAssembly()
|
||||
=> ClipFrameAssembler.BeginWalkFrame(ClipFrame.NoClip(), outdoorRoot: true);
|
||||
|
||||
[Fact]
|
||||
public void PixelDoorway_MapsToExpectedNdcAabbAndPlanes()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
// Pixel quad x∈[256,512], y∈[180,360] → NDC x∈[-0.5,0], y∈[0,0.5]
|
||||
// (yNdc = 1 − 2·py/H flips the axis: py=180 → +0.5, py=360 → 0).
|
||||
WalkPortalView walkView = WalkViewOfPixelQuads(new[]
|
||||
|
|
@ -95,7 +87,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void FullViewportWalkQuad_CoversFullNdc()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
var walkView = new WalkPortalView();
|
||||
Assert.True(WalkCopyView.AppendFullViewportQuad(
|
||||
walkView, new StubRays(), Vector3.Zero, W, H));
|
||||
|
|
@ -109,8 +101,8 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void EmptyWalkView_YieldsSkipMode()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
Assert.True(asm.OutdoorVisible); // the old view was visible pre-cutover
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
|
||||
ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, new WalkPortalView(), W, H);
|
||||
|
||||
|
|
@ -138,7 +130,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
|
||||
var walkView = new WalkPortalView();
|
||||
// View 0: three EXACTLY collinear pixel points -> zero-area triangle,
|
||||
|
|
@ -196,7 +188,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
var walkView = new WalkPortalView();
|
||||
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) });
|
||||
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) });
|
||||
|
|
@ -356,7 +348,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
|
||||
const int n = 9;
|
||||
var verts = new Vector2[n];
|
||||
|
|
@ -387,7 +379,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
WalkPortalView walkView = WalkViewOfPixelQuads(
|
||||
new[]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,194 +1,29 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Vfx;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class WorldRenderDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GlTripwire_DisabledPerformsNoGlRead()
|
||||
{
|
||||
var gl = new RecordingGlStateReader();
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
||||
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: false);
|
||||
|
||||
Assert.Equal(0, gl.StateCaptureCount);
|
||||
Assert.Empty(log.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount()
|
||||
{
|
||||
var gl = new RecordingGlStateReader
|
||||
{
|
||||
State = State(depthFunction: 0x201),
|
||||
};
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
||||
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
||||
gl.State = State(depthFunction: 0x203);
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
||||
|
||||
Assert.Equal(3, gl.StateCaptureCount);
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
first =>
|
||||
{
|
||||
Assert.StartsWith("[gl-state] frame=1 stable=0", first);
|
||||
Assert.Contains("dfunc=0x201", first);
|
||||
},
|
||||
changed =>
|
||||
{
|
||||
Assert.StartsWith("[gl-state] frame=3 stable=1", changed);
|
||||
Assert.Contains("dfunc=0x203", changed);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates()
|
||||
{
|
||||
var gl = new RecordingGlStateReader
|
||||
{
|
||||
Scissor = new RenderGlScissorSnapshot(
|
||||
true,
|
||||
new IntRenderRectangle(1, 2, 3, 4)),
|
||||
};
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
||||
|
||||
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
||||
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
||||
gl.Scissor = new RenderGlScissorSnapshot(
|
||||
true,
|
||||
new IntRenderRectangle(5, 6, 7, 8));
|
||||
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
||||
|
||||
Assert.Equal(3, gl.ScissorCaptureCount);
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
first => Assert.StartsWith("[clip-route-scis] n=1", first),
|
||||
changed => Assert.StartsWith("[clip-route-scis] n=3", changed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners()
|
||||
{
|
||||
FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Type[] forbidden =
|
||||
[
|
||||
typeof(GameWindow),
|
||||
typeof(PortalVisibilityFrame),
|
||||
typeof(ClipFrameAssembly),
|
||||
typeof(InteriorEntityPartition.Result),
|
||||
typeof(AcDream.Core.Physics.PhysicsEngine),
|
||||
typeof(CameraController),
|
||||
typeof(CellVisibility),
|
||||
];
|
||||
foreach (Type type in forbidden)
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == type);
|
||||
Assert.DoesNotContain(
|
||||
fields,
|
||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
}
|
||||
|
||||
// Campaign V slice V11 deleted SilkRenderGlStateReader, the raw-GL
|
||||
// IRenderGlStateReader implementation this test pinned (it was never
|
||||
// constructed in production — NullRenderGlStateReader.Instance is the
|
||||
// sole surviving implementer), so the "consume the entering GL error
|
||||
// before other state reads" source-order tripwire it protected has
|
||||
// nothing left to pin.
|
||||
|
||||
[Fact]
|
||||
public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
|
||||
{
|
||||
var log = new ThrowOnceLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
||||
var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8);
|
||||
var diagnostics = new WorldRenderDiagnostics(log);
|
||||
var facts = new TerrainRenderDiagnosticFacts(3, 3, 5, 8);
|
||||
|
||||
diagnostics.BeginTerrainDraw();
|
||||
diagnostics.EndTerrainDraw();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
diagnostics.PublishTerrainDiagnostics(facts));
|
||||
Assert.Throws<InvalidOperationException>(() => diagnostics.PublishTerrainDiagnostics(facts));
|
||||
|
||||
diagnostics.BeginTerrainDraw();
|
||||
diagnostics.EndTerrainDraw();
|
||||
diagnostics.PublishTerrainDiagnostics(facts);
|
||||
|
||||
Assert.Single(log.Messages);
|
||||
Assert.Contains("draws=3/frame", log.Messages[0]);
|
||||
Assert.Contains("visible=3", log.Messages[0]);
|
||||
Assert.Contains("loaded=5", log.Messages[0]);
|
||||
Assert.Contains("capacity=8", log.Messages[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderSignature_LogsOnlyChangesAndCarriesStableCount()
|
||||
{
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
||||
|
||||
EmitMinimalSignature(diagnostics, "flat");
|
||||
EmitMinimalSignature(diagnostics, "flat");
|
||||
EmitMinimalSignature(diagnostics, "pview");
|
||||
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first),
|
||||
changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed));
|
||||
}
|
||||
|
||||
private static RenderGlStateSnapshot State(int depthFunction) => new(
|
||||
DepthTest: true,
|
||||
DepthWrite: true,
|
||||
DepthFunction: depthFunction,
|
||||
Blend: false,
|
||||
BlendSource: 1,
|
||||
BlendDestination: 0,
|
||||
CullFace: true,
|
||||
CullMode: 0x405,
|
||||
FrontFace: 0x900,
|
||||
Scissor: false,
|
||||
ScissorBox: new IntRenderRectangle(0, 0, 1280, 720),
|
||||
Viewport: new IntRenderRectangle(0, 0, 1280, 720),
|
||||
DrawFramebuffer: 0,
|
||||
AlphaToCoverage: false,
|
||||
Stencil: false,
|
||||
ClipBits: 0,
|
||||
Error: 0);
|
||||
|
||||
private sealed class RecordingGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201);
|
||||
public RenderGlScissorSnapshot Scissor { get; set; }
|
||||
public int StateCaptureCount { get; private set; }
|
||||
public int ScissorCaptureCount { get; private set; }
|
||||
|
||||
public RenderGlStateSnapshot CaptureState()
|
||||
{
|
||||
StateCaptureCount++;
|
||||
return State;
|
||||
}
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor()
|
||||
{
|
||||
ScissorCaptureCount++;
|
||||
return Scissor;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public void WriteLine(string message) => Messages.Add(message);
|
||||
string message = Assert.Single(log.Messages);
|
||||
Assert.Contains("draws=3/frame", message);
|
||||
Assert.Contains("visible=3", message);
|
||||
Assert.Contains("loaded=5", message);
|
||||
Assert.Contains("capacity=8", message);
|
||||
}
|
||||
|
||||
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
||||
|
|
@ -207,33 +42,4 @@ public sealed class WorldRenderDiagnosticsTests
|
|||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EmitMinimalSignature(
|
||||
WorldRenderDiagnostics diagnostics,
|
||||
string branch) =>
|
||||
diagnostics.EmitRenderSignatureIfChanged(
|
||||
enabled: true,
|
||||
branch: branch,
|
||||
clipRoot: null,
|
||||
viewerRoot: null,
|
||||
playerRoot: null,
|
||||
viewerCellId: 0,
|
||||
playerCellId: 0,
|
||||
playerIndoorGate: false,
|
||||
cameraInsideCell: false,
|
||||
renderSkyGate: false,
|
||||
drawSkyThisFrame: false,
|
||||
terrainDrawn: false,
|
||||
terrainClipMode: TerrainClipMode.Skip,
|
||||
skyDrawn: false,
|
||||
depthClear: false,
|
||||
outdoorSceneryDrawn: false,
|
||||
liveDynamicDrawnCount: 0,
|
||||
sceneParticles: "none",
|
||||
visibleCells: null,
|
||||
clipAssembly: null,
|
||||
drawableCells: null,
|
||||
partition: null,
|
||||
cameraPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,6 @@ public sealed class WorldSceneRendererTests
|
|||
"passes:disable-clip",
|
||||
"particles:global",
|
||||
"flat:weather",
|
||||
"diagnostics:signature:OutdoorRoot",
|
||||
"diagnostics:draw",
|
||||
"alpha:end",
|
||||
"visibility:complete",
|
||||
|
|
@ -238,10 +237,8 @@ public sealed class WorldSceneRendererTests
|
|||
"passes:begin",
|
||||
"pview:draw",
|
||||
"visibility:mark",
|
||||
"diagnostics:pview",
|
||||
"passes:disable-clip",
|
||||
"particles:none",
|
||||
"diagnostics:signature:RetailPViewInside",
|
||||
"particles:skipped",
|
||||
"diagnostics:draw",
|
||||
"alpha:end",
|
||||
"visibility:complete",
|
||||
|
|
@ -307,7 +304,7 @@ public sealed class WorldSceneRendererTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
|
||||
public void OutdoorPView_SkipsPostWorldParticleReplayAndFlatWeather()
|
||||
{
|
||||
var root = new LoadedCell
|
||||
{
|
||||
|
|
@ -319,7 +316,7 @@ public sealed class WorldSceneRendererTests
|
|||
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||
|
||||
Assert.True(result.NormalWorldDrawn);
|
||||
Assert.Contains("particles:pviewScoped", rig.Calls);
|
||||
Assert.Contains("particles:skipped", rig.Calls);
|
||||
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||
}
|
||||
|
||||
|
|
@ -498,7 +495,6 @@ public sealed class WorldSceneRendererTests
|
|||
Type[] borrowedTypes =
|
||||
[
|
||||
typeof(RetailPViewFrameResult),
|
||||
typeof(PortalVisibilityFrame),
|
||||
typeof(ClipFrameAssembly),
|
||||
];
|
||||
|
||||
|
|
@ -690,9 +686,7 @@ public sealed class WorldSceneRendererTests
|
|||
new WbFrustum());
|
||||
_clipFrame = ClipFrame.NoClip();
|
||||
|
||||
var glDiagnostics = new WorldRenderDiagnostics(
|
||||
new DefaultGlStateReader(),
|
||||
new NullDiagnosticLog());
|
||||
var glDiagnostics = new WorldRenderDiagnostics(new NullDiagnosticLog());
|
||||
var terrainDiagnostics = new TerrainDrawDiagnosticsController(
|
||||
enabled: false,
|
||||
glDiagnostics,
|
||||
|
|
@ -710,7 +704,6 @@ public sealed class WorldSceneRendererTests
|
|||
particleRenderer: null,
|
||||
portalDepthMask: null,
|
||||
alpha,
|
||||
glDiagnostics,
|
||||
terrainDiagnostics);
|
||||
|
||||
const uint landblockId = 0xF4180000u;
|
||||
|
|
@ -859,13 +852,6 @@ public sealed class WorldSceneRendererTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class DefaultGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class NullDiagnosticLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public void WriteLine(string message) { }
|
||||
|
|
@ -1093,20 +1079,13 @@ public sealed class WorldSceneRendererTests
|
|||
uint? playerLandblockId,
|
||||
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
|
||||
|
||||
public string DrawPostWorldParticles(
|
||||
public void DrawPostWorldParticles(
|
||||
LoadedCell? clipRoot,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
in WorldCameraFrame camera,
|
||||
string currentSignature)
|
||||
in WorldCameraFrame camera)
|
||||
{
|
||||
string kind = clipRoot switch
|
||||
{
|
||||
null => "global",
|
||||
{ IsOutdoorNode: true } => currentSignature,
|
||||
_ => currentSignature,
|
||||
};
|
||||
string kind = clipRoot is null ? "global" : "skipped";
|
||||
calls.Add($"particles:{kind}");
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void DrawFlatWeather(
|
||||
|
|
@ -1135,37 +1114,6 @@ public sealed class WorldSceneRendererTests
|
|||
{
|
||||
public CameraCellResolution CameraCellResolution => CameraCellResolution.None;
|
||||
|
||||
public void EmitPViewInput(
|
||||
IReadOnlySet<uint> visibleCells,
|
||||
int outsideViewCount,
|
||||
Matrix4x4 viewProjection,
|
||||
LoadedCell clipRoot,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition) => calls.Add("diagnostics:pview");
|
||||
|
||||
public void EmitRenderSignature(
|
||||
string branch,
|
||||
LoadedCell? clipRoot,
|
||||
LoadedCell? viewerRoot,
|
||||
LoadedCell? playerRoot,
|
||||
uint viewerCellId,
|
||||
uint playerCellId,
|
||||
bool playerIndoorGate,
|
||||
bool cameraInsideCell,
|
||||
bool renderSky,
|
||||
bool drawSkyThisFrame,
|
||||
bool terrainDrawn,
|
||||
TerrainClipMode terrainClipMode,
|
||||
bool skyDrawn,
|
||||
bool depthClear,
|
||||
bool outdoorSceneryDrawn,
|
||||
int liveDynamicDrawnCount,
|
||||
string sceneParticles,
|
||||
RetailPViewFrameResult? pviewResult,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition) =>
|
||||
calls.Add($"diagnostics:signature:{branch}");
|
||||
|
||||
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||
in WorldCameraFrame camera,
|
||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||
|
|
|
|||
|
|
@ -5,175 +5,24 @@ namespace AcDream.Core.Tests.Rendering;
|
|||
|
||||
public sealed class RenderingDiagnosticsTests
|
||||
{
|
||||
// Each flag-mutating test snapshots the IndoorAll state on entry and
|
||||
// restores it via try/finally. RenderingDiagnostics is a process-wide
|
||||
// static (env-var-initialized); without restoration a mutated state
|
||||
// leaks into other tests + into parallel test runs. Mirrors the
|
||||
// PhysicsDiagnosticsTests pattern at line 30-49.
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_True_TurnsAllFlagsOn()
|
||||
{
|
||||
bool initial = RenderingDiagnostics.IndoorAll;
|
||||
try
|
||||
{
|
||||
// Reset all flags off first to make the test deterministic
|
||||
// regardless of env-var state on the test runner.
|
||||
RenderingDiagnostics.ProbeIndoorWalkEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorLookupEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorUploadEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorXformEnabled = false;
|
||||
RenderingDiagnostics.ProbeIndoorCullEnabled = false;
|
||||
|
||||
RenderingDiagnostics.IndoorAll = true;
|
||||
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled);
|
||||
Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled);
|
||||
Assert.True(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = initial;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_False_TurnsAllFlagsOff()
|
||||
{
|
||||
bool initial = RenderingDiagnostics.IndoorAll;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = true; // start from all-on
|
||||
RenderingDiagnostics.IndoorAll = false;
|
||||
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled);
|
||||
Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled);
|
||||
Assert.False(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = initial;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorAll_OneOff_ReadsAsFalse()
|
||||
{
|
||||
bool initial = RenderingDiagnostics.IndoorAll;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = true;
|
||||
RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off
|
||||
Assert.False(RenderingDiagnostics.IndoorAll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.IndoorAll = initial;
|
||||
}
|
||||
}
|
||||
[Theory]
|
||||
[InlineData(0x00000029ul, false)]
|
||||
[InlineData(0xA9B40029ul, false)]
|
||||
[InlineData(0x00000100ul, true)]
|
||||
[InlineData(0xA9B40105ul, true)]
|
||||
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
|
||||
=> Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid
|
||||
[InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix
|
||||
[InlineData(0x00000100ul, true)] // indoor cell minimum
|
||||
[InlineData(0x00000105ul, true)] // typical Holtburg Inn interior
|
||||
[InlineData(0xA9B40105ul, true)] // indoor with landblock prefix
|
||||
[InlineData(0xA9B401FFul, true)] // indoor near top of range
|
||||
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
|
||||
}
|
||||
|
||||
// ── Render inside/outside branch (retail RenderNormalMode is_player_outside) ──
|
||||
// The top-level render branch decides DrawInside vs DrawOutside. Retail
|
||||
// (SmartBox::RenderNormalMode 0x453aa0:92665) keys it on is_player_outside (the
|
||||
// PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the
|
||||
// camera cell, so a chase camera lagging in a doorway while the player was already
|
||||
// outside took the DrawInside path and degenerated to a grey world + entities showing
|
||||
// through walls. These pin the player-keyed branch and loaded player-root requirement.
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse()
|
||||
{
|
||||
// THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the
|
||||
// chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor.
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue()
|
||||
{
|
||||
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerInside_RootNotLoaded_ReturnsFalse()
|
||||
{
|
||||
// Opposite lag (camera pulled outside while the player is inside): no viewer cell to
|
||||
// root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior.
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse()
|
||||
{
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse()
|
||||
{
|
||||
// playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render).
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbePortalChurn_DefaultsFalse_WhenEnvUnset()
|
||||
{
|
||||
// Env var is absent in the test host, so the flag must default false (inert probe).
|
||||
Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled);
|
||||
}
|
||||
|
||||
// ── ACDREAM_DUMP_ENTITY parser (#119 decisive probe) ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseDumpEntityIds_NullOrEmpty_ReturnsEmptySet()
|
||||
{
|
||||
Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(null));
|
||||
Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(""));
|
||||
Assert.Empty(RenderingDiagnostics.ParseDumpEntityIds(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDumpEntityIds_CommaSeparatedWithPrefixes_ParsesAll()
|
||||
{
|
||||
var set = RenderingDiagnostics.ParseDumpEntityIds("0x020003F2,0x020005D8");
|
||||
Assert.Equal(2, set.Count);
|
||||
Assert.Contains(0x020003F2u, set);
|
||||
Assert.Contains(0x020005D8u, set);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDumpEntityIds_NoPrefixMixedCaseAndWhitespace_Parses()
|
||||
{
|
||||
var set = RenderingDiagnostics.ParseDumpEntityIds(" 020003f2 , 0X020005D8 ");
|
||||
Assert.Equal(2, set.Count);
|
||||
Assert.Contains(0x020003F2u, set);
|
||||
Assert.Contains(0x020005D8u, set);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDumpEntityIds_MalformedSegment_IgnoredNotFatal()
|
||||
{
|
||||
// A typo'd segment must not take the launch down — parse what's valid.
|
||||
var set = RenderingDiagnostics.ParseDumpEntityIds("0x020003F2,zzz,,0x");
|
||||
Assert.Single(set);
|
||||
Assert.Contains(0x020003F2u, set);
|
||||
}
|
||||
[InlineData(0xA9B40031u, true, false)]
|
||||
[InlineData(0xA9B40171u, true, true)]
|
||||
[InlineData(0xA9B40171u, false, false)]
|
||||
[InlineData(0u, true, false)]
|
||||
public void ShouldRenderIndoor_UsesPlayerCellAndResolvedRoot(
|
||||
uint playerCellId,
|
||||
bool renderRootResolved,
|
||||
bool expected)
|
||||
=> Assert.Equal(
|
||||
expected,
|
||||
RenderingDiagnostics.ShouldRenderIndoor(playerCellId, renderRootResolved));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
// Phase A8 — visibility probe flag tests.
|
||||
// Phase U.2d (2026-05-30) — EmitVis formatter + cell-change gating tests.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AcDream.Core.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering;
|
||||
|
||||
public class RenderingDiagnosticsVisibilityTests
|
||||
{
|
||||
// RenderingDiagnostics is a process-wide static (env-var-initialized) and
|
||||
// EmitVis keeps a private last-root-cell field. Both leak between tests +
|
||||
// parallel runs if not restored, and this codebase has documented
|
||||
// static-leak flakiness — so every test here snapshots ProbeVisibilityEnabled,
|
||||
// resets the cell tracker, and restores in finally. Mirrors the
|
||||
// RenderingDiagnosticsTests / PhysicsDiagnosticsTests pattern.
|
||||
|
||||
[Fact]
|
||||
public void ProbeVisibilityEnabled_CanBeToggled()
|
||||
{
|
||||
var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = true;
|
||||
Assert.True(RenderingDiagnostics.ProbeVisibilityEnabled);
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = false;
|
||||
Assert.False(RenderingDiagnostics.ProbeVisibilityEnabled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = prev;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeVisibilityEnabled_DefaultsToEnvVarPresence()
|
||||
{
|
||||
// The static initializer reads ACDREAM_PROBE_VIS == "1". Reconstruct the
|
||||
// documented default from the current env and assert the property's
|
||||
// *defined* default matches it. (We can't re-run the static initializer,
|
||||
// but the initial value is a pure function of the env var, so this pins
|
||||
// the documented contract without depending on the runner's env.)
|
||||
bool expected = Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1";
|
||||
|
||||
var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.ResetVisibilityProbeForTests();
|
||||
// Drive the property to the env-derived default explicitly, then read
|
||||
// it back — this asserts the property faithfully stores the env value
|
||||
// (the same expression the field initializer uses).
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = expected;
|
||||
Assert.Equal(expected, RenderingDiagnostics.ProbeVisibilityEnabled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = prev;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitVis_NoOp_WhenProbeDisabled()
|
||||
{
|
||||
var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
|
||||
var prevOut = Console.Out;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.ResetVisibilityProbeForTests();
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = false;
|
||||
|
||||
using var sw = new StringWriter();
|
||||
Console.SetOut(sw);
|
||||
|
||||
RenderingDiagnostics.EmitVis(
|
||||
rootCellId: 0xA9B40105u,
|
||||
visibleCells: new uint[] { 0xA9B40105u, 0xA9B40164u },
|
||||
outsidePolyCount: 1,
|
||||
outsidePlaneCount: 4,
|
||||
perCellPlaneCounts: new Dictionary<uint, int> { [0xA9B40105u] = 0, [0xA9B40164u] = 4 },
|
||||
scissorFallbacks: 1);
|
||||
|
||||
Console.SetOut(prevOut);
|
||||
Assert.Equal(string.Empty, sw.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(prevOut);
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = prev;
|
||||
RenderingDiagnostics.ResetVisibilityProbeForTests();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitVis_FiresOnceOnNewRoot_SuppressedOnUnchangedRoot()
|
||||
{
|
||||
var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
|
||||
var prevOut = Console.Out;
|
||||
try
|
||||
{
|
||||
RenderingDiagnostics.ResetVisibilityProbeForTests();
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = true;
|
||||
|
||||
using var sw = new StringWriter();
|
||||
Console.SetOut(sw);
|
||||
|
||||
var cells = new uint[] { 0xA9B40105u, 0xA9B40164u };
|
||||
var perCell = new Dictionary<uint, int> { [0xA9B40105u] = 0, [0xA9B40164u] = 4 };
|
||||
|
||||
// First call on a fresh root → fires.
|
||||
RenderingDiagnostics.EmitVis(0xA9B40105u, cells, 1, 4, perCell, 0);
|
||||
// Same root again → suppressed (no-op).
|
||||
RenderingDiagnostics.EmitVis(0xA9B40105u, cells, 1, 4, perCell, 0);
|
||||
// New root → fires again.
|
||||
RenderingDiagnostics.EmitVis(0xA9B40164u, cells, 2, 8, perCell, 1);
|
||||
|
||||
Console.SetOut(prevOut);
|
||||
|
||||
string output = sw.ToString();
|
||||
string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Exactly two [vis] lines: one per distinct root transition.
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.All(lines, l => Assert.StartsWith("[vis]", l.TrimStart()));
|
||||
Assert.Contains("root=0xA9B40105", lines[0]);
|
||||
Assert.Contains("root=0xA9B40164", lines[1]);
|
||||
|
||||
// Information-density spot checks on the first line.
|
||||
Assert.Contains("cells=2", lines[0]);
|
||||
Assert.Contains("0xA9B40105", lines[0]);
|
||||
Assert.Contains("0xA9B40164", lines[0]);
|
||||
Assert.Contains("polys=1", lines[0]);
|
||||
Assert.Contains("planes=4", lines[0]);
|
||||
Assert.Contains("fallbacks=0", lines[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(prevOut);
|
||||
RenderingDiagnostics.ProbeVisibilityEnabled = prev;
|
||||
RenderingDiagnostics.ResetVisibilityProbeForTests();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue