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
|
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-
|
sunlight and indoor lighting. A null root exists only for login/debug/streaming-
|
||||||
gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are
|
gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are
|
||||||
composition/pass facades, and `PortalVisibilityBuilder` has no production
|
composition/pass facades. The superseded `PortalVisibilityBuilder`,
|
||||||
caller. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render
|
`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:**
|
and physics cross-cell membership one canonical owner:**
|
||||||
`ShadowObjectRegistry` computes retail's exact CELLARRAY
|
`ShadowObjectRegistry` computes retail's exact CELLARRAY
|
||||||
(`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per
|
(`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
|
`docs/architecture/worldbuilder-inventory.md`'s "Retail static render-shadow
|
||||||
cell membership" / "Consumer cutover to a borrowed per-cell view" entries.
|
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,
|
ordered event stream for landscape cells, building portals, EnvCell shells,
|
||||||
statics, dynamics, particles, depth boundaries, and alpha barriers, then
|
statics, dynamics, particles, depth boundaries, and alpha barriers, then
|
||||||
`WalkFrameDriver` replays it through the Vulkan leaves. Private portal/
|
`WalkFrameDriver` replays it through the Vulkan leaves. Private portal/
|
||||||
paperdoll viewports and retained UI execute after the world stream, and
|
paperdoll viewports and retained UI execute after the world stream, and
|
||||||
projectiles remain ordinary live-entity draws. Three current details are
|
projectiles remain ordinary live-entity draws. Campaign OVERHAUL S3/S4 landed
|
||||||
explicit Campaign OVERHAUL divergences, not accepted retail contracts:
|
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
|
||||||
- terrain is staged before all per-cell building/object turns instead of
|
CLIP and ALPHA) drained at their exact barriers. The remaining explicit deviations
|
||||||
retail's `DrawLandCell` then `DrawSortCell` interleave for each land cell;
|
are AP-238 (EnvCell token granularity), AP-239 (collapsed raw surface-mask
|
||||||
- depth clear/seal events are emitted for zero-outside-view roots and do not
|
origin), AP-240 (ordinary Gfx/building pure-Clip immediate A2C placement),
|
||||||
consume retail's persistent previous-call `uint16 portalsDrawnCount` gate;
|
AP-241 (opaque object and row-5 particle GPU channels), AP-242 (equal-key
|
||||||
- ordinary object and particle parts now receive retail's stable per-cell
|
cross-source tie ordinal), and AP-243 (retail's >50 m shared-cell-key
|
||||||
authored-CYpt order before entering the two FIFO mesh lists, which drain
|
optimization). They are bounded residuals, not a second visibility or ordering
|
||||||
CLIP before ALPHA at exact barriers. The retained differences are AP-241
|
model.
|
||||||
(opaque/row-5 GPU channels), AP-242 (equal cross-source tie ordinal), and
|
|
||||||
AP-243 (retail's >50 m shared-cell-key optimization).
|
|
||||||
|
|
||||||
The exact target and evidence are recorded in
|
The exact target and evidence are recorded in
|
||||||
`docs/research/2026-09-01-overhaul/oh1-retail-world-contract.md`; OH5, OH6, and
|
`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
|
Every distinct entity id this happens for in one frame is counted once in
|
||||||
`WalkProductionWorldData.UnregisteredRenderMembershipCount` (renamed from
|
`WalkProductionWorldData.UnregisteredRenderMembershipCount` (renamed from
|
||||||
`UnregisteredStaticRenderFallbackCount`, which under the pre-chunk-5 model
|
`UnregisteredStaticRenderFallbackCount`, which under the pre-chunk-5 model
|
||||||
also covered the separate dynamic-record fallback) and reported by one
|
also covered the separate dynamic-record fallback). The count remains a cheap
|
||||||
print-only `[walk-membership]` line — gated on
|
per-frame invariant available to tests and diagnostics consumers; Campaign
|
||||||
`RenderingDiagnostics.ProbeFacilityStairsEnabled`, the walk family's
|
OVERHAUL S5-c5 deleted its spent print-only Facility probe and next-frame log.
|
||||||
existing diagnostic flag — at the start of the next frame when nonzero.
|
|
||||||
A primitive-only Setup may reuse its already-authored cylsphere collision
|
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
|
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
|
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_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_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_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_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_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` |
|
| `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_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_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_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_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` | `=<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` |
|
| `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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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` |
|
| `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_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_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_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_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_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 |
|
| `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.
|
// Campaign V slice V6j: the world scene is composed on BOTH arms.
|
||||||
// Every renderer below now exists on Vulkan too; what forks is one
|
// 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 =
|
WorldRenderDiagnostics worldRenderDiagnostics =
|
||||||
host.WorldRenderDiagnostics
|
new(d.RenderDiagnosticLog);
|
||||||
?? new WorldRenderDiagnostics(
|
|
||||||
NullRenderGlStateReader.Instance,
|
|
||||||
d.RenderDiagnosticLog);
|
|
||||||
IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance;
|
IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance;
|
||||||
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
|
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
|
||||||
IWorldPassSurface worldPassSurface = new RhiWorldPassSurface(
|
IWorldPassSurface worldPassSurface = new RhiWorldPassSurface(
|
||||||
|
|
@ -445,10 +438,8 @@ internal sealed class FrameRootCompositionPhase
|
||||||
live.ParticleRenderer,
|
live.ParticleRenderer,
|
||||||
live.PortalDepthMask,
|
live.PortalDepthMask,
|
||||||
d.RetailAlphaQueue,
|
d.RetailAlphaQueue,
|
||||||
worldRenderDiagnostics,
|
|
||||||
terrainDrawDiagnostics);
|
terrainDrawDiagnostics);
|
||||||
var worldSceneDiagnostics = new WorldSceneDiagnosticsController(
|
var worldSceneDiagnostics = new WorldSceneDiagnosticsController(
|
||||||
worldRenderDiagnostics,
|
|
||||||
new RuntimeWorldScenePViewDiagnosticSource(
|
new RuntimeWorldScenePViewDiagnosticSource(
|
||||||
d.PlayerController,
|
d.PlayerController,
|
||||||
d.PhysicsEngine,
|
d.PhysicsEngine,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ internal sealed record HostInputCameraResult(
|
||||||
IRenderFrameSlotSource FrameSlots,
|
IRenderFrameSlotSource FrameSlots,
|
||||||
IGpuDevice GpuDevice,
|
IGpuDevice GpuDevice,
|
||||||
GpuDeviceFrameLifetime GpuFrameLifetime,
|
GpuDeviceFrameLifetime GpuFrameLifetime,
|
||||||
WorldRenderDiagnostics? WorldRenderDiagnostics,
|
|
||||||
SilkKeyboardSource? KeyboardSource,
|
SilkKeyboardSource? KeyboardSource,
|
||||||
SilkMouseSource? MouseSource,
|
SilkMouseSource? MouseSource,
|
||||||
IMouseLookCursor? MouseLookCursor,
|
IMouseLookCursor? MouseLookCursor,
|
||||||
|
|
@ -86,11 +85,6 @@ internal interface IHostInputCameraCompositionFactory
|
||||||
GpuFrameFlightController? frameFlights,
|
GpuFrameFlightController? frameFlights,
|
||||||
IGpuDevice device);
|
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(
|
SilkKeyboardSource CreateKeyboardSource(
|
||||||
IKeyboard keyboard,
|
IKeyboard keyboard,
|
||||||
HostQuiescenceGate quiescence);
|
HostQuiescenceGate quiescence);
|
||||||
|
|
@ -250,11 +244,6 @@ internal sealed class HostInputCameraCompositionPhase :
|
||||||
var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
|
var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
|
||||||
_publication.PublishGpuFrameLifetime(gpuFrameLifetime);
|
_publication.PublishGpuFrameLifetime(gpuFrameLifetime);
|
||||||
|
|
||||||
WorldRenderDiagnostics? diagnostics =
|
|
||||||
_factory.CreateWorldRenderDiagnostics(
|
|
||||||
graphics,
|
|
||||||
_dependencies.RenderDiagnosticLog);
|
|
||||||
|
|
||||||
IKeyboard? firstKeyboard = input.Keyboards.FirstOrDefault();
|
IKeyboard? firstKeyboard = input.Keyboards.FirstOrDefault();
|
||||||
IMouse? firstMouse = input.Mice.FirstOrDefault();
|
IMouse? firstMouse = input.Mice.FirstOrDefault();
|
||||||
SilkKeyboardSource? keyboard = null;
|
SilkKeyboardSource? keyboard = null;
|
||||||
|
|
@ -356,7 +345,6 @@ internal sealed class HostInputCameraCompositionPhase :
|
||||||
frameSlots,
|
frameSlots,
|
||||||
gpuDevice,
|
gpuDevice,
|
||||||
gpuFrameLifetime,
|
gpuFrameLifetime,
|
||||||
diagnostics,
|
|
||||||
keyboard,
|
keyboard,
|
||||||
mouse,
|
mouse,
|
||||||
cursor,
|
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
|
/// <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
|
/// device owns its own frames-in-flight through a timeline semaphore, so
|
||||||
/// retirement and slot indexing come from the device instead. There is no
|
/// retirement and slot indexing come from the device instead.</para>
|
||||||
/// <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>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class VulkanHostInputCameraCompositionFactory
|
internal sealed class VulkanHostInputCameraCompositionFactory
|
||||||
: IHostInputCameraCompositionFactory
|
: IHostInputCameraCompositionFactory
|
||||||
|
|
@ -57,10 +54,6 @@ internal sealed class VulkanHostInputCameraCompositionFactory
|
||||||
IGpuDevice device) =>
|
IGpuDevice device) =>
|
||||||
new VulkanRenderFrameSlotSource(RequireContext(graphics).Device);
|
new VulkanRenderFrameSlotSource(RequireContext(graphics).Device);
|
||||||
|
|
||||||
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
|
|
||||||
GameWindowGraphics graphics,
|
|
||||||
IRenderFrameDiagnosticLog log) => null;
|
|
||||||
|
|
||||||
public SilkKeyboardSource CreateKeyboardSource(
|
public SilkKeyboardSource CreateKeyboardSource(
|
||||||
IKeyboard keyboard,
|
IKeyboard keyboard,
|
||||||
HostQuiescenceGate quiescence) =>
|
HostQuiescenceGate quiescence) =>
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,7 @@ public sealed class LoadedCell
|
||||||
/// CEnvCell.stab_list (acclient.h ~30925), the stable set of cells potentially
|
/// 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
|
/// visible from this cell, precomputed by the AC content tools. Refreshed only at
|
||||||
/// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878).
|
/// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878).
|
||||||
/// PortalVisibilityBuilder grounds set membership in it so a brittle per-frame
|
/// The frame walk uses it to ground authored potentially-visible membership.
|
||||||
/// portal-side test can't drop a potentially-visible cell from the visible set.
|
|
||||||
/// Empty when the dat carried no stab list (degenerate / old cell).
|
/// Empty when the dat carried no stab list (degenerate / old cell).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<uint> VisibleCells = System.Array.Empty<uint>();
|
public IReadOnlyList<uint> VisibleCells = System.Array.Empty<uint>();
|
||||||
|
|
@ -105,9 +104,8 @@ public sealed class LoadedCell
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
|
/// 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
|
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a walk root whose
|
||||||
/// shell is the landscape. <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView
|
/// shell is the landscape. The frame assembler seeds its outside view full-screen.
|
||||||
/// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
|
|
||||||
/// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
|
/// 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.
|
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -168,10 +166,8 @@ public struct PortalClipPlane
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked
|
/// Historical result describing which branch of the former camera-cell resolver ran.
|
||||||
/// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained
|
/// The retained physics-membership route always reports <see cref="None"/>.
|
||||||
/// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that
|
|
||||||
/// still prints <see cref="LastCameraCellResolution"/> (always None post-Stage 3).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum CameraCellResolution
|
public enum CameraCellResolution
|
||||||
{
|
{
|
||||||
|
|
@ -219,7 +215,7 @@ public sealed class CellVisibility
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stage 3 (2026-06-02): always <see cref="CameraCellResolution.None"/> — the FindCameraCell
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None;
|
public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// ClipFrameAssembler.cs
|
// ClipFrameAssembler.cs
|
||||||
//
|
//
|
||||||
// Retail view assembly policy. The production frame walk supplies its
|
// Retail view assembly policy. The production frame walk supplies its
|
||||||
// outside_view directly; the legacy PortalVisibilityBuilder overload remains
|
// outside_view directly. Each visible polygon is packed as an individual GPU
|
||||||
// only for isolated research/replay tests. Each visible polygon is packed as
|
// clip slot.
|
||||||
// an individual GPU clip slot.
|
|
||||||
//
|
//
|
||||||
// outside_view landscape slices
|
// outside_view landscape slices
|
||||||
// reverse cell_draw_list exit masks
|
// reverse cell_draw_list exit masks
|
||||||
|
|
@ -19,34 +18,10 @@ using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
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>
|
/// <summary>
|
||||||
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is
|
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is
|
||||||
/// retained for diagnostics (<see cref="WorldRenderDiagnostics"/>) only — no
|
/// retained as part of the exact outside-view reconstruction proof — no GPU
|
||||||
/// GPU pass reads it; there is no scissor consumer left in the walk.
|
/// pass reads it; there is no scissor consumer left in the walk.
|
||||||
///
|
///
|
||||||
/// <para>S3 review fix round 1 (F1): <paramref name="NothingVisible"/>
|
/// <para>S3 review fix round 1 (F1): <paramref name="NothingVisible"/>
|
||||||
/// distinguishes the "this walk view's own polygon collapsed" case (retail
|
/// 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 Dictionary<uint, int> PerCellPlaneCounts { get; } = new();
|
||||||
public int ScissorFallbacks { get; internal set; }
|
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.
|
// 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.
|
// Exact-length pools keep the public array API honest: Length is always the live slice count.
|
||||||
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
|
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
|
||||||
|
|
@ -279,8 +254,7 @@ public sealed class ClipFrameAssembly
|
||||||
public static class ClipFrameAssembler
|
public static class ClipFrameAssembler
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts one production walk assembly without constructing a parallel
|
/// Starts the one production walk assembly. Outdoor roots begin with retail's full-screen
|
||||||
/// PortalVisibilityFrame. Outdoor roots begin with retail's full-screen
|
|
||||||
/// default view; interior roots begin empty and are populated from
|
/// default view; interior roots begin empty and are populated from
|
||||||
/// <see cref="Walk.RetailFrameWalk.InteriorOutsideView"/> after Collect.
|
/// <see cref="Walk.RetailFrameWalk.InteriorOutsideView"/> after Collect.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -321,109 +295,6 @@ public static class ClipFrameAssembler
|
||||||
return assembly;
|
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>
|
/// <summary>
|
||||||
/// Campaign FW4 slice 1 — the interior root's outside-view cutover.
|
/// Campaign FW4 slice 1 — the interior root's outside-view cutover.
|
||||||
/// Replaces the assembly's outside-view block (slices, terrain mode,
|
/// 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
|
/// exactly ONE visibility structure per frame: <c>LScape::draw</c>'s
|
||||||
/// terrain clip, the punch fans' <c>building_view</c> planes, and the
|
/// terrain clip, the punch fans' <c>building_view</c> planes, and the
|
||||||
/// landscape turn's view count all read the views the walk itself
|
/// landscape turn's view count all read the views the walk itself
|
||||||
/// installed. Feeding these from the old <c>PortalVisibilityBuilder</c>
|
/// installed. This keeps terrain, punch fans, and landscape turns on
|
||||||
/// assembly let the two systems desynchronize on camera-transition
|
/// the same frame-owned walk views during camera transitions.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// The walk stores view vertices as PIXEL screen points
|
/// The walk stores view vertices as PIXEL screen points
|
||||||
/// (<c>copy_view</c>'s post-divide viewport coordinates, origin
|
/// (<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
|
/// <see cref="ClipFrameAssembly.OutsideViewSlices"/> by that index. The
|
||||||
/// shared <see cref="AppendOutsideSlice"/> helper SKIPS a view whose
|
/// shared <see cref="AppendOutsideSlice"/> helper SKIPS a view whose
|
||||||
/// polygon collapses (<see cref="ClipPlaneSet.IsNothingVisible"/>) —
|
/// polygon collapses (<see cref="ClipPlaneSet.IsNothingVisible"/>) —
|
||||||
/// correct for <see cref="Assemble"/>'s "empty regions are omitted
|
/// unsuitable here because skipping would make the
|
||||||
/// entirely" legacy policy, but wrong here: skipping would make the
|
|
||||||
/// slice array SHORTER than the view list, so every later view's fan
|
/// 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
|
/// would read the WRONG view's planes (off by however many views
|
||||||
/// collapsed before it). A collapsed view instead gets its own slice
|
/// collapsed before it). A collapsed view instead gets its own slice
|
||||||
|
|
@ -564,22 +429,14 @@ public static class ClipFrameAssembler
|
||||||
assembly.OutdoorVisible = outdoorVisible;
|
assembly.OutdoorVisible = outdoorVisible;
|
||||||
assembly.HasOutsideView = outdoorVisible;
|
assembly.HasOutsideView = outdoorVisible;
|
||||||
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
|
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
|
||||||
// S3 chunk 4 fix round 2 (L2): see Assemble's matching comment — the
|
// Any scissor fallback makes the aggregate plane count zero.
|
||||||
// same reduction applies here.
|
|
||||||
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
|
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
|
||||||
assembly.ScissorFallbacks = scissorFallbacks;
|
assembly.ScissorFallbacks = scissorFallbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// S3 chunk 4 fix round 2 (L4): the ONE place a single outside_view
|
/// Converts one outside_view <see cref="ViewPolygon"/> into an appended
|
||||||
/// <see cref="ViewPolygon"/> becomes an appended clip slot plus its
|
/// clip slot and <see cref="ClipViewSlice"/>. A polygon entirely outside every plane
|
||||||
/// <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
|
|
||||||
/// (<see cref="ClipPlaneSet.IsNothingVisible"/>) appends nothing and
|
/// (<see cref="ClipPlaneSet.IsNothingVisible"/>) appends nothing and
|
||||||
/// returns false; the caller's own loop simply moves to the next
|
/// 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
|
/// 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
|
// gl_ClipDistance, or a signal that the region OVER-includes (draws
|
||||||
// unclipped) because it cannot be expressed as one convex plane set.
|
// 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).
|
// 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
|
// 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)
|
// 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);
|
new(Array.Empty<Vector4>(), isPlaneOverflow: true, isNothingVisible: false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return the polygon wound CCW with collinear vertices removed. The PortalVisibilityBuilder
|
/// Return the polygon wound CCW with collinear vertices removed. From() must be robust to
|
||||||
/// already EnsureCcw's its output, but From() is a public entry point that must be robust to
|
|
||||||
/// either winding (e.g. a hand-built CellView), so we normalize here too.
|
/// either winding (e.g. a hand-built CellView), so we normalize here too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static int NormalizeAndMerge(ReadOnlySpan<Vector2> input, Span<Vector2> points)
|
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.
|
/// 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 →
|
/// 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
|
/// 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
|
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> and
|
||||||
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw
|
/// <see cref="LoadedCell.SeenOutside"/>. <see cref="ClipFrameAssembler.BeginWalkFrame"/> seeds the
|
||||||
/// as the root's shell) and <see cref="LoadedCell.SeenOutside"/>.
|
/// full-screen outside view for this root.
|
||||||
///
|
///
|
||||||
/// <para>R-A2 (2026-06-08): the node no longer carries reverse portals into nearby buildings. Retail
|
/// <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
|
/// 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).
|
/// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827).
|
||||||
/// acdream issues those via <see cref="PortalVisibilityBuilder.ConstructViewBuilding"/> per nearby
|
/// acdream issues those through the retained frame walk per nearby building inside
|
||||||
/// building inside <see cref="RetailPViewRenderer.DrawInside"/>. The pre-R-A2 design flooded all
|
/// <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
|
/// 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>
|
/// root-level portal-side test that oscillated as the chase eye grazed a doorway — the indoor flap.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -71,24 +71,6 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
|
||||||
|
|
||||||
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
|
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).
|
// success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871).
|
||||||
// Graph-tracked, no AABB/grace.
|
// Graph-tracked, no AABB/grace.
|
||||||
if (r.Ok) return new CameraSweepResult(eye, r.CellId);
|
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
|
/// <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
|
/// 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
|
/// CCW for the CCW-only <see cref="ScreenPolygonClip"/>. Returns fewer than 3 verts when
|
||||||
/// the polygon is entirely behind the camera / degenerate.</summary>
|
/// the polygon is entirely behind the camera / degenerate.</summary>
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,8 @@ public sealed class CellView
|
||||||
{
|
{
|
||||||
if (p.IsEmpty) return false;
|
if (p.IsEmpty) return false;
|
||||||
|
|
||||||
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
|
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). A caller may
|
||||||
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
|
// 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
|
// 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 +
|
// 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
|
// 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 ParticleRenderer? _particleRenderer;
|
||||||
private readonly PortalDepthMaskRenderer? _portalDepthMask;
|
private readonly PortalDepthMaskRenderer? _portalDepthMask;
|
||||||
private readonly RetailAlphaQueue _alpha;
|
private readonly RetailAlphaQueue _alpha;
|
||||||
private readonly WorldRenderDiagnostics _diagnostics;
|
|
||||||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
||||||
private readonly EnvCellAlphaDrawSource _envCellClipAlphaSource;
|
private readonly EnvCellAlphaDrawSource _envCellClipAlphaSource;
|
||||||
|
|
@ -85,7 +84,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
||||||
ParticleRenderer? particleRenderer,
|
ParticleRenderer? particleRenderer,
|
||||||
PortalDepthMaskRenderer? portalDepthMask,
|
PortalDepthMaskRenderer? portalDepthMask,
|
||||||
RetailAlphaQueue alpha,
|
RetailAlphaQueue alpha,
|
||||||
WorldRenderDiagnostics diagnostics,
|
|
||||||
TerrainDrawDiagnosticsController terrainDiagnostics)
|
TerrainDrawDiagnosticsController terrainDiagnostics)
|
||||||
{
|
{
|
||||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||||
|
|
@ -100,7 +98,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
||||||
_particleRenderer = particleRenderer;
|
_particleRenderer = particleRenderer;
|
||||||
_portalDepthMask = portalDepthMask;
|
_portalDepthMask = portalDepthMask;
|
||||||
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
|
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
|
||||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
|
||||||
_terrainDiagnostics = terrainDiagnostics
|
_terrainDiagnostics = terrainDiagnostics
|
||||||
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
|
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
|
||||||
_envCellClipAlphaSource = new EnvCellAlphaDrawSource(
|
_envCellClipAlphaSource = new EnvCellAlphaDrawSource(
|
||||||
|
|
@ -663,20 +660,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
||||||
clipSlot: 0);
|
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>
|
/// <summary>Retail <c>D3DPolyRender::DrawPortalPolyInternal</c>
|
||||||
/// @0x0059BC90. Main interior roots stamp true depth (seal); outdoor and
|
/// @0x0059BC90. Main interior roots stamp true depth (seal); outdoor and
|
||||||
/// look-in apertures stamp far depth (punch). The renderer owns that
|
/// look-in apertures stamp far depth (punch). The renderer owns that
|
||||||
|
|
@ -737,14 +720,6 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
|
||||||
cell.WorldTransform);
|
cell.WorldTransform);
|
||||||
}
|
}
|
||||||
|
|
||||||
_diagnostics.EmitSeamMask(
|
|
||||||
RenderingDiagnostics.ProbeSeamDrawEnabled,
|
|
||||||
RenderingDiagnostics.SeamDrawTargetCells,
|
|
||||||
cellId,
|
|
||||||
index,
|
|
||||||
forceFarZ,
|
|
||||||
world[..count]);
|
|
||||||
|
|
||||||
// S4-c1 fix round 1 F2: retail increments portalsDrawnCount
|
// S4-c1 fix round 1 F2: retail increments portalsDrawnCount
|
||||||
// (0x59BD70-0x59BD74) BEFORE polyClipFinish runs (0x59BDB0) —
|
// (0x59BD70-0x59BD74) BEFORE polyClipFinish runs (0x59BDB0) —
|
||||||
// the counter records accepted ATTEMPTS, not successful GPU
|
// the counter records accepted ATTEMPTS, not successful GPU
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,6 @@ internal sealed class RetailPViewRenderer
|
||||||
// cleared in finally.
|
// cleared in finally.
|
||||||
private Action? _walkPreClearDynamics;
|
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,
|
// FW6 allocation closeout: the walk's large event/view/route scratch,
|
||||||
// frame context, and one-cell leaf collections are renderer-lifetime
|
// frame context, and one-cell leaf collections are renderer-lifetime
|
||||||
// owners. Only their frame-local bindings change. Before this cutover all
|
// owners. Only their frame-local bindings change. Before this cutover all
|
||||||
|
|
@ -108,11 +102,8 @@ internal sealed class RetailPViewRenderer
|
||||||
ArgumentNullException.ThrowIfNull(ctx);
|
ArgumentNullException.ThrowIfNull(ctx);
|
||||||
ArgumentNullException.ThrowIfNull(passes);
|
ArgumentNullException.ThrowIfNull(passes);
|
||||||
passes.BeginFrame();
|
passes.BeginFrame();
|
||||||
// Campaign FW4: there is one production renderer and one visibility
|
// There is one production renderer and one frame-walk owner. Fail
|
||||||
// owner. The fake/legacy executor path was useful during the cutover,
|
// loudly if composition attempts to split that path.
|
||||||
// but retaining it here kept the retired PortalVisibilityBuilder and
|
|
||||||
// look-in seed machinery alive. Fail loudly if composition ever tries
|
|
||||||
// to reintroduce that split.
|
|
||||||
RetailPViewPassExecutor walkExecutor = passes as RetailPViewPassExecutor
|
RetailPViewPassExecutor walkExecutor = passes as RetailPViewPassExecutor
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The retail frame walk requires RetailPViewPassExecutor.");
|
"The retail frame walk requires RetailPViewPassExecutor.");
|
||||||
|
|
@ -294,128 +285,13 @@ internal sealed class RetailPViewRenderer
|
||||||
viewportHeight);
|
viewportHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The walk's visited set replaces PortalVisibilityFrame's old
|
// The walk's visited set feeds every production consumer.
|
||||||
// OrderedVisibleCells side-channel for every production consumer.
|
|
||||||
_drawableCellsScratch.Clear();
|
_drawableCellsScratch.Clear();
|
||||||
_drawableCellsScratch.UnionWith(walkDriver.VisitedCells);
|
_drawableCellsScratch.UnionWith(walkDriver.VisitedCells);
|
||||||
walkDriver.CopyVisibleCellsTo(_visibleCellsScratch);
|
walkDriver.CopyVisibleCellsTo(_visibleCellsScratch);
|
||||||
_visibleLandscapeCellsScratch.Clear();
|
_visibleLandscapeCellsScratch.Clear();
|
||||||
_visibleLandscapeCellsScratch.UnionWith(walkDriver.VisitedLandscapeCellIds);
|
_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
|
// FW4 slice 1: the ONE clip-region publication, after any walk
|
||||||
|
|
@ -448,8 +324,6 @@ internal sealed class RetailPViewRenderer
|
||||||
counts,
|
counts,
|
||||||
sourceCounts,
|
sourceCounts,
|
||||||
diagnosticPartition: null);
|
diagnosticPartition: null);
|
||||||
passes.EmitDiagnostics(ctx, result);
|
|
||||||
|
|
||||||
// The one collected walk owns terrain, cell shells, buildings,
|
// The one collected walk owns terrain, cell shells, buildings,
|
||||||
// statics, dynamics, particles, punches, and alpha barriers at
|
// statics, dynamics, particles, punches, and alpha barriers at
|
||||||
// their retail turns. Interior-root landscape services run at the
|
// 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
|
/// exit-view polygons THIS walk's own <c>ConstructView</c> installed
|
||||||
/// during the most recent <see cref="DrawInside"/>. The production
|
/// during the most recent <see cref="DrawInside"/>. The production
|
||||||
/// renderer materializes the frame's terrain/sky/punch clip slices from
|
/// renderer materializes the frame's terrain/sky/punch clip slices from
|
||||||
/// these views (retail has exactly one visibility structure; the old
|
/// these views (retail has exactly one visibility structure). Valid after
|
||||||
/// PortalVisibilityBuilder assembly no longer feeds the walk path's
|
/// <see cref="WalkFrame"/> returns for an
|
||||||
/// outside views). Valid after <see cref="WalkFrame"/> returns for an
|
|
||||||
/// interior root, until the next interior root's view push resets it.</summary>
|
/// interior root, until the next interior root's view push resets it.</summary>
|
||||||
internal WalkPortalView InteriorOutsideView => _interiorPView.OutsideView;
|
internal WalkPortalView InteriorOutsideView => _interiorPView.OutsideView;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,11 +186,6 @@ public sealed class WalkPView
|
||||||
ref WalkCellPortal portal = ref cell.Portals[j];
|
ref WalkCellPortal portal = ref cell.Portals[j];
|
||||||
if (!flags.Seen || flags.InView)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu)
|
if (cell.CachedNeighbors[j] is null && portal.OtherCellId != 0xFFFFFFFFu)
|
||||||
|
|
@ -198,11 +193,6 @@ public sealed class WalkPView
|
||||||
cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId);
|
cell.CachedNeighbors[j] = ctx.GetVisible(portal.OtherCellId);
|
||||||
if (cell.CachedNeighbors[j] is null)
|
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
|
continue; // not loaded: silently dead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,15 +213,8 @@ public sealed class WalkPView
|
||||||
cell, portal.PortalSide,
|
cell, portal.PortalSide,
|
||||||
cell.PortalPolygons[portal.PolygonIndex],
|
cell.PortalPolygons[portal.PolygonIndex],
|
||||||
doClip: true, ctx, _clipScratch);
|
doClip: true, ctx, _clipScratch);
|
||||||
int projectedCount =
|
|
||||||
cell.PortalPolygons[portal.PolygonIndex].Vertices.Length;
|
|
||||||
if (n == 0)
|
if (n == 0)
|
||||||
{
|
{
|
||||||
EmitFacilityPortalProbe(
|
|
||||||
cell, j, i, top, portal, flags, ctx,
|
|
||||||
projectedCount, clippedCount: 0,
|
|
||||||
neighborViewsBefore: -1, neighborViewsAfter: -1,
|
|
||||||
appended: false, result: "clip-empty");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,123 +240,19 @@ public sealed class WalkPView
|
||||||
SetView(top, i); // restore after the far-frame excursion
|
SetView(top, i); // restore after the far-frame excursion
|
||||||
if (n == 0)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
int before = neighbor.TopView.ViewCount;
|
|
||||||
bool appended = false;
|
|
||||||
if (neighbor.NumView != 0)
|
if (neighbor.NumView != 0)
|
||||||
appended = WalkCopyView.Append(
|
WalkCopyView.Append(
|
||||||
neighbor.TopView, _clipScratch.AsSpan(0, n),
|
neighbor.TopView, _clipScratch.AsSpan(0, n),
|
||||||
ctx.Rays, ctx.WorldViewpoint);
|
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;
|
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
|
// OtherPortalClip @0x005a5400 — the double clip for non-exact_match
|
||||||
// portals: snapshot the near-clipped poly as a temp view, then re-clip
|
// 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> _cellCache = new();
|
||||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
|
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
|
||||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellObjectsCache = 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, List<RenderProjectionRecord>> _shellsByAnchor = new();
|
||||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
|
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
|
||||||
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
|
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
|
||||||
|
|
@ -162,21 +161,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
int renderCenterLbX,
|
int renderCenterLbX,
|
||||||
int renderCenterLbY)
|
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;
|
_scene = scene;
|
||||||
_tupleLandblockId = tupleLandblockId;
|
_tupleLandblockId = tupleLandblockId;
|
||||||
_cellCache.Clear();
|
_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)
|
public WalkFrameStaticRecords GetCellStatics(uint cellId)
|
||||||
{
|
{
|
||||||
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||||||
return cached;
|
return cached;
|
||||||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
|
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
|
||||||
EmitFacilityShadowProbe(records.Records, cellId, "static");
|
|
||||||
_cellCache[cellId] = records;
|
_cellCache[cellId] = records;
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +215,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||||||
return cached;
|
return cached;
|
||||||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
|
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
|
||||||
EmitFacilityShadowProbe(records.Records, cellId, "combined");
|
|
||||||
_cellObjectsCache[cellId] = records;
|
_cellObjectsCache[cellId] = records;
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +224,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||||||
return cached;
|
return cached;
|
||||||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
|
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
|
||||||
EmitFacilityShadowProbe(records.Records, cellId, "dynamic");
|
|
||||||
_cellDynamicCache[cellId] = records;
|
_cellDynamicCache[cellId] = records;
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
@ -391,68 +343,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
projectionClass is RenderProjectionClass.LiveDynamicRoot
|
projectionClass is RenderProjectionClass.LiveDynamicRoot
|
||||||
or RenderProjectionClass.EquippedChild;
|
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)
|
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
|
||||||
{
|
{
|
||||||
uint anchor = BuildingShellBucketCellId(building);
|
uint anchor = BuildingShellBucketCellId(building);
|
||||||
|
|
|
||||||
|
|
@ -224,8 +224,7 @@ internal sealed class WalkStaticStreamPopulator
|
||||||
liveDynamic,
|
liveDynamic,
|
||||||
views,
|
views,
|
||||||
viewRouteIndex,
|
viewRouteIndex,
|
||||||
cellId,
|
cellId);
|
||||||
diagnosticViewProjection: viewProjection);
|
|
||||||
|
|
||||||
for (int batchIndex = 0; batchIndex < _batchScratch.Count; batchIndex++)
|
for (int batchIndex = 0; batchIndex < _batchScratch.Count; batchIndex++)
|
||||||
{
|
{
|
||||||
|
|
@ -257,17 +256,10 @@ internal sealed class WalkStaticStreamPopulator
|
||||||
WbDrawDispatcher.WalkClassifiedBatch batch = item.Batch;
|
WbDrawDispatcher.WalkClassifiedBatch batch = item.Batch;
|
||||||
if (batch.IsOpaque)
|
if (batch.IsOpaque)
|
||||||
{
|
{
|
||||||
int commandIndex = stream.Count;
|
|
||||||
stream.Append(new OrderedDrawCommand(
|
stream.Append(new OrderedDrawCommand(
|
||||||
batch.Key, batch.Transform, item.Stage, cellId, batch.ClipSlot,
|
batch.Key, batch.Transform, item.Stage, cellId, batch.ClipSlot,
|
||||||
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
||||||
batch.SelectionLighting, batch.DetailCategory));
|
batch.SelectionLighting, batch.DetailCategory));
|
||||||
_dispatcher.ProbeFacilityStairCommandAppended(
|
|
||||||
commandIndex,
|
|
||||||
item.LocalEntityId,
|
|
||||||
cellId,
|
|
||||||
item.Stage,
|
|
||||||
in batch);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -318,7 +310,6 @@ internal sealed class WalkStaticStreamPopulator
|
||||||
lookInViews,
|
lookInViews,
|
||||||
lookInRouteIndex,
|
lookInRouteIndex,
|
||||||
cellId,
|
cellId,
|
||||||
diagnosticViewProjection: viewProjection,
|
|
||||||
buildingSelection: buildingSelection,
|
buildingSelection: buildingSelection,
|
||||||
buildingPartTransform: buildingPartTransform);
|
buildingPartTransform: buildingPartTransform);
|
||||||
|
|
||||||
|
|
@ -327,17 +318,10 @@ internal sealed class WalkStaticStreamPopulator
|
||||||
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i];
|
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i];
|
||||||
if (batch.IsOpaque)
|
if (batch.IsOpaque)
|
||||||
{
|
{
|
||||||
int commandIndex = stream.Count;
|
|
||||||
stream.Append(new OrderedDrawCommand(
|
stream.Append(new OrderedDrawCommand(
|
||||||
batch.Key, batch.Transform, stage, cellId, batch.ClipSlot,
|
batch.Key, batch.Transform, stage, cellId, batch.ClipSlot,
|
||||||
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
batch.Lights, batch.IndoorFlag, batch.Alpha,
|
||||||
batch.SelectionLighting, batch.DetailCategory));
|
batch.SelectionLighting, batch.DetailCategory));
|
||||||
_dispatcher.ProbeFacilityStairCommandAppended(
|
|
||||||
commandIndex,
|
|
||||||
record.Source.LocalEntityId,
|
|
||||||
cellId,
|
|
||||||
stage,
|
|
||||||
in batch);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -220,12 +220,6 @@ public sealed unsafe partial class EnvCellRenderer
|
||||||
Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
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 lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||||
int globalLightUploadCount = lightCount > 0 ? lightCount : 1;
|
int globalLightUploadCount = lightCount > 0 ? lightCount : 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -203,21 +203,6 @@ public sealed partial class EnvCellRenderer :
|
||||||
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
|
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
|
||||||
private LastFrameStats _lastFrameStats;
|
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
|
// Constructor
|
||||||
// Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step
|
// 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:
|
// WB EnvCellRenderManager.cs:470-483:
|
||||||
if (allInstances.Count > 0)
|
if (allInstances.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1421,104 +1400,6 @@ public sealed partial class EnvCellRenderer :
|
||||||
ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount, materialState));
|
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)
|
// List pool (GetPooledList)
|
||||||
// Copied from WB ObjectRenderManagerBase (pattern).
|
// 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;
|
_orderedStream = stream;
|
||||||
_orderedFrame = frame;
|
_orderedFrame = frame;
|
||||||
_orderedPreparedCount = 0;
|
_orderedPreparedCount = 0;
|
||||||
ProbeFacilityStairPrepareStarted(stream.Count);
|
|
||||||
|
|
||||||
// Fail loud before any GPU work: a PortalPunch command has no
|
// Fail loud before any GPU work: a PortalPunch command has no
|
||||||
// submission path.
|
// submission path.
|
||||||
|
|
@ -433,7 +432,6 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
checked((uint)(count * DrawCommandStride)));
|
checked((uint)(count * DrawCommandStride)));
|
||||||
|
|
||||||
_orderedPreparedCount = count;
|
_orderedPreparedCount = count;
|
||||||
ProbeFacilityStairUploadCompleted(count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -587,11 +585,6 @@ public sealed unsafe partial class WbDrawDispatcher
|
||||||
DrawIndirectRangeRhi(
|
DrawIndirectRangeRhi(
|
||||||
encoder, ref pushConstants, commandBuffer, commandBase,
|
encoder, ref pushConstants, commandBuffer, commandBase,
|
||||||
run.FirstCommand, run.CommandCount, _orderedDrawCullModes);
|
run.FirstCommand, run.CommandCount, _orderedDrawCullModes);
|
||||||
ProbeFacilityStairRunSubmitted(
|
|
||||||
firstCommand,
|
|
||||||
commandCount,
|
|
||||||
run.FirstCommand,
|
|
||||||
run.CommandCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ClearDetailPushConstants(ref pushConstants);
|
ClearDetailPushConstants(ref pushConstants);
|
||||||
|
|
|
||||||
|
|
@ -55,20 +55,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
{
|
{
|
||||||
private readonly HashSet<WalkDrawnPartKey> _walkDrawnParts = new();
|
private readonly HashSet<WalkDrawnPartKey> _walkDrawnParts = new();
|
||||||
private bool _walkPartFrameActive;
|
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(
|
private readonly record struct WalkDrawnPartKey(
|
||||||
RenderProjectionId ProjectionId,
|
RenderProjectionId ProjectionId,
|
||||||
int PartIndex);
|
int PartIndex);
|
||||||
|
|
@ -93,7 +79,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
|
|
||||||
_missRequested.Clear();
|
_missRequested.Clear();
|
||||||
_walkDrawnParts.Clear();
|
_walkDrawnParts.Clear();
|
||||||
BeginFacilityStairSubmissionProbeFrame();
|
|
||||||
_walkPartFrameActive = true;
|
_walkPartFrameActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,7 +260,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
IWalkLookInViewSource? lookInViews = null,
|
IWalkLookInViewSource? lookInViews = null,
|
||||||
int lookInRouteIndex = -1,
|
int lookInRouteIndex = -1,
|
||||||
uint lookInCellId = 0,
|
uint lookInCellId = 0,
|
||||||
Matrix4x4 diagnosticViewProjection = default,
|
|
||||||
WalkBuildingSelection? buildingSelection = null,
|
WalkBuildingSelection? buildingSelection = null,
|
||||||
Matrix4x4 buildingPartTransform = default)
|
Matrix4x4 buildingPartTransform = default)
|
||||||
{
|
{
|
||||||
|
|
@ -369,17 +353,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
|
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||||
if (partData is null)
|
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))
|
if (_missRequested.Add(gfxObjId))
|
||||||
_meshAdapter.EnsureLoaded(gfxObjId);
|
_meshAdapter.EnsureLoaded(gfxObjId);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -393,12 +366,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
: 1f;
|
: 1f;
|
||||||
if (opacity <= 0f)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,21 +378,11 @@ public sealed partial class WbDrawDispatcher
|
||||||
lookInRouteIndex,
|
lookInRouteIndex,
|
||||||
partData,
|
partData,
|
||||||
model,
|
model,
|
||||||
out Vector3 sphereCenter,
|
out _,
|
||||||
out float sphereRadius,
|
out _,
|
||||||
out bool hasSphere);
|
out _);
|
||||||
bool firstAdmission = visible
|
bool firstAdmission = visible
|
||||||
&& TryStampWalkPart(in projection, selectionPartIndex);
|
&& 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)
|
if (!firstAdmission)
|
||||||
continue;
|
continue;
|
||||||
EmitClassifiedBatches(
|
EmitClassifiedBatches(
|
||||||
|
|
@ -448,12 +405,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
: 1f;
|
: 1f;
|
||||||
if (opacity <= 0f)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,21 +414,11 @@ public sealed partial class WbDrawDispatcher
|
||||||
lookInRouteIndex,
|
lookInRouteIndex,
|
||||||
renderData,
|
renderData,
|
||||||
model,
|
model,
|
||||||
out Vector3 sphereCenter,
|
out _,
|
||||||
out float sphereRadius,
|
out _,
|
||||||
out bool hasSphere);
|
out _);
|
||||||
bool firstAdmission = visible
|
bool firstAdmission = visible
|
||||||
&& TryStampWalkPart(in projection, partIndex);
|
&& 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)
|
if (!firstAdmission)
|
||||||
continue;
|
continue;
|
||||||
EmitClassifiedBatches(
|
EmitClassifiedBatches(
|
||||||
|
|
@ -585,213 +526,6 @@ public sealed partial class WbDrawDispatcher
|
||||||
return lookInViews.SphereVisibleInLookInTurn(routeIndex, in sphereCenter, sphereRadius);
|
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(
|
internal static bool LookInDrawingSphereVisible(
|
||||||
IWalkLookInViewSource lookInViews,
|
IWalkLookInViewSource lookInViews,
|
||||||
int routeIndex,
|
int routeIndex,
|
||||||
|
|
|
||||||
|
|
@ -750,31 +750,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
private bool _disposing;
|
private bool _disposing;
|
||||||
private bool _disposed;
|
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.
|
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
|
||||||
private int _entitiesSeen;
|
private int _entitiesSeen;
|
||||||
private int _entitiesDrawn;
|
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
|
/// 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.
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
internal static void WalkEntitiesInto(
|
internal static void WalkEntitiesInto(
|
||||||
IEnumerable<LandblockEntry> landblockEntries,
|
IEnumerable<LandblockEntry> landblockEntries,
|
||||||
|
|
@ -966,7 +931,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
HashSet<uint>? animatedEntityIds,
|
HashSet<uint>? animatedEntityIds,
|
||||||
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
||||||
ref WalkResult result,
|
ref WalkResult result,
|
||||||
IndoorProbeState? indoorProbeState = null,
|
|
||||||
EntitySet set = EntitySet.All)
|
EntitySet set = EntitySet.All)
|
||||||
{
|
{
|
||||||
scratch.Clear();
|
scratch.Clear();
|
||||||
|
|
@ -1015,13 +979,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
if (!EntityMatchesSet(entity, set)) continue;
|
if (!EntityMatchesSet(entity, set)) continue;
|
||||||
if (entity.MeshRefs.Count == 0) 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)
|
bool shellScoped = IsShellScopedSet(set)
|
||||||
&& entity.IsBuildingShell
|
&& entity.IsBuildingShell
|
||||||
&& visibleCellIds is not null;
|
&& visibleCellIds is not null;
|
||||||
|
|
@ -1029,14 +986,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
if (!cellInVis)
|
if (!cellInVis)
|
||||||
{
|
{
|
||||||
if (shellScoped) result.BuildingShellAnchorReject++;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (shellScoped) result.BuildingShellAnchorPass++;
|
if (shellScoped) result.BuildingShellAnchorPass++;
|
||||||
|
|
@ -1054,31 +1003,9 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
|
|
||||||
if (!aabbVisible)
|
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;
|
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++;
|
result.EntitiesWalked++;
|
||||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||||
scratch.Add((entity, i, entry.LandblockId));
|
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)
|
// that populates _walkScratch (a per-dispatcher field reused across frames)
|
||||||
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
|
// 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);
|
var walkResult = default(WalkResult);
|
||||||
WalkEntitiesInto(
|
WalkEntitiesInto(
|
||||||
ToEntries(landblockEntries),
|
ToEntries(landblockEntries),
|
||||||
|
|
@ -1222,7 +1132,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
animatedEntityIds,
|
animatedEntityIds,
|
||||||
_walkScratch,
|
_walkScratch,
|
||||||
ref walkResult,
|
ref walkResult,
|
||||||
probeState,
|
|
||||||
set);
|
set);
|
||||||
_currentRenderSceneObserver?.ObserveDispatcherDraw(
|
_currentRenderSceneObserver?.ObserveDispatcherDraw(
|
||||||
set,
|
set,
|
||||||
|
|
@ -1358,14 +1267,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
? new Vector2(lighting.Luminosity, lighting.Diffuse)
|
? new Vector2(lighting.Luminosity, lighting.Diffuse)
|
||||||
: new Vector2(0f, 1f);
|
: 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;
|
prevTupleEntityId = entity.Id;
|
||||||
|
|
||||||
|
|
@ -1468,41 +1369,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
|
|
||||||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
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)
|
if (renderData is null)
|
||||||
{
|
{
|
||||||
// Tier 1 cache (#53): mesh data is still async-decoding via
|
// Tier 1 cache (#53): mesh data is still async-decoding via
|
||||||
|
|
@ -1595,23 +1461,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
var model = ComposePartWorldMatrix(
|
var model = ComposePartWorldMatrix(
|
||||||
entityWorld, meshRef.PartTransform, partTransform);
|
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;
|
var restPose = partTransform * meshRef.PartTransform;
|
||||||
|
|
||||||
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
|
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
|
||||||
|
|
@ -1718,11 +1567,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
populateEntityId, populateLandblockId, _cache,
|
populateEntityId, populateLandblockId, _cache,
|
||||||
_populateScratch, _populateSelectionScratch);
|
_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(
|
ExecuteClassifiedGroups(
|
||||||
vp,
|
vp,
|
||||||
camPos,
|
camPos,
|
||||||
|
|
@ -1777,7 +1621,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
out Vector3 cameraWorldPosition)
|
out Vector3 cameraWorldPosition)
|
||||||
{
|
{
|
||||||
_selectionLighting?.TickLighting();
|
_selectionLighting?.TickLighting();
|
||||||
_indoorProbeFrameCounter++;
|
|
||||||
viewProjection = camera.View * camera.Projection;
|
viewProjection = camera.View * camera.Projection;
|
||||||
_missRequested.Clear();
|
_missRequested.Clear();
|
||||||
|
|
||||||
|
|
@ -3085,44 +2928,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
||||||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </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(
|
private void ComputeEntityLightSet(
|
||||||
in RenderInstanceCandidate entity)
|
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
|
internal sealed class InstanceGroup
|
||||||
{
|
{
|
||||||
// Nonzero only while this exact object is registered in _groups.
|
// Nonzero only while this exact object is registered in _groups.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ internal interface IRenderFrameGlState
|
||||||
{
|
{
|
||||||
void RestoreFrameDefaults();
|
void RestoreFrameDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign V slice V6j: the small graphics surface the two world pass executors
|
/// 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,
|
/// 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.Diagnostics;
|
||||||
using System.Text;
|
|
||||||
using AcDream.Core.Vfx;
|
|
||||||
using AcDream.Core.World;
|
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
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(
|
internal readonly record struct TerrainRenderDiagnosticFacts(
|
||||||
int VisibleSlots,
|
int VisibleSlots,
|
||||||
int Draws,
|
int Draws,
|
||||||
int LoadedSlots,
|
int LoadedSlots,
|
||||||
int CapacitySlots);
|
int CapacitySlots);
|
||||||
|
|
||||||
internal interface IRenderGlStateReader
|
/// <summary>Owns permanent terrain timing and diagnostic publication.</summary>
|
||||||
{
|
|
||||||
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>
|
|
||||||
internal sealed class WorldRenderDiagnostics
|
internal sealed class WorldRenderDiagnostics
|
||||||
{
|
{
|
||||||
private readonly IRenderGlStateReader _gl;
|
|
||||||
private readonly IRenderFrameDiagnosticLog _log;
|
private readonly IRenderFrameDiagnosticLog _log;
|
||||||
private readonly Stopwatch _terrainStopwatch = new();
|
private readonly Stopwatch _terrainStopwatch = new();
|
||||||
private readonly RollingTimingSampleWindow _terrainSamples = new(256);
|
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(
|
public WorldRenderDiagnostics(IRenderFrameDiagnosticLog log)
|
||||||
IRenderGlStateReader gl,
|
|
||||||
IRenderFrameDiagnosticLog log)
|
|
||||||
{
|
{
|
||||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
|
||||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,12 +29,6 @@ internal sealed class WorldRenderDiagnostics
|
||||||
(long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
|
(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) =>
|
public void PushTerrainSample(long elapsedHundredthsMicroseconds) =>
|
||||||
_terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds);
|
_terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds);
|
||||||
|
|
||||||
|
|
@ -107,442 +44,4 @@ internal sealed class WorldRenderDiagnostics
|
||||||
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
|
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
|
||||||
+ $"capacity={facts.CapacitySlots}");
|
+ $"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)
|
if (viewerRoot is null)
|
||||||
outdoorNode = OutdoorCellNode.Build(viewerCellId);
|
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);
|
return new WorldBuildingFrame(outdoorNode, _scratch);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,36 +13,6 @@ internal interface IWorldSceneDiagnostics
|
||||||
{
|
{
|
||||||
CameraCellResolution CameraCellResolution { get; }
|
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(
|
WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
|
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
|
||||||
|
|
@ -55,7 +25,6 @@ internal interface IWorldSceneDiagnostics
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
||||||
{
|
{
|
||||||
private readonly WorldRenderDiagnostics _diagnostics;
|
|
||||||
private readonly IWorldScenePViewDiagnosticSource _pview;
|
private readonly IWorldScenePViewDiagnosticSource _pview;
|
||||||
private readonly IWorldSceneDebugStateSource _state;
|
private readonly IWorldSceneDebugStateSource _state;
|
||||||
private readonly DebugLineRenderer? _lines;
|
private readonly DebugLineRenderer? _lines;
|
||||||
|
|
@ -67,7 +36,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
||||||
private int _debugDrawLogCount;
|
private int _debugDrawLogCount;
|
||||||
|
|
||||||
public WorldSceneDiagnosticsController(
|
public WorldSceneDiagnosticsController(
|
||||||
WorldRenderDiagnostics diagnostics,
|
|
||||||
IWorldScenePViewDiagnosticSource pview,
|
IWorldScenePViewDiagnosticSource pview,
|
||||||
IWorldSceneDebugStateSource state,
|
IWorldSceneDebugStateSource state,
|
||||||
DebugLineRenderer? lines,
|
DebugLineRenderer? lines,
|
||||||
|
|
@ -77,7 +45,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
||||||
DebugVmRenderFactsPublisher debugVm,
|
DebugVmRenderFactsPublisher debugVm,
|
||||||
bool debugVmConsumerActive)
|
bool debugVmConsumerActive)
|
||||||
{
|
{
|
||||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
|
||||||
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
|
||||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||||
_lines = lines;
|
_lines = lines;
|
||||||
|
|
@ -90,89 +57,11 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
|
||||||
|
|
||||||
public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution;
|
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(
|
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(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);
|
DrawCollisionWireframes(in camera);
|
||||||
|
|
||||||
int visible = 0;
|
int visible = 0;
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,10 @@ internal interface IWorldScenePassExecutor
|
||||||
uint? playerLandblockId,
|
uint? playerLandblockId,
|
||||||
HashSet<uint> animatedEntityIds);
|
HashSet<uint> animatedEntityIds);
|
||||||
|
|
||||||
string DrawPostWorldParticles(
|
void DrawPostWorldParticles(
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
ClipFrameAssembly? clipAssembly,
|
ClipFrameAssembly? clipAssembly,
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera);
|
||||||
string currentSignature);
|
|
||||||
|
|
||||||
void DrawFlatWeather(
|
void DrawFlatWeather(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
|
|
@ -182,14 +181,13 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||||
visibleCellIds: null,
|
visibleCellIds: null,
|
||||||
animatedEntityIds: animatedEntityIds);
|
animatedEntityIds: animatedEntityIds);
|
||||||
|
|
||||||
public string DrawPostWorldParticles(
|
public void DrawPostWorldParticles(
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
ClipFrameAssembly? clipAssembly,
|
ClipFrameAssembly? clipAssembly,
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera)
|
||||||
string currentSignature)
|
|
||||||
{
|
{
|
||||||
if (_particles is null || _particleRenderer is null)
|
if (_particles is null || _particleRenderer is null)
|
||||||
return currentSignature;
|
return;
|
||||||
|
|
||||||
if (clipRoot is null)
|
if (clipRoot is null)
|
||||||
{
|
{
|
||||||
|
|
@ -202,21 +200,21 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||||
_visibleParticleOwners,
|
_visibleParticleOwners,
|
||||||
includeUnattached: true,
|
includeUnattached: true,
|
||||||
excludedAttachedOwnerIds: _noExcludedParticleOwners);
|
excludedAttachedOwnerIds: _noExcludedParticleOwners);
|
||||||
return AppendSignature(currentSignature, "filtered");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_particleRenderer.Draw(
|
_particleRenderer.Draw(
|
||||||
camera.Camera,
|
camera.Camera,
|
||||||
camera.Position,
|
camera.Position,
|
||||||
ParticleRenderPass.Scene);
|
ParticleRenderPass.Scene);
|
||||||
return AppendSignature(currentSignature, "global");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every PView root, including the outdoor sentinel, now submits scene
|
// Every PView root, including the outdoor sentinel, now submits scene
|
||||||
// particles inside LScape::draw. Replaying them here is both a duplicate
|
// particles inside LScape::draw. Replaying them here is both a duplicate
|
||||||
// and too late: it occurs after nested building cells, allowing exterior
|
// and too late: it occurs after nested building cells, allowing exterior
|
||||||
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
||||||
return currentSignature;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DrawFlatWeather(
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,20 +179,12 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
LoadedCell? clipRoot = world.ClipRoot;
|
LoadedCell? clipRoot = world.ClipRoot;
|
||||||
bool renderSky = roots.RenderSky;
|
bool renderSky = roots.RenderSky;
|
||||||
bool drawSkyThisFrame = false;
|
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;
|
RetailPViewFrameResult? pviewResult = null;
|
||||||
|
|
||||||
if (clipRoot is null)
|
if (clipRoot is null)
|
||||||
{
|
{
|
||||||
_passes.PrepareFlatWorldClip();
|
_passes.PrepareFlatWorldClip();
|
||||||
drawSkyThisFrame = renderSky;
|
drawSkyThisFrame = renderSky;
|
||||||
skyDrawn = drawSkyThisFrame;
|
|
||||||
if (drawSkyThisFrame)
|
if (drawSkyThisFrame)
|
||||||
{
|
{
|
||||||
_passes.DrawFlatSky(
|
_passes.DrawFlatSky(
|
||||||
|
|
@ -210,11 +202,6 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
// waiting frame. One gate computes the frame's visibility;
|
// waiting frame. One gate computes the frame's visibility;
|
||||||
// this phase only enforces it.
|
// this phase only enforces it.
|
||||||
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
|
||||||
terrainDrawn = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
terrainClipMode = TerrainClipMode.Skip;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clipRoot is not null)
|
if (clipRoot is not null)
|
||||||
|
|
@ -253,30 +240,6 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
// VisibleCells remains a diagnostics-only union.
|
// VisibleCells remains a diagnostics-only union.
|
||||||
_particleVisibility.MarkVisibleLandscapeCells(
|
_particleVisibility.MarkVisibleLandscapeCells(
|
||||||
pviewResult.VisibleLandscapeCells);
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -288,15 +251,13 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
}
|
}
|
||||||
|
|
||||||
_passes.DisableClipDistances();
|
_passes.DisableClipDistances();
|
||||||
sceneParticles = _passes.DrawPostWorldParticles(
|
_passes.DrawPostWorldParticles(
|
||||||
clipRoot,
|
clipRoot,
|
||||||
pviewResult?.ClipAssembly,
|
pviewResult?.ClipAssembly,
|
||||||
in camera,
|
in camera);
|
||||||
sceneParticles);
|
|
||||||
|
|
||||||
if (clipRoot is null && drawSkyThisFrame)
|
if (clipRoot is null && drawSkyThisFrame)
|
||||||
{
|
{
|
||||||
skyDrawn = true;
|
|
||||||
_passes.DrawFlatWeather(
|
_passes.DrawFlatWeather(
|
||||||
in camera,
|
in camera,
|
||||||
in foundation,
|
in foundation,
|
||||||
|
|
@ -304,27 +265,6 @@ internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
|
||||||
_sky.DayFraction);
|
_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(
|
WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish(
|
||||||
in camera,
|
in camera,
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ public sealed class LightManager
|
||||||
/// registered lights (chase-boom swing churned the eviction boundary), then
|
/// registered lights (chase-boom swing churned the eviction boundary), then
|
||||||
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
||||||
/// purples entered/left the pool as the camera turned — the seam-floor
|
/// 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.
|
/// (=resident) lit lights, then dynamics-first nearest-player, capped here.
|
||||||
/// A later last-frame drawable-cell filter was removed after the Facility Hub
|
/// 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
|
/// 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(
|
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
|
// cross-cell block-offset distance math. #176 correction (2026-07-06): it is
|
||||||
// NOT a pool filter — retail collects from ALL resident cells
|
// NOT a pool filter — retail collects from ALL resident cells
|
||||||
// (CEnvCell::visible_cell_table = the loaded-cell registry, not the flood);
|
// (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 IsLit = true; // SetLightHook latch
|
||||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||||
// false = static dat-baked bake (1/d³, range×1.3)
|
// false = static dat-baked bake (1/d³, range×1.3)
|
||||||
|
|
|
||||||
|
|
@ -1,612 +1,44 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace AcDream.Core.Rendering;
|
namespace AcDream.Core.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Permanent rendering diagnostics and retail render-route helpers.</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>
|
|
||||||
public static class RenderingDiagnostics
|
public static class RenderingDiagnostics
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
|
/// #176 stripe-hunt shader isolation mode. Zero disables the override;
|
||||||
/// <c>[indoor-walk]</c> line per visible cell entity per second:
|
/// values 1-3 retain the established lighting diagnostic modes.
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int LightDebugMode { get; set; } =
|
public static int LightDebugMode { get; set; } =
|
||||||
int.TryParse(
|
int.TryParse(
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"),
|
Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"),
|
||||||
NumberStyles.Integer,
|
NumberStyles.Integer,
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
out int ldm)
|
out int mode)
|
||||||
? ldm
|
? mode
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// S3 review fix round 1 (F5): ClipDebugNoShellTrim (ACDREAM_CLIP_DEBUG)
|
/// <summary>Returns true for AC indoor EnvCell ids.</summary>
|
||||||
// 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>
|
|
||||||
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
|
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse the <c>ACDREAM_DUMP_ENTITY</c> value: comma-separated hex ids,
|
/// Retail chooses the indoor path from the player's cell, provided the
|
||||||
/// optional 0x prefix, whitespace tolerated, malformed segments ignored
|
/// transition-owned render root is available.
|
||||||
/// (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>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
||||||
=> renderRootResolved && IsEnvCellId(playerCellId);
|
=> renderRootResolved && IsEnvCellId(playerCellId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Permanent frame-profiler toggle.</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>
|
|
||||||
public static bool FrameProfEnabled { get; set; } =
|
public static bool FrameProfEnabled { get; set; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Optional per-frame CSV history path for the frame profiler.</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>
|
|
||||||
public static string? FrameHistoryPath { get; } =
|
public static string? FrameHistoryPath { get; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
|
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): print-only walk-transcript
|
/// Print-only production walk transcript gate. RuntimeOptions owns the
|
||||||
/// emitter. When true, the production frame walk
|
/// sole environment read and assigns this property during construction.
|
||||||
/// (<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>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool DumpWalkTranscriptEnabled { get; set; }
|
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
|
// 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
|
// 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
|
// 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)
|
if (publishRenderRoot)
|
||||||
_physics.UpdatePlayerCurrCell(newCellId);
|
_physics.UpdatePlayerCurrCell(newCellId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,11 +325,6 @@ public sealed class HostInputCameraCompositionTests
|
||||||
GpuFrameFlightController? frameFlights,
|
GpuFrameFlightController? frameFlights,
|
||||||
IGpuDevice device) => frameFlights!;
|
IGpuDevice device) => frameFlights!;
|
||||||
|
|
||||||
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
|
|
||||||
GameWindowGraphics graphics,
|
|
||||||
IRenderFrameDiagnosticLog log) =>
|
|
||||||
new(new GlStateReader(), log);
|
|
||||||
|
|
||||||
public SilkKeyboardSource CreateKeyboardSource(
|
public SilkKeyboardSource CreateKeyboardSource(
|
||||||
IKeyboard keyboard,
|
IKeyboard keyboard,
|
||||||
HostQuiescenceGate quiescence) =>
|
HostQuiescenceGate quiescence) =>
|
||||||
|
|
@ -498,12 +493,6 @@ public sealed class HostInputCameraCompositionTests
|
||||||
public void Delete(nint fence) { }
|
public void Delete(nint fence) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class GlStateReader : IRenderGlStateReader
|
|
||||||
{
|
|
||||||
public RenderGlStateSnapshot CaptureState() => default;
|
|
||||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class DiagnosticLog : IRenderFrameDiagnosticLog
|
private sealed class DiagnosticLog : IRenderFrameDiagnosticLog
|
||||||
{
|
{
|
||||||
public void WriteLine(string message) { }
|
public void WriteLine(string message) { }
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ namespace AcDream.App.Tests.Rendering;
|
||||||
/// each class's <c>finally</c> sufficient.
|
/// each class's <c>finally</c> sufficient.
|
||||||
/// </para>
|
/// </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>
|
/// edge between at least two classes in this collection:</para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description><c>AcDream.Core.Rendering.CameraDiagnostics</c> —
|
/// <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>RetailChaseCamera.Update</c>, <c>CameraController.Active</c>,
|
||||||
/// <c>CameraFrameController</c>, <c>WorldRenderFrameBuilder</c> and
|
/// <c>CameraFrameController</c>, <c>WorldRenderFrameBuilder</c> and
|
||||||
/// <c>MouseLookController</c>.</description></item>
|
/// <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>
|
/// <item><description><c>AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled</c>
|
||||||
/// (Campaign OVERHAUL S3 chunk 1) — written by
|
/// (Campaign OVERHAUL S3 chunk 1) — written by
|
||||||
/// <c>WalkFrameDriverTests</c>' transcript-emitter tests
|
/// <c>WalkFrameDriverTests</c>' transcript-emitter tests
|
||||||
/// (<c>WalkFrameDriverTranscriptTests.cs</c>).</description></item>
|
/// (<c>WalkFrameDriverTranscriptTests.cs</c>).</description></item>
|
||||||
/// <item><description><c>System.Console.Out</c> — redirected via
|
/// <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
|
/// Interleaved redirection can restore a DISPOSED <c>StringWriter</c> as the
|
||||||
/// process-wide <c>Console.Out</c>, which then throws in unrelated
|
/// process-wide <c>Console.Out</c>, which then throws in unrelated
|
||||||
/// tests.</description></item>
|
/// tests.</description></item>
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ public sealed class ChargenPreviewControllerTests
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
|
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
_out.WriteLine("SKIP: dats unavailable");
|
_out.WriteLine("SKIP: dats unavailable");
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -87,7 +87,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
ChargenPreviewEntityBuilder.PreviewBackdropRenderId,
|
ChargenPreviewEntityBuilder.PreviewBackdropRenderId,
|
||||||
ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId);
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -121,7 +121,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuild_UnknownSetupId_ReturnsNull()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -145,7 +145,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -186,7 +186,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -232,7 +232,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildAnimated_UnknownSetupId_ReturnsNull()
|
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."); }
|
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);
|
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.
|
[InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key.
|
||||||
public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey)
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -297,7 +297,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -337,7 +337,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildBackdrop_AluvianHeritage_ResolvesANonEmptyMesh()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -376,7 +376,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildBackdrop_ExplicitRenderId_StampsThatIdOnTheEntity()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -405,7 +405,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildBackdrop_UnsetEnvironmentSetupId_ReturnsNull()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -419,7 +419,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildBackdrop_UnknownSetupId_ReturnsNull()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
@ -440,7 +440,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBuildBackdrop_AllThirteenHeritages_ResolveOrAreReportedByName()
|
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."); }
|
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);
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -7,30 +6,12 @@ namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
public class ClipFrameAssemblerTests
|
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]
|
[Fact]
|
||||||
public void BeginWalkFrame_OutdoorRoot_SeedsOneFullScreenDefaultView()
|
public void BeginWalkFrame_OutdoorRoot_SeedsOneFullScreenDefaultView()
|
||||||
{
|
{
|
||||||
using ClipFrame frame = ClipFrame.NoClip();
|
using ClipFrame frame = ClipFrame.NoClip();
|
||||||
|
|
||||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true);
|
||||||
frame,
|
|
||||||
outdoorRoot: true);
|
|
||||||
|
|
||||||
ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices);
|
ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices);
|
||||||
Assert.Equal(0, slice.Slot);
|
Assert.Equal(0, slice.Slot);
|
||||||
|
|
@ -45,9 +26,7 @@ public class ClipFrameAssemblerTests
|
||||||
public void BeginWalkFrame_InteriorRoot_ResetsReusedOutdoorAssemblyToEmpty()
|
public void BeginWalkFrame_InteriorRoot_ResetsReusedOutdoorAssemblyToEmpty()
|
||||||
{
|
{
|
||||||
using ClipFrame frame = ClipFrame.NoClip();
|
using ClipFrame frame = ClipFrame.NoClip();
|
||||||
ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(
|
ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true);
|
||||||
frame,
|
|
||||||
outdoorRoot: true);
|
|
||||||
|
|
||||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
||||||
frame,
|
frame,
|
||||||
|
|
@ -62,270 +41,4 @@ public class ClipFrameAssemblerTests
|
||||||
Assert.Equal(0, assembly.ScissorFallbacks);
|
Assert.Equal(0, assembly.ScissorFallbacks);
|
||||||
Assert.Equal(1, frame.SlotCount);
|
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,
|
particleRenderer: null,
|
||||||
portalDepthMask: null,
|
portalDepthMask: null,
|
||||||
Queue,
|
Queue,
|
||||||
(WorldRenderDiagnostics)RuntimeHelpers.GetUninitializedObject(typeof(WorldRenderDiagnostics)),
|
|
||||||
(TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject(
|
(TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject(
|
||||||
typeof(TerrainDrawDiagnosticsController)));
|
typeof(TerrainDrawDiagnosticsController)));
|
||||||
Leaf = new WalkProductionLeafRenderer(
|
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(
|
_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}"));
|
$"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
|
// Orbit structure: 16 consecutive frames at 6dp.
|
||||||
// own [flap-sweep] lines captured for the same frames.
|
for (int i = 0; i < 16; i++)
|
||||||
bool savedFlap = RenderingDiagnostics.ProbeFlapEnabled;
|
|
||||||
var savedOut = Console.Out;
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
RenderingDiagnostics.ProbeFlapEnabled = true;
|
Step();
|
||||||
using var writer = new StringWriter();
|
_out.WriteLine(FormattableString.Invariant(
|
||||||
Console.SetOut(writer);
|
$"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
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 System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Rendering;
|
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
|
public class OutdoorCellNodeTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_ReturnsPortallessOutdoorRoot()
|
public void Build_ReturnsPortallessOutdoorRoot()
|
||||||
{
|
{
|
||||||
uint outdoorId = 0xA9B40031;
|
uint outdoorId = 0xA9B40031;
|
||||||
var node = OutdoorCellNode.Build(outdoorId);
|
LoadedCell node = OutdoorCellNode.Build(outdoorId);
|
||||||
|
|
||||||
Assert.Equal(outdoorId, node.CellId);
|
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.True(node.SeenOutside);
|
||||||
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
||||||
Assert.Empty(node.Portals); // R-A2: no reverse portals into buildings (buildings flood per-building)
|
Assert.Empty(node.Portals);
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 frames = new GpuDeviceFrameLifetime(device);
|
||||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||||
using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope);
|
using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope);
|
||||||
var diagnostics = new WorldRenderDiagnostics(new NeverCalledGlStateReader(), new NeverCalledDiagnosticLog());
|
|
||||||
|
|
||||||
// DrawPortalDepthWrite only reads _portalDepthMask, frame.Cells,
|
// DrawPortalDepthWrite only reads _portalDepthMask, frame.Cells,
|
||||||
// frame.RootCell.IsOutdoorNode, frame.ViewProjection, and
|
// frame.RootCell.IsOutdoorNode, and frame.ViewProjection — the same
|
||||||
// _diagnostics (short-circuited off by RenderingDiagnostics.
|
|
||||||
// ProbeSeamDrawEnabled's default-off value) — the same
|
|
||||||
// constructor-bypass pattern WalkOutsideViewReassemblyTests already
|
// constructor-bypass pattern WalkOutsideViewReassemblyTests already
|
||||||
// uses for exercising a real leaf without a full GL/DAT renderer
|
// uses for exercising a real leaf without a full GL/DAT renderer
|
||||||
// graph.
|
// graph.
|
||||||
|
|
@ -578,10 +574,6 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
typeof(RetailPViewPassExecutor)
|
typeof(RetailPViewPassExecutor)
|
||||||
.GetField("_portalDepthMask", BindingFlags.NonPublic | BindingFlags.Instance)!
|
.GetField("_portalDepthMask", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||||
.SetValue(executor, portalDepthMask);
|
.SetValue(executor, portalDepthMask);
|
||||||
typeof(RetailPViewPassExecutor)
|
|
||||||
.GetField("_diagnostics", BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
||||||
.SetValue(executor, diagnostics);
|
|
||||||
|
|
||||||
var root = new LoadedCell { CellId = 0xF4180003u, IsOutdoorNode = false };
|
var root = new LoadedCell { CellId = 0xF4180003u, IsOutdoorNode = false };
|
||||||
RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset(
|
RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset(
|
||||||
rootCell: root,
|
rootCell: root,
|
||||||
|
|
@ -625,21 +617,6 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
public LoadedCell? Find(uint cellId) => cellId == cell.CellId ? cell : null;
|
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(
|
private static int RequiredCallIndex(
|
||||||
IReadOnlyList<CompiledCall> calls,
|
IReadOnlyList<CompiledCall> calls,
|
||||||
Type declaringType,
|
Type declaringType,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
{
|
{
|
||||||
var log = new RecordingLog();
|
var log = new RecordingLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(false, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(false, world, facts, log);
|
||||||
|
|
||||||
controller.Begin();
|
controller.Begin();
|
||||||
|
|
@ -25,7 +25,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
{
|
{
|
||||||
var log = new ThrowOnceLog();
|
var log = new ThrowOnceLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
controller.Begin();
|
controller.Begin();
|
||||||
|
|
@ -47,7 +47,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
{
|
{
|
||||||
var log = new ThrowOnFirstFrameLog();
|
var log = new ThrowOnFirstFrameLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
controller.Begin();
|
controller.Begin();
|
||||||
|
|
@ -70,7 +70,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
{
|
{
|
||||||
var log = new RecordingLog();
|
var log = new RecordingLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
controller.Begin();
|
controller.Begin();
|
||||||
|
|
@ -99,7 +99,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
{
|
{
|
||||||
var log = new RecordingLog();
|
var log = new RecordingLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
controller.AccumulateWalkBatch(1_000);
|
controller.AccumulateWalkBatch(1_000);
|
||||||
|
|
@ -121,7 +121,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
// not depend on AccumulateWalkBatch ever having been called.
|
// not depend on AccumulateWalkBatch ever having been called.
|
||||||
var log = new RecordingLog();
|
var log = new RecordingLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
controller.CompleteWalkFrame(10_000);
|
controller.CompleteWalkFrame(10_000);
|
||||||
|
|
@ -135,7 +135,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
||||||
{
|
{
|
||||||
var log = new RecordingLog();
|
var log = new RecordingLog();
|
||||||
var facts = new RecordingFacts();
|
var facts = new RecordingFacts();
|
||||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
var world = new WorldRenderDiagnostics(log);
|
||||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||||
|
|
||||||
controller.AccumulateWalkBatch(5_000);
|
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
|
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
||||||
{
|
{
|
||||||
public List<string> Messages { get; } = [];
|
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]
|
[Fact]
|
||||||
public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus()
|
public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
using var dats = new DatCollection(datDir!, DatAccessType.Read);
|
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(
|
public void Traced_lookin_cells_are_reachable_through_the_building_portal_table(
|
||||||
uint buildingCellId, uint[] tracedLookInCells)
|
uint buildingCellId, uint[] tracedLookInCells)
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
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));
|
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
||||||
|
|
||||||
// Exit seals replay through the exact portal_view slices captured by
|
// 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(0));
|
||||||
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1));
|
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1));
|
||||||
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length);
|
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="RenderingDiagnostics.DumpWalkTranscriptEnabled"/> and
|
||||||
/// <see cref="Console.Out"/> are both process-wide mutable statics — joins
|
/// <see cref="Console.Out"/> are both process-wide mutable statics — joins
|
||||||
/// <see cref="CameraDiagnosticsCollection"/> for the SAME reason
|
/// <see cref="CameraDiagnosticsCollection"/> for the SAME reason
|
||||||
/// <c>CornerFloodReplayTests</c>/<c>Issue181WallPressEquilibriumTests</c> do
|
/// <c>Issue181WallPressEquilibriumTests</c> does (that collection's own doc
|
||||||
/// (that collection's own doc comment, issue #251): interleaved
|
/// comment, issue #251): interleaved
|
||||||
/// <c>Console.SetOut</c> redirection across parallel test classes can
|
/// <c>Console.SetOut</c> redirection across parallel test classes can
|
||||||
/// restore a disposed <see cref="StringWriter"/> process-wide.
|
/// restore a disposed <see cref="StringWriter"/> process-wide.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ public sealed class WalkLookInGateSweepTests
|
||||||
+ "(the doorway-still fixture tests it in isolation).")]
|
+ "(the doorway-still fixture tests it in isolation).")]
|
||||||
public void Sweep_the_lookin_gate_decodes_against_the_street_fixture()
|
public void Sweep_the_lookin_gate_decodes_against_the_street_fixture()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ public sealed class WalkPortalGateDumpTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dump_the_gate_inputs_for_a_punching_and_a_silent_building()
|
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)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
|
@ -102,7 +102,7 @@ public sealed class WalkPortalGateDumpTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dump_the_exit_view_look_in_inputs_for_walkout_f2()
|
public void Dump_the_exit_view_look_in_inputs_for_walkout_f2()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
|
@ -232,7 +232,7 @@ public sealed class WalkPortalGateDumpTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dump_the_foundry_f67_bsp_traversal_for_0036()
|
public void Dump_the_foundry_f67_bsp_traversal_for_0036()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ public sealed class WalkProductionWorldConformanceTests
|
||||||
|
|
||||||
private static DatCollection OpenDats()
|
private static DatCollection OpenDats()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
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);
|
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) =>
|
private static RenderProjectionRecord Record(uint id, Vector3 position) =>
|
||||||
new RenderProjectionRecord() with
|
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(
|
Assert.DoesNotContain(
|
||||||
"one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase);
|
"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(
|
int apSectionStart = register.IndexOf(
|
||||||
"## 3. Documented approximation (AP)", StringComparison.Ordinal);
|
"## 3. Documented approximation (AP)", StringComparison.Ordinal);
|
||||||
int apSectionEnd = register.IndexOf(
|
int apSectionEnd = register.IndexOf(
|
||||||
|
|
@ -342,7 +342,7 @@ public sealed class WalkStaticStreamPopulatorTests
|
||||||
Assert.Single(File.ReadLines(registerPath), static line =>
|
Assert.Single(File.ReadLines(registerPath), static line =>
|
||||||
line.StartsWith("| AP-244 |", StringComparison.Ordinal));
|
line.StartsWith("| AP-244 |", StringComparison.Ordinal));
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
161,
|
160,
|
||||||
File.ReadLines(registerPath).Count(static line =>
|
File.ReadLines(registerPath).Count(static line =>
|
||||||
line.StartsWith("| AP-", StringComparison.Ordinal)));
|
line.StartsWith("| AP-", StringComparison.Ordinal)));
|
||||||
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
|
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ public sealed partial class WalkTraceConformanceTests
|
||||||
|
|
||||||
private static DatCollection OpenDats()
|
private static DatCollection OpenDats()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
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()
|
private static DatCollection OpenDats()
|
||||||
{
|
{
|
||||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
{
|
{
|
||||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||||
|
|
|
||||||
|
|
@ -39,21 +39,13 @@ public class WalkOutsideViewReassemblyTests
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ClipFrameAssembly AssembledWithOldOutside()
|
private static ClipFrameAssembly BeginAssembly()
|
||||||
{
|
=> ClipFrameAssembler.BeginWalkFrame(ClipFrame.NoClip(), outdoorRoot: true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PixelDoorway_MapsToExpectedNdcAabbAndPlanes()
|
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]
|
// 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).
|
// (yNdc = 1 − 2·py/H flips the axis: py=180 → +0.5, py=360 → 0).
|
||||||
WalkPortalView walkView = WalkViewOfPixelQuads(new[]
|
WalkPortalView walkView = WalkViewOfPixelQuads(new[]
|
||||||
|
|
@ -95,7 +87,7 @@ public class WalkOutsideViewReassemblyTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FullViewportWalkQuad_CoversFullNdc()
|
public void FullViewportWalkQuad_CoversFullNdc()
|
||||||
{
|
{
|
||||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
ClipFrameAssembly asm = BeginAssembly();
|
||||||
var walkView = new WalkPortalView();
|
var walkView = new WalkPortalView();
|
||||||
Assert.True(WalkCopyView.AppendFullViewportQuad(
|
Assert.True(WalkCopyView.AppendFullViewportQuad(
|
||||||
walkView, new StubRays(), Vector3.Zero, W, H));
|
walkView, new StubRays(), Vector3.Zero, W, H));
|
||||||
|
|
@ -109,8 +101,8 @@ public class WalkOutsideViewReassemblyTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EmptyWalkView_YieldsSkipMode()
|
public void EmptyWalkView_YieldsSkipMode()
|
||||||
{
|
{
|
||||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
ClipFrameAssembly asm = BeginAssembly();
|
||||||
Assert.True(asm.OutdoorVisible); // the old view was visible pre-cutover
|
Assert.True(asm.OutdoorVisible);
|
||||||
|
|
||||||
ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, new WalkPortalView(), W, H);
|
ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, new WalkPortalView(), W, H);
|
||||||
|
|
||||||
|
|
@ -138,7 +130,7 @@ public class WalkOutsideViewReassemblyTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned()
|
public void FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned()
|
||||||
{
|
{
|
||||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
ClipFrameAssembly asm = BeginAssembly();
|
||||||
|
|
||||||
var walkView = new WalkPortalView();
|
var walkView = new WalkPortalView();
|
||||||
// View 0: three EXACTLY collinear pixel points -> zero-area triangle,
|
// View 0: three EXACTLY collinear pixel points -> zero-area triangle,
|
||||||
|
|
@ -196,7 +188,7 @@ public class WalkOutsideViewReassemblyTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange()
|
public void PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange()
|
||||||
{
|
{
|
||||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
ClipFrameAssembly asm = BeginAssembly();
|
||||||
var walkView = new WalkPortalView();
|
var walkView = new WalkPortalView();
|
||||||
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) });
|
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) });
|
||||||
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) });
|
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) });
|
||||||
|
|
@ -356,7 +348,7 @@ public class WalkOutsideViewReassemblyTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible()
|
public void NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible()
|
||||||
{
|
{
|
||||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
ClipFrameAssembly asm = BeginAssembly();
|
||||||
|
|
||||||
const int n = 9;
|
const int n = 9;
|
||||||
var verts = new Vector2[n];
|
var verts = new Vector2[n];
|
||||||
|
|
@ -387,7 +379,7 @@ public class WalkOutsideViewReassemblyTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots()
|
public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots()
|
||||||
{
|
{
|
||||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
ClipFrameAssembly asm = BeginAssembly();
|
||||||
WalkPortalView walkView = WalkViewOfPixelQuads(
|
WalkPortalView walkView = WalkViewOfPixelQuads(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,194 +1,29 @@
|
||||||
using System.Numerics;
|
|
||||||
using System.Reflection;
|
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Core.Vfx;
|
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Rendering;
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
public sealed class WorldRenderDiagnosticsTests
|
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]
|
[Fact]
|
||||||
public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
|
public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
|
||||||
{
|
{
|
||||||
var log = new ThrowOnceLog();
|
var log = new ThrowOnceLog();
|
||||||
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
var diagnostics = new WorldRenderDiagnostics(log);
|
||||||
var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8);
|
var facts = new TerrainRenderDiagnosticFacts(3, 3, 5, 8);
|
||||||
|
|
||||||
diagnostics.BeginTerrainDraw();
|
diagnostics.BeginTerrainDraw();
|
||||||
diagnostics.EndTerrainDraw();
|
diagnostics.EndTerrainDraw();
|
||||||
Assert.Throws<InvalidOperationException>(() =>
|
Assert.Throws<InvalidOperationException>(() => diagnostics.PublishTerrainDiagnostics(facts));
|
||||||
diagnostics.PublishTerrainDiagnostics(facts));
|
|
||||||
|
|
||||||
diagnostics.BeginTerrainDraw();
|
diagnostics.BeginTerrainDraw();
|
||||||
diagnostics.EndTerrainDraw();
|
diagnostics.EndTerrainDraw();
|
||||||
diagnostics.PublishTerrainDiagnostics(facts);
|
diagnostics.PublishTerrainDiagnostics(facts);
|
||||||
|
|
||||||
Assert.Single(log.Messages);
|
string message = Assert.Single(log.Messages);
|
||||||
Assert.Contains("draws=3/frame", log.Messages[0]);
|
Assert.Contains("draws=3/frame", message);
|
||||||
Assert.Contains("visible=3", log.Messages[0]);
|
Assert.Contains("visible=3", message);
|
||||||
Assert.Contains("loaded=5", log.Messages[0]);
|
Assert.Contains("loaded=5", message);
|
||||||
Assert.Contains("capacity=8", log.Messages[0]);
|
Assert.Contains("capacity=8", message);
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
||||||
|
|
@ -207,33 +42,4 @@ public sealed class WorldRenderDiagnosticsTests
|
||||||
Messages.Add(message);
|
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",
|
"passes:disable-clip",
|
||||||
"particles:global",
|
"particles:global",
|
||||||
"flat:weather",
|
"flat:weather",
|
||||||
"diagnostics:signature:OutdoorRoot",
|
|
||||||
"diagnostics:draw",
|
"diagnostics:draw",
|
||||||
"alpha:end",
|
"alpha:end",
|
||||||
"visibility:complete",
|
"visibility:complete",
|
||||||
|
|
@ -238,10 +237,8 @@ public sealed class WorldSceneRendererTests
|
||||||
"passes:begin",
|
"passes:begin",
|
||||||
"pview:draw",
|
"pview:draw",
|
||||||
"visibility:mark",
|
"visibility:mark",
|
||||||
"diagnostics:pview",
|
|
||||||
"passes:disable-clip",
|
"passes:disable-clip",
|
||||||
"particles:none",
|
"particles:skipped",
|
||||||
"diagnostics:signature:RetailPViewInside",
|
|
||||||
"diagnostics:draw",
|
"diagnostics:draw",
|
||||||
"alpha:end",
|
"alpha:end",
|
||||||
"visibility:complete",
|
"visibility:complete",
|
||||||
|
|
@ -307,7 +304,7 @@ public sealed class WorldSceneRendererTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
|
public void OutdoorPView_SkipsPostWorldParticleReplayAndFlatWeather()
|
||||||
{
|
{
|
||||||
var root = new LoadedCell
|
var root = new LoadedCell
|
||||||
{
|
{
|
||||||
|
|
@ -319,7 +316,7 @@ public sealed class WorldSceneRendererTests
|
||||||
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
Assert.True(result.NormalWorldDrawn);
|
Assert.True(result.NormalWorldDrawn);
|
||||||
Assert.Contains("particles:pviewScoped", rig.Calls);
|
Assert.Contains("particles:skipped", rig.Calls);
|
||||||
Assert.DoesNotContain("flat:weather", rig.Calls);
|
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -498,7 +495,6 @@ public sealed class WorldSceneRendererTests
|
||||||
Type[] borrowedTypes =
|
Type[] borrowedTypes =
|
||||||
[
|
[
|
||||||
typeof(RetailPViewFrameResult),
|
typeof(RetailPViewFrameResult),
|
||||||
typeof(PortalVisibilityFrame),
|
|
||||||
typeof(ClipFrameAssembly),
|
typeof(ClipFrameAssembly),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -690,9 +686,7 @@ public sealed class WorldSceneRendererTests
|
||||||
new WbFrustum());
|
new WbFrustum());
|
||||||
_clipFrame = ClipFrame.NoClip();
|
_clipFrame = ClipFrame.NoClip();
|
||||||
|
|
||||||
var glDiagnostics = new WorldRenderDiagnostics(
|
var glDiagnostics = new WorldRenderDiagnostics(new NullDiagnosticLog());
|
||||||
new DefaultGlStateReader(),
|
|
||||||
new NullDiagnosticLog());
|
|
||||||
var terrainDiagnostics = new TerrainDrawDiagnosticsController(
|
var terrainDiagnostics = new TerrainDrawDiagnosticsController(
|
||||||
enabled: false,
|
enabled: false,
|
||||||
glDiagnostics,
|
glDiagnostics,
|
||||||
|
|
@ -710,7 +704,6 @@ public sealed class WorldSceneRendererTests
|
||||||
particleRenderer: null,
|
particleRenderer: null,
|
||||||
portalDepthMask: null,
|
portalDepthMask: null,
|
||||||
alpha,
|
alpha,
|
||||||
glDiagnostics,
|
|
||||||
terrainDiagnostics);
|
terrainDiagnostics);
|
||||||
|
|
||||||
const uint landblockId = 0xF4180000u;
|
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
|
private sealed class NullDiagnosticLog : IRenderFrameDiagnosticLog
|
||||||
{
|
{
|
||||||
public void WriteLine(string message) { }
|
public void WriteLine(string message) { }
|
||||||
|
|
@ -1093,20 +1079,13 @@ public sealed class WorldSceneRendererTests
|
||||||
uint? playerLandblockId,
|
uint? playerLandblockId,
|
||||||
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
|
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
|
||||||
|
|
||||||
public string DrawPostWorldParticles(
|
public void DrawPostWorldParticles(
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
ClipFrameAssembly? clipAssembly,
|
ClipFrameAssembly? clipAssembly,
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera)
|
||||||
string currentSignature)
|
|
||||||
{
|
{
|
||||||
string kind = clipRoot switch
|
string kind = clipRoot is null ? "global" : "skipped";
|
||||||
{
|
|
||||||
null => "global",
|
|
||||||
{ IsOutdoorNode: true } => currentSignature,
|
|
||||||
_ => currentSignature,
|
|
||||||
};
|
|
||||||
calls.Add($"particles:{kind}");
|
calls.Add($"particles:{kind}");
|
||||||
return kind;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DrawFlatWeather(
|
public void DrawFlatWeather(
|
||||||
|
|
@ -1135,37 +1114,6 @@ public sealed class WorldSceneRendererTests
|
||||||
{
|
{
|
||||||
public CameraCellResolution CameraCellResolution => CameraCellResolution.None;
|
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(
|
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||||
in WorldCameraFrame camera,
|
in WorldCameraFrame camera,
|
||||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||||
|
|
|
||||||
|
|
@ -5,175 +5,24 @@ namespace AcDream.Core.Tests.Rendering;
|
||||||
|
|
||||||
public sealed class RenderingDiagnosticsTests
|
public sealed class RenderingDiagnosticsTests
|
||||||
{
|
{
|
||||||
// Each flag-mutating test snapshots the IndoorAll state on entry and
|
[Theory]
|
||||||
// restores it via try/finally. RenderingDiagnostics is a process-wide
|
[InlineData(0x00000029ul, false)]
|
||||||
// static (env-var-initialized); without restoration a mutated state
|
[InlineData(0xA9B40029ul, false)]
|
||||||
// leaks into other tests + into parallel test runs. Mirrors the
|
[InlineData(0x00000100ul, true)]
|
||||||
// PhysicsDiagnosticsTests pattern at line 30-49.
|
[InlineData(0xA9B40105ul, true)]
|
||||||
|
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
|
||||||
[Fact]
|
=> Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
|
||||||
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]
|
[Theory]
|
||||||
[InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid
|
[InlineData(0xA9B40031u, true, false)]
|
||||||
[InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix
|
[InlineData(0xA9B40171u, true, true)]
|
||||||
[InlineData(0x00000100ul, true)] // indoor cell minimum
|
[InlineData(0xA9B40171u, false, false)]
|
||||||
[InlineData(0x00000105ul, true)] // typical Holtburg Inn interior
|
[InlineData(0u, true, false)]
|
||||||
[InlineData(0xA9B40105ul, true)] // indoor with landblock prefix
|
public void ShouldRenderIndoor_UsesPlayerCellAndResolvedRoot(
|
||||||
[InlineData(0xA9B401FFul, true)] // indoor near top of range
|
uint playerCellId,
|
||||||
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
|
bool renderRootResolved,
|
||||||
{
|
bool expected)
|
||||||
Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
|
=> Assert.Equal(
|
||||||
}
|
expected,
|
||||||
|
RenderingDiagnostics.ShouldRenderIndoor(playerCellId, renderRootResolved));
|
||||||
// ── 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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